udp-holepunch - Python script for UDP holepunching using a webserver. With an example of SSH using this and sctp-echo.

git clone https://benconnors.ca/git-repos/udp-holepunch

About | Log | Files | Refs

commit 53ab5b4222fd3ad90ac14f38606ecc6025bae003
parent d99f969405fe754cd41142ec2400ef56391f3daa
Author: Ben Connors <benconnors@outlook.com>
Date:   Thu, 30 Jul 2026 13:02:12 -0400

Add example for SSH holepunching

Diffstat:
A.gitignore | 1+
MREADME.md | 28+++++++++++++++++++++++++++-
Assh_holepunch_client.py | 77+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Assh_holepunch_server.py | 106+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 211 insertions(+), 1 deletion(-)

diff --git a/.gitignore b/.gitignore @@ -0,0 +1 @@ +/__pycache__/* diff --git a/README.md b/README.md @@ -40,7 +40,7 @@ Once holepunching has been established, both sides print out (if successful): ``` <local UDP port> <remote IP> <remote UDP port> ``` -The downstream application should open a UDP socket with port `local UDP port` and send messages to `remote IP:remote UDP port`. +The downstream applications on either end should open a UDP socket with port `local UDP port` and send messages to `remote IP:remote UDP port`. ## Setup @@ -58,3 +58,29 @@ The downstream application should open a UDP socket with port `local UDP port` a ``` udp_holepunch.py client example-com-ssh-name /var/www/poll-file ``` + +## Example: SSH without open ports +We can use holepunching and [sctp-echo](/cgit/sctp-echo) to establish a connection to an SSH server without opening/forwarding any ports on the server side. + +The ideal simple solution is, once UDP holepunching is established as above, to use `socat` on each end: +``` +client: socat -b 1150 - UDP:<remote ip>:<remote port>,sourceport=<my port> +server: socat UDP-LISTEN:<my port> TCP-CONNECT:127.0.0.1:<ssh port> +``` +where the client `socat` reads from `stdin`, for use with SSH's `ProxyCommand` function. This will work for about a minute (the `-b 1150` option on the client is important: larger options tend to break faster, smaller break SSH). The difficulty lies with `socat` and is irreparable. We have three connections: +``` +client SSH --A-> client socat --B-> server socat --C-> server SSH +``` +Connections `A` and `C` are both entirely reliable: `A` uses file descriptors and `C` uses TCP. Connection `B`, however, uses UDP and is not reliable. The result is that the whole connection `client SSH -> server SSH` is unreliable, yet since SSH is designed to run over TCP it has no facilities for dealing with this. At the first lost, corrupted, etc. packet the SSH session will be terminated. + +We fix connection `B` using SCTP-over-UDP via [sctp-echo](/cgit/sctp-echo). For the reasons described there, we require the use of [usrsctp](https://github.com/Kurento/libusrsctp) rather than the kernel implementations of SCTP. + +The two files `ssh_holepunch_client.py` and `ssh_holepunch_server.py` implement the two sides of this. We use `ssh_holepunch_server.py` just as `udp_holepunch.py server`, except it will open a connection to the SSH server once holepunching has been established. + +`ssh_holepunch_client.py` is intended to be called by SSH itself so that on the client side this whole process happens transparently. Inside `ssh_config`: +``` +Host <server name> + ProxyCommand "<path to ssh_holepunch_client.py> ... %h" + ConnectTimeout 65 +``` +setting `ConnectTimeout` long enough in relation to the frequency that `ssh_holepunch_server.py` is run on the server (a minute or two unless you are very patient). diff --git a/ssh_holepunch_client.py b/ssh_holepunch_client.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 + +import argparse as ap +import subprocess as subp +import sys + +from udp_holepunch import udp_holepunch_client + +if __name__ == "__main__": + parser = ap.ArgumentParser( + description="Run SSH through UDP with holepunching (client)" + ) + + parser.add_argument( + "-l", "--local-port", + type=int, + default=0, + help="Fixed internal UDP port for us to use (defaults to 0 = auto-assigned by system)" + ) + parser.add_argument( + "-p", "--server-port", + type=int, + default=0, + help="Fixed UDP port to use to start holepunching (defaults to 0 to use our external UDP port number)" + ) + parser.add_argument( + "-s", "--skip-ack", + action="store_true", + default=False, + help="Skip hello packet acknowledgement to finalize holepunching (a couple of the first packets down the line may get lost)", + ) + parser.add_argument( + "-e", "--sctp-echo", + default="sctp_echo", + help="Path to invoke sctp_echo" + ) + + parser.add_argument( + "server_name", + help="Name of the intermediate server (for SSH)" + ) + parser.add_argument( + "server_path", + help="Path of file to write on the intermediate server (e.g. /var/www/inter)" + ) + parser.add_argument( + "ssh_name", + help="Name of SSH server to connect to (%%h if in ProxyCommand)", + ) + + args = parser.parse_args() + + ## 1. Holepunch + local_port, remote_ip, remote_port = udp_holepunch_client( + args.local_port, + args.server_name, + args.server_port, + args.server_path, + no_ack=args.skip_ack + ) + + ## 2. Run the client + client = subp.Popen( + [ + args.sctp_echo, + "-c", + "0.0.0.0", ## Local IP + str(local_port), ## Local UDP + str(local_port), ## Local SCTP = UDP + remote_ip, ## Remote IP + str(remote_port), ## Remote UDP port + str(remote_port), ## Remote SCTP port = UDP + ], + stdout=sys.stdout.buffer.raw, ## Must use raw or e.g. vi will crash it + stdin=sys.stdin.buffer.raw, + ) + client.communicate() diff --git a/ssh_holepunch_server.py b/ssh_holepunch_server.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 + +import argparse as ap +import subprocess as subp +import sys + +from udp_holepunch import udp_holepunch_server + +if __name__ == "__main__": + parser = ap.ArgumentParser( + description="Run SSH through UDP with holepunching (server)" + ) + + parser.add_argument( + "-l", "--local-port", + type=int, + default=0, + help="Fixed internal UDP port for us to use (defaults to 0 = auto-assigned by system)" + ) + parser.add_argument( + "--local-ip", + default="0.0.0.0", + help="Local IP to listen on (defaults to 0.0.0.0 = all)", + ) + parser.add_argument( + "-p", "--server-port", + type=int, + default=0, + help="Fixed UDP port to use to start holepunching (defaults to 0 to use our external UDP port number)" + ) + parser.add_argument( + "-t", "--timeout", + type=int, + default=60, + help="Inactivity timeout once holepunching has been established (defaults to 60)", + ) + parser.add_argument( + "-s", "--skip-ack", + action="store_true", + default=False, + help="Skip hello packet acknowledgement to finalize holepunching (a couple of the first packets down the line may get lost)", + ) + parser.add_argument( + "-e", "--sctp-echo", + default="sctp_echo", + help="Path to invoke sctp_echo" + ) + parser.add_argument( + "--ssh-port", + type=int, + default=22, + help="SSH port on the local machine (defaults to 22)", + ) + + parser.add_argument( + "server_path", + help="Path of file to write on the intermediate server (e.g. /var/www/inter)" + ) + + args = parser.parse_args() + + ## 1. Holepunch + ret = udp_holepunch_server( + args.local_port, + args.server_port, + args.server_path, + no_ack=args.skip_ack + ) + + if ret is None: + ## Nothing on the intermediate server, just exit + sys.exit(0) + + local_port, remote_ip, remote_port = ret + + ## 2. Run the server + server = subp.Popen( + [ + args.sctp_echo, + "-s", + "0.0.0.0", ## Local IP + str(local_port), ## Local UDP + str(local_port), ## Local SCTP = UDP + remote_ip, ## Remote IP + str(remote_port), ## Remote SCTP port + ], + stdout=subp.PIPE, + stdin=subp.PIPE, + ) + + ## 3. Use socat to make the actual connection + socat = subp.Popen( + [ + "socat", + "-T", str(args.timeout), + "-", + "TCP4:127.0.0.1:%d" % args.ssh_port, + ], + stdin=server.stdout, + stdout=server.stdin, + ) + + ## Communicate with socat; this lets us bypass the fact that sctp-echo doesn't + ## have timeout functionality + socat.communicate() + server.terminate()