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 edbe28521348adf794b914c3372988b2c94623ed
parent 798c8cfbe9d54994b948b7680cf5b7da1660786a
Author: Ben Connors <benconnors@outlook.com>
Date:   Sun,  2 Aug 2026 14:43:32 -0400

Add SSH key signing for poll files

Diffstat:
MREADME.md | 7++++++-
Mssh_holepunch_client.py | 9++++++++-
Mssh_holepunch_server.py | 13++++++++++++-
Mudp_holepunch.py | 105+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
4 files changed, 123 insertions(+), 11 deletions(-)

diff --git a/README.md b/README.md @@ -11,7 +11,7 @@ Using `tcpdump` on the intermediate server we can read incoming packets without We do not use a sophisticated form of UDP holepunching: this is intended to be run on single friendly NATs; the main purpose is to establish the connection without opening any ports on the firewall. -Our UDP packets do not attempt to mimic any legitimate protocol: any firewall with some sort of restrictive DPI will likely block these. UDP holepunching in general will always be susceptible to this since the established communication link between client and server will almost certainly be using non-standard UDP ports. +Our UDP packets do not attempt to mimic any legitimate protocol: any firewall with some sort of restrictive DPI will likely block these. UDP holepunching in general will always be susceptible to this since the established communication link between client and server will almost certainly be using non-standard UDP ports since applications do not in general have any control over what external port the NAT assigns. ## Requirements ### Intermediate @@ -59,6 +59,11 @@ The downstream applications on either end should open a UDP socket with port `lo udp_holepunch.py client example-com-ssh-name /var/www/poll-file ``` +### Security +The client can sign the information it puts to the webserver using an SSH key (specified via `-k`). The server can accept a list of allowed public keys passed as a comma-separated list to `-k` in the same form as found in the key's `.pub` file. The server will exit with an error if a missing or invalid signature is found when polling. + +Signing and verifying with SSH keys can be done via `ssh-keygen` without any external tools like GnuPG, so this does not pull in any additional dependencies. + ## 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. diff --git a/ssh_holepunch_client.py b/ssh_holepunch_client.py @@ -34,6 +34,12 @@ if __name__ == "__main__": default="sctp_echo", help="Path to invoke sctp_echo" ) + parser.add_argument( + "-k", "--signing-key", + type=str, + default=None, + help="Path to the SSH key used to sign postings on the webserver (can be different from the login key" + ) parser.add_argument( "server_name", @@ -56,7 +62,8 @@ if __name__ == "__main__": args.server_name, args.server_port, args.server_path, - no_ack=args.skip_ack + no_ack=args.skip_ack, + key_path=args.signing_key, ) ## 2. Run the client diff --git a/ssh_holepunch_server.py b/ssh_holepunch_server.py @@ -51,6 +51,12 @@ if __name__ == "__main__": default=22, help="SSH port on the local machine (defaults to 22)", ) + parser.add_argument( + "-k", "--allowed-keys", + type=str, + default=None, + help="Comma-separated list of allowed SSH public keys to verify postings on the webserver, as stored in e.g. authorized_keys" + ) parser.add_argument( "server_path", @@ -59,12 +65,17 @@ if __name__ == "__main__": args = parser.parse_args() + allowed_keys = None + if args.allowed_keys is not None: + allowed_keys = args.allowed_keys.split(',') + ## 1. Holepunch ret = udp_holepunch_server( args.local_port, args.server_port, args.server_path, - no_ack=args.skip_ack + no_ack=args.skip_ack, + allowed_keys=allowed_keys, ) if ret is None: diff --git a/udp_holepunch.py b/udp_holepunch.py @@ -1,13 +1,66 @@ #!/usr/bin/env python3 import argparse as ap -import requests as r +import base64 as b64 +import os import socket as s import subprocess as subp import sys +import tempfile import time import threading +import requests as r + +def ssh_sign(message, key_path, namespace="udp-holepunch"): + """Sign a message using ssh-keygen.""" + return subp.check_output(["ssh-keygen", "-f", key_path, "-Y", "sign", "-n", namespace], input=message, text=True, stderr=subp.DEVNULL) + +def ssh_verify(message, signature, allowed_keys, namespace="udp-holepunch"): + """Verify a message using ssh-keygen. + + `allowed_keys` should be a list of keys of the form + + ssh-whatever encodedpublickeyhere + """ + allowed_keys_fix = [] + principals = set() + for k in allowed_keys: + k = k.strip() + if k.count(' ') < 2: + ## No principal + i = 0 + while "p%d" % i in principals: + i += 1 + principals.add("p%d" % i) + allowed_keys_fix.append(("p%d " % i) + k) + else: + a, b, principal = k.split(' ') + principals.add(principal) + allowed_keys_fix.append(' '.join((principal, a, b))) + + with tempfile.TemporaryDirectory() as td: + valid_f = os.path.join(td, "valid") + with open(valid_f, 'w') as f: + f.write('\n'.join(allowed_keys_fix)) + + signature_f = os.path.join(td, "signature") + with open(signature_f, 'w') as f: + f.write(signature) + + for p in principals: + ## Inexplicably, check_call doesn't have `input` + try: + subp.check_output(["ssh-keygen", "-f", valid_f, "-Y", "verify", "-n", namespace, "-s", signature_f, "-I", p], input=message, text=True, stderr=subp.DEVNULL) + except subp.CalledProcessError: + continue + else: + break + else: + return False + + return True + def udp_holepunch_loop(sock, other_addr, no_ack=False): """Finalize the UDP holepunching. @@ -48,7 +101,7 @@ def udp_holepunch_loop(sock, other_addr, no_ack=False): except TimeoutError: break -def udp_holepunch_server(local_port, server_port, server_path, no_ack=False): +def udp_holepunch_server(local_port, server_port, server_path, no_ack=False, allowed_keys=None): """Serve as the UDP holepunching "server". Poll the given `server_path`; if a message is found from `udp_holepunch_client`, begin the @@ -65,8 +118,19 @@ def udp_holepunch_server(local_port, server_port, server_path, no_ack=False): server_ip = resp.raw._connection.sock.getpeername()[0] - ## TODO: Add some sort of authentication here - other_ip, other_port = resp.text.strip().split(':') + info = resp.text.strip() + if allowed_keys is not None: + if ';' not in info: + ## Missing signature + raise Exception("Missing signature on the webserver") + + info, sig = info.split(';', 1) + sig = b64.b64decode(sig).decode("utf-8") + if not ssh_verify(info, sig, allowed_keys): + ## Invalid signature + raise Exception("Invalid signature on the webserver") + + other_ip, other_port = info.split(':') break other_port = int(other_port) @@ -105,7 +169,7 @@ def parse_packet(data): return source_ip, int(source_port) -def udp_holepunch_client(local_port, server_name, server_port, server_path, no_ack=False): +def udp_holepunch_client(local_port, server_name, server_port, server_path, no_ack=False, key_path=None): """Serve as the UDP holepunching "client". Post a message to `server_path` on `server_name` via SSH, then listen using `tcp_dump` to find @@ -193,7 +257,15 @@ rm {server_path} proc.stdout.readline() ## 4. Write our information to the poll file - proc.stdin.write(("%s:%s\n" % (external_ip, external_port)).encode("utf-8")) + info_string = "%s:%s" % (external_ip, external_port) + if key_path is not None: + ## Add a signature + sig = ssh_sign(info_string, key_path) + sig = b64.b64encode(sig.encode("utf-8")).decode("utf-8") + info_string += ';' + sig + info_string += '\n' + + proc.stdin.write(info_string.encode("utf-8")) proc.stdin.flush() ## Head prints to stdout proc.stdout.readline() @@ -305,11 +377,23 @@ if __name__ == "__main__": "server_path", help="Path of file to write on the intermediate server (e.g. /var/www/inter)" ) + subparser_client.add_argument( + "-k", "--signing-key", + type=str, + default=None, + help="Path to the SSH key used to sign postings on the webserver" + ) subparser_server.add_argument( "server_path", help="Path to check (e.g. https://example.com/something)" ) + subparser_server.add_argument( + "-k", "--allowed-keys", + type=str, + default=None, + help="Comma-separated list of allowed SSH public keys to verify postings on the webserver, as stored in e.g. authorized_keys" + ) args = parser.parse_args() @@ -319,14 +403,19 @@ if __name__ == "__main__": args.server_name, args.server_port, args.server_path, - no_ack=args.skip_ack + no_ack=args.skip_ack, + key_path=args.signing_key, ) else: + allowed_keys = None + if args.allowed_keys is not None: + allowed_keys = args.allowed_keys.split(',') ret = udp_holepunch_server( args.local_port, args.server_port, args.server_path, - no_ack=args.skip_ack + no_ack=args.skip_ack, + allowed_keys=allowed_keys, ) if ret is None: sys.exit(1)