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

udp_holepunch.py (13853B) - raw


      1 #!/usr/bin/env python3
      2 
      3 import argparse as ap
      4 import base64 as b64
      5 import os
      6 import datetime as dt
      7 import socket as s
      8 import subprocess as subp
      9 import sys
     10 import tempfile
     11 import time
     12 import threading
     13 
     14 import requests as r
     15 
     16 def utcnow():
     17     return dt.datetime.now(dt.timezone.utc).timestamp()
     18 
     19 def ssh_sign(message, key_path, namespace="udp-holepunch"):
     20     """Sign a message using ssh-keygen."""
     21     return subp.check_output(["ssh-keygen", "-f", key_path, "-Y", "sign", "-n", namespace], input=message, text=True, stderr=subp.DEVNULL)
     22 
     23 def ssh_verify(message, signature, allowed_keys, namespace="udp-holepunch"):
     24     """Verify a message using ssh-keygen.
     25 
     26     `allowed_keys` should be a list of keys of the form
     27 
     28       ssh-whatever encodedpublickeyhere
     29     """
     30     allowed_keys_fix = []
     31     principals = set()
     32     for k in allowed_keys:
     33         k = k.strip()
     34         if k.count(' ') < 2:
     35             ## No principal
     36             i = 0
     37             while "p%d" % i in principals:
     38                 i += 1
     39             principals.add("p%d" % i)
     40             allowed_keys_fix.append(("p%d " % i) + k)
     41         else:
     42             a, b, principal = k.split(' ')
     43             principals.add(principal)
     44             allowed_keys_fix.append(' '.join((principal, a, b)))
     45 
     46     with tempfile.TemporaryDirectory() as td:
     47         valid_f = os.path.join(td, "valid")
     48         with open(valid_f, 'w') as f:
     49             f.write('\n'.join(allowed_keys_fix))
     50 
     51         signature_f = os.path.join(td, "signature")
     52         with open(signature_f, 'w') as f:
     53             f.write(signature)
     54 
     55         for p in principals:
     56             ## Inexplicably, check_call doesn't have `input`
     57             try:
     58                 subp.check_output(["ssh-keygen", "-f", valid_f, "-Y", "verify", "-n", namespace, "-s", signature_f, "-I", p], input=message, text=True, stderr=subp.DEVNULL)
     59             except subp.CalledProcessError:
     60                 continue
     61             else:
     62                 break
     63         else:
     64             return False
     65 
     66     return True
     67 
     68 def udp_holepunch_loop(sock, other_addr, no_ack=False, timeout=60):
     69     """Finalize the UDP holepunching.
     70 
     71     The general idea here is to blast them with "hello!" packets; once they receive one from us,
     72     they reply with a "done!" packet, and we do the same. With `no_ack`, we just blast a couple
     73     "hello!" packets and then return.
     74     """
     75     if no_ack:
     76         ## Send a couple anyways but don't listen for a response
     77         for _ in range(5):
     78             sock.sendto(b"hello!", other_addr)
     79     else: 
     80         found = False
     81 
     82         start = time.monotonic()
     83         sock.settimeout(0.5)
     84         while True:
     85             sock.sendto(b"hello!" if not found else b"done!", other_addr)
     86 
     87             try:
     88                 resp = sock.recvfrom(100)
     89             except TimeoutError:
     90                 if time.monotonic() - start > timeout:
     91                     return False
     92 
     93                 continue
     94 
     95             if resp[1] != other_addr:
     96                 continue
     97             
     98             resp = resp[0]
     99             if resp == b"done!":
    100                 sock.sendto(b"done!", other_addr)
    101                 break
    102 
    103             if not found:
    104                 found = True
    105                 continue
    106 
    107         sock.settimeout(0.5)
    108         while True:
    109             try:
    110                 resp = sock.recvfrom(100)
    111             except TimeoutError:
    112                 break
    113 
    114     return True
    115 
    116 def udp_holepunch_server(local_port, server_port, server_path, no_ack=False, allowed_keys=None, timeout=60, sig_valid=60*10):
    117     """Serve as the UDP holepunching "server".
    118 
    119     Poll the given `server_path`; if a message is found from `udp_holepunch_client`, begin the
    120     holepunching process.
    121     """
    122     other_ip = None
    123     other_port = None
    124 
    125     ## Poll the server_path for a file telling us what to phone
    126     while True:
    127         resp = r.get(server_path, stream=True)
    128         if resp.status_code == 404:
    129             return None
    130 
    131         server_ip = resp.raw._connection.sock.getpeername()[0]
    132 
    133         info = resp.text.strip()
    134         if allowed_keys is not None:
    135             if ';' not in info:
    136                 ## Missing signature
    137                 raise Exception("Missing signature on the webserver")
    138 
    139             info, sig = info.split(';', 1)
    140             sig = b64.b64decode(sig).decode("utf-8")
    141             if not ssh_verify(info, sig, allowed_keys):
    142                 ## Invalid signature
    143                 raise Exception("Invalid signature on the webserver")
    144 
    145         if ',' not in info:
    146             raise Exception("Missing time on the webserver")
    147 
    148         info, other_time = info.split(',')
    149         other_time = float(other_time)
    150 
    151         if abs(utcnow() - other_time) > sig_valid:
    152             raise Exception("Signature expired on webserver")
    153 
    154         other_ip, other_port = info.split(':')
    155         break
    156 
    157     other_port = int(other_port)
    158 
    159     ## Send to the intermediate server
    160     c = s.socket(s.AF_INET, s.SOCK_DGRAM)
    161     c.settimeout(0.5)
    162     c.bind(("", local_port))
    163 
    164     if server_port == 0:
    165         server_port = other_port
    166 
    167     ## Reasonable number of tries
    168     for _ in range(3):
    169         c.sendto(b"hello!", (server_ip, server_port))
    170         time.sleep(0.1)
    171 
    172     internal_port = c.getsockname()[1]
    173 
    174     ## Send to the client directly
    175     other_addr = (other_ip, other_port)
    176 
    177     if not udp_holepunch_loop(c, other_addr, no_ack=no_ack, timeout=timeout):
    178         raise Exception("Holepunching timeout")
    179 
    180     c.close()
    181 
    182     ## We have established holepunching
    183     return (internal_port, other_ip, other_port)
    184 
    185 def parse_packet(data):
    186     """Parse packet from tcpdump."""
    187     data = data.decode("utf-8").strip()
    188     source_info = data.split(' ')[2].split('.')
    189     source_ip = '.'.join(source_info[:-1])
    190     source_port = source_info[-1]
    191 
    192     return source_ip, int(source_port)
    193 
    194 def udp_holepunch_client(local_port, server_name, server_port, server_path, no_ack=False, key_path=None, timeout=60):
    195     """Serve as the UDP holepunching "client".
    196 
    197     Post a message to `server_path` on `server_name` via SSH, then listen using `tcp_dump` to find
    198     our own external port and the "server"'s external port.
    199     """
    200     server_ip = None
    201 
    202     ## Setup the socket
    203     c = s.socket(s.AF_INET, s.SOCK_DGRAM)
    204     c.bind(("", local_port))
    205     c.settimeout(0.5)
    206 
    207     ## Internal port number downstream applications must use
    208     internal_port = c.getsockname()[1]
    209 
    210     auto_server_port = server_port == 0
    211     if auto_server_port:
    212         server_port = internal_port
    213 
    214     ## Our external info
    215     external_ip = None
    216     external_port = None
    217 
    218     ## Info for the other end
    219     other_ip = None
    220     other_port = None
    221 
    222     lock = threading.RLock()
    223 
    224     def server_ops():
    225         ## Functions run on the intermediate server
    226 
    227         nonlocal external_ip
    228         nonlocal external_port
    229         nonlocal other_ip
    230         nonlocal other_port
    231         nonlocal server_ip
    232 
    233         with lock:
    234             ## We do the following on the server:
    235             ## 1. Get our own external IP via $SSH_CONNECTION;
    236             ## 2. Clear out the previous poll file
    237             ## 3. Find our external port
    238             ## 4. Write our information to the poll file
    239             ## 5. Listen for the server's UDP ping
    240             ## 6. Remove the poll file
    241             proc = subp.Popen(
    242                 [
    243                     "ssh", "-tt", server_name,
    244                     """
    245 echo $SSH_CONNECTION
    246 rm {server_path}
    247 sudo tcpdump -n -c 1 -i eth0 udp port {server_port}
    248 head -n 1 > {server_path}
    249 SERVER_PORT=`head -n 1`
    250 sudo tcpdump -c 1 -n -i eth0 udp port $SERVER_PORT
    251 rm {server_path}
    252                     """.strip().replace('\n', ';').format(server_path=server_path, server_port=server_port)
    253                 ],
    254                 stdout=subp.PIPE,
    255                 stderr=subp.DEVNULL,
    256                 stdin=subp.PIPE,
    257             )
    258 
    259             ## 1. Get the IP of the webserver
    260             ssh_conn = proc.stdout.readline().strip().split()
    261             server_ip = ssh_conn[-2].decode("utf-8")
    262 
    263         ## 2. Clear out the previous poll file (nop)
    264 
    265         ## 3. Find our external port
    266         ## Clear tcpdump trash at start, and SSH might complain about TTY
    267         while True:
    268             i = proc.stdout.readline()
    269             if i.startswith(b"tcpdump"):
    270                 break
    271         proc.stdout.readline()
    272 
    273         data = proc.stdout.readline()
    274         with lock:
    275             external_ip, external_port = parse_packet(data)
    276 
    277         proc.stdout.readline()
    278         proc.stdout.readline()
    279         proc.stdout.readline()
    280 
    281         ## 4. Write our information to the poll file
    282         info_string = "%s:%s,%d" % (external_ip, external_port, utcnow())
    283         if key_path is not None:
    284             ## Add a signature
    285             sig = ssh_sign(info_string, key_path)
    286             sig = b64.b64encode(sig.encode("utf-8")).decode("utf-8")
    287             info_string += ';' + sig
    288         info_string += '\n'
    289 
    290         proc.stdin.write(info_string.encode("utf-8"))
    291         proc.stdin.flush()
    292         ## Head prints to stdout
    293         proc.stdout.readline()
    294 
    295         ## 5. Listen for the server's UDP ping
    296 
    297         ## Write the server port to the intermediate
    298         proc.stdin.write(
    299             ("%d\n" % (external_port if auto_server_port else server_port)).encode("utf-8")
    300         )
    301         proc.stdin.flush()
    302         ## Head prints to stdout
    303         proc.stdout.readline()
    304 
    305         ## Wait for things to settle
    306         time.sleep(1)
    307 
    308         ## Clear tcpdump trash at start
    309         data = proc.stdout.readline()
    310         data = proc.stdout.readline()
    311 
    312         while True:
    313             data = proc.stdout.readline()
    314             if not data:
    315                 continue
    316             ip, port = parse_packet(data)
    317 
    318             if ip == external_ip:
    319                 ## Ignore our own packets
    320                 continue
    321 
    322             with lock:
    323                 other_ip = ip
    324                 other_port = int(port)
    325                 proc.communicate()
    326                 break
    327 
    328         ## 6. Remove the poll file (nop)
    329 
    330     ## Run operations on the intermediate server
    331     server_thread = threading.Thread(target=server_ops, daemon=True)
    332     server_thread.start()
    333 
    334     ## 3. Find our external port
    335     ## Send UDP packets to the intermediate server
    336     while True:
    337         with lock:
    338             if external_port is not None:
    339                 break
    340             if server_ip is None:
    341                 ## FIXME: Should probably be a semaphore before the while loop
    342                 time.sleep(0.1)
    343                 continue
    344         c.sendto(b"hello!", (str(server_ip), server_port))
    345         time.sleep(0.5)
    346 
    347     server_thread.join()
    348 
    349     ## Now we're done with the intermediate: talk directly to the real server
    350     other_addr = (other_ip, other_port)
    351 
    352     udp_holepunch_loop(c, other_addr, no_ack=no_ack, timeout=timeout)
    353 
    354     c.close()
    355 
    356     return (internal_port, other_ip, other_port)
    357 
    358 if __name__ == "__main__":
    359     parser = ap.ArgumentParser(
    360         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>`."
    361     )
    362 
    363     subparsers = parser.add_subparsers(dest="type", required=True)
    364 
    365     subparser_client = subparsers.add_parser(
    366         "client",
    367         help="Serve as the client initiating the connection; requires SSH and root access to the intermediate server.",
    368     )
    369     subparser_server = subparsers.add_parser(
    370         "server",
    371         help="Serve as the server polling the intermediate server for a connection.",
    372     )
    373 
    374     for p in (subparser_client, subparser_server):
    375         p.add_argument(
    376             "-l", "--local-port", 
    377             type=int, 
    378             default=0, 
    379             help="Fixed internal UDP port for us to use (defaults to 0 = auto-assigned by system)"
    380         )
    381         p.add_argument(
    382             "-p", "--server-port", 
    383             type=int, 
    384             default=0,
    385             help="Fixed UDP port to use to start holepunching (defaults to 0 to use our external UDP port number)"
    386         )
    387         p.add_argument(
    388             "-s", "--skip-ack",
    389             action="store_true",
    390             default=False,
    391             help="Skip hello packet acknowledgement to finalize holepunching (a couple of the first packets down the line may get lost)",
    392         )
    393 
    394     subparser_client.add_argument(
    395         "server_name", 
    396         help="Name of the intermediate server (for SSH)"
    397     )
    398     subparser_client.add_argument(
    399         "server_path", 
    400         help="Path of file to write on the intermediate server (e.g. /var/www/inter)"
    401     )
    402     subparser_client.add_argument(
    403         "-k", "--signing-key",
    404         type=str,
    405         default=None,
    406         help="Path to the SSH key used to sign postings on the webserver"
    407     )
    408 
    409     subparser_server.add_argument(
    410         "server_path", 
    411         help="Path to check (e.g. https://example.com/something)"
    412     )
    413     subparser_server.add_argument(
    414         "-k", "--allowed-keys",
    415         type=str,
    416         default=None,
    417         help="Comma-separated list of allowed SSH public keys to verify postings on the webserver, as stored in e.g. authorized_keys"
    418     )
    419 
    420     args = parser.parse_args()
    421 
    422     if args.type == "client":
    423         ret = udp_holepunch_client(
    424             args.local_port,
    425             args.server_name,
    426             args.server_port,
    427             args.server_path,
    428             no_ack=args.skip_ack,
    429             key_path=args.signing_key,
    430         )
    431     else:
    432         allowed_keys = None
    433         if args.allowed_keys is not None:
    434             allowed_keys = args.allowed_keys.split(',')
    435         ret = udp_holepunch_server(
    436             args.local_port,
    437             args.server_port,
    438             args.server_path,
    439             no_ack=args.skip_ack,
    440             allowed_keys=allowed_keys,
    441         )
    442         if ret is None:
    443             sys.exit(1)
    444 
    445     print(' '.join((str(i) for i in ret)))