About | Log | Files | Refs
commit 45e8ff1a9ccbc6a6086704d71ebee3ea3344ce2f
Author: Ben Connors <benconnors@outlook.com>
Date: Thu, 30 Jul 2026 12:15:44 -0400
Initial commit
Diffstat:
| A | README.md | | | 60 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | udp_holepunch.py | | | 323 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
2 files changed, 383 insertions(+), 0 deletions(-)
diff --git a/README.md b/README.md
@@ -0,0 +1,60 @@
+# UDP Holepunching
+
+This is a simple Python program for performing UDP holepunching using an intermediate webserver. The intention is to conduct holepunching with as few open ports as possible, and those backed by reliable, well-tested systems:
+
+- HTTP port(s) on the intermediate server
+- SSH port on the intermediate server
+
+Using `tcpdump` on the intermediate server we can read incoming packets without opening a firewall port. This could easily be altered by allowing an open UDP port on the webserver and using that instead.
+
+## Limitations
+
+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.
+
+## Requirements
+### Intermediate
+- Webserver
+- SSH server
+- Root access
+- `tcpdump`
+
+### Client and Server
+- Modern Python with `requests`
+- `cron` or similar on the server
+
+## Overview
+On the network, the general procedure for holepunching is:
+
+1. Periodically, Server polls a specific Page on Intermediate (via `udp_holepunch.py server ...`)
+2. Client connects to Intermediate via SSH
+3. Client begins UDP holepunching with server
+4. Client writes its UDP external port and IP to a webpage on Intermediate
+5. Client waits for an incoming UDP connection
+6. Server polls the Page, sees the information, and sends packets to Client
+7. Client sends packets to Server; holepunch is established
+8. Client deletes the Page from the webserver
+
+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`.
+
+## Setup
+
+- Decide on a path for the file to poll on the webserver
+- Decide on a UDP port to send to on the server (or 0 to use the client's internal UDP port on the client, and the client's external UDP port on the server)
+
+### Server
+- Setup `cron` to run `udp_holepunch.py server ...` periodically. A typical invocation would be:
+ ```
+ udp_holepunch.py server https://example.com/poll-file
+ ```
+
+### Client
+- Run `udp_holepunch.py client ...`. A typical invocation would be:
+ ```
+ udp_holepunch.py client example-com-ssh-name /var/www/poll-file
+ ```
diff --git a/udp_holepunch.py b/udp_holepunch.py
@@ -0,0 +1,323 @@
+#!/usr/bin/env python3
+
+import argparse as ap
+import requests as r
+import socket as s
+import subprocess as subp
+import sys
+import time
+import threading
+
+def udp_holepunch_loop(sock, other_addr, no_ack=False):
+ """Finalize the UDP holepunching.
+
+ The general idea here is to blast them with "hello!" packets; once they receive one from us,
+ they reply with a "done!" packet, and we do the same. With `no_ack`, we just blast a couple
+ "hello!" packets and then return.
+ """
+ if no_ack:
+ ## Send a couple anyways but don't listen for a response
+ for _ in range(5):
+ sock.sendto(b"hello!", other_addr)
+ else:
+ found = False
+ while True:
+ sock.sendto(b"hello!" if not found else b"done!", other_addr)
+
+ try:
+ resp = sock.recvfrom(100)
+ except TimeoutError:
+ continue
+
+ if resp[1] != other_addr:
+ continue
+
+ resp = resp[0]
+ if resp == b"done!":
+ sock.sendto(b"done!", other_addr)
+ break
+
+ if not found:
+ found = True
+ continue
+
+ sock.settimeout(0.5)
+ while True:
+ try:
+ resp = sock.recvfrom(100)
+ except TimeoutError:
+ break
+
+def udp_holepunch_server(local_port, server_port, server_path, no_ack=False):
+ """Serve as the UDP holepunching "server".
+
+ Poll the given `server_path`; if a message is found from `udp_holepunch_client`, begin the
+ holepunching process.
+ """
+ other_ip = None
+ other_port = None
+
+ ## Poll the server_path for a file telling us what to phone
+ while True:
+ resp = r.get(server_path, stream=True)
+ if resp.status_code == 404:
+ return None
+
+ server_ip = resp.raw._connection.sock.getpeername()[0]
+
+ ## TODO: Add some sort of authentication here
+ other_ip, other_port = resp.text.strip().split(':')
+ break
+
+ other_port = int(other_port)
+
+ ## Send to the intermediate server
+ c = s.socket(s.AF_INET, s.SOCK_DGRAM)
+ c.settimeout(0.5)
+ c.bind(("", local_port))
+
+ if server_port == 0:
+ server_port = other_port
+
+ ## Reasonable number of tries
+ for _ in range(3):
+ c.sendto(b"hello!", (server_ip, server_port))
+ time.sleep(0.1)
+
+ internal_port = c.getsockname()[1]
+
+ ## Send to the client directly
+ other_addr = (other_ip, other_port)
+
+ udp_holepunch_loop(c, other_addr, no_ack=no_ack)
+
+ c.close()
+
+ ## We have established holepunching
+ return (internal_port, other_ip, other_port)
+
+def parse_packet(data):
+ """Parse packet from tcpdump."""
+ data = data.decode("utf-8").strip()
+ source_info = data.split(' ')[2].split('.')
+ source_ip = '.'.join(source_info[:-1])
+ source_port = source_info[-1]
+
+ return source_ip, int(source_port)
+
+def udp_holepunch_client(local_port, server_name, server_port, server_path, no_ack=False):
+ """Serve as the UDP holepunching "client".
+
+ Post a message to `server_path` on `server_name` via SSH, then listen using `tcp_dump` to find
+ our own external port and the "server"'s external port.
+ """
+ server_ip = None
+
+ ## Setup the socket
+ c = s.socket(s.AF_INET, s.SOCK_DGRAM)
+ c.bind(("", local_port))
+ c.settimeout(0.5)
+
+ ## Internal port number downstream applications must use
+ internal_port = c.getsockname()[1]
+
+ auto_server_port = server_port == 0
+ if auto_server_port:
+ server_port = internal_port
+
+ ## Our external info
+ external_ip = None
+ external_port = None
+
+ ## Info for the other end
+ other_ip = None
+ other_port = None
+
+ lock = threading.RLock()
+
+ def server_ops():
+ ## Functions run on the intermediate server
+
+ nonlocal external_ip
+ nonlocal external_port
+ nonlocal other_ip
+ nonlocal other_port
+ nonlocal server_ip
+
+ with lock:
+ ## We do the following on the server:
+ ## 1. Get our own external IP via $SSH_CONNECTION;
+ ## 2. Clear out the previous poll file
+ ## 3. Find our external port
+ ## 4. Write our information to the poll file
+ ## 5. Listen for the server's UDP ping
+ ## 6. Remove the poll file
+ proc = subp.Popen(
+ [
+ "ssh", "-tt", server_name,
+ """
+echo $SSH_CONNECTION
+rm {server_path}
+sudo tcpdump -n -c 1 -i eth0 udp port {server_port}
+head -n 1 > {server_path}
+SERVER_PORT=`head -n 1`
+sudo tcpdump -c 1 -n -i eth0 udp port $SERVER_PORT
+rm {server_path}
+ """.strip().replace('\n', ';').format(server_path=server_path, server_port=server_port)
+ ],
+ stdout=subp.PIPE,
+ stderr=subp.DEVNULL,
+ stdin=subp.PIPE,
+ )
+
+ ## 1. Get the IP of the webserver
+ ssh_conn = proc.stdout.readline().strip().split()
+ server_ip = ssh_conn[-2].decode("utf-8")
+
+ ## 2. Clear out the previous poll file (nop)
+
+ ## 3. Find our external port
+ ## Clear tcpdump trash at start, and SSH might complain about TTY
+ while True:
+ i = proc.stdout.readline()
+ if i.startswith(b"tcpdump"):
+ break
+ proc.stdout.readline()
+
+ data = proc.stdout.readline()
+ with lock:
+ external_ip, external_port = parse_packet(data)
+
+ proc.stdout.readline()
+ proc.stdout.readline()
+ proc.stdout.readline()
+
+ ## 4. Write our information to the poll file
+ proc.stdin.write(("%s:%s\n" % (external_ip, external_port)).encode("utf-8"))
+ proc.stdin.flush()
+ ## Head prints to stdout
+ proc.stdout.readline()
+
+ ## 5. Listen for the server's UDP ping
+
+ ## Write the server port to the intermediate
+ proc.stdin.write(
+ ("%d\n" % (external_port if auto_server_port else server_port)).encode("utf-8")
+ )
+ proc.stdin.flush()
+ ## Head prints to stdout
+ proc.stdout.readline()
+
+ ## Wait for things to settle
+ time.sleep(1)
+
+ ## Clear tcpdump trash at start
+ data = proc.stdout.readline()
+ data = proc.stdout.readline()
+
+ while True:
+ data = proc.stdout.readline()
+ if not data:
+ continue
+ ip, port = parse_packet(data)
+
+ if ip == external_ip:
+ ## Ignore our own packets
+ continue
+
+ with lock:
+ other_ip = ip
+ other_port = int(port)
+ proc.communicate()
+ break
+
+ ## 6. Remove the poll file (nop)
+
+ ## Run operations on the intermediate server
+ server_thread = threading.Thread(target=server_ops)
+ server_thread.start()
+
+ ## 3. Find our external port
+ ## Send UDP packets to the intermediate server
+ while True:
+ with lock:
+ if external_port is not None:
+ break
+ if server_ip is None:
+ ## FIXME: Should probably be a semaphore before the while loop
+ time.sleep(0.1)
+ continue
+ c.sendto(b"hello!", (str(server_ip), server_port))
+ time.sleep(0.5)
+
+ server_thread.join()
+
+ ## Now we're done with the intermediate: talk directly to the real server
+ other_addr = (other_ip, other_port)
+
+ udp_holepunch_loop(c, other_addr, no_ack=no_ack)
+
+ c.close()
+
+ return (internal_port, other_ip, other_port)
+
+if __name__ == "__main__":
+ parser = ap.ArgumentParser(
+ description="Perform UDP holepunching using an intermediate webserver. The program returns 0 if successful and 1 otherwise; if successful, the output will have the form `<local UDP port> <remote IP> <remote UDP port>`."
+ )
+
+ subparsers = parser.add_subparsers(dest="type", required=True)
+
+ subparser_client = subparsers.add_parser(
+ "client",
+ help="Serve as the client initiating the connection; requires SSH and root access to the intermediate server.",
+ )
+ subparser_server = subparsers.add_parser(
+ "server",
+ help="Serve as the server polling the intermediate server for a connection.",
+ )
+
+ for p in (subparser_client, subparser_server):
+ p.add_argument(
+ "-l", "--local-port",
+ type=int,
+ default=0,
+ help="Fixed UDP port for us to use (defaults to 0 = auto-assigned by system)"
+ )
+ p.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)"
+ )
+ p.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)",
+ )
+
+ subparser_client.add_argument(
+ "server_name",
+ help="Name of the intermediate server (for SSH)"
+ )
+ subparser_client.add_argument(
+ "server_path",
+ help="Path of file to write on the intermediate server (e.g. /var/www/inter)"
+ )
+
+ subparser_server.add_argument(
+ "server_path",
+ help="Path to check (e.g. https://example.com/something)"
+ )
+
+ args = parser.parse_args()
+
+ if args.type == "client":
+ ret = udp_holepunch_client(args.local_port, args.server_name, args.server_port, args.server_path, no_ack=args.skip_ack)
+ else:
+ ret = udp_holepunch_server(args.local_port, args.server_port, args.server_path, no_ack=args.skip_ack)
+ if ret is None:
+ sys.exit(1)
+
+ print(' '.join((str(i) for i in ret)))