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:
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()