| udp-holepunch - Python script for UDP holepunching using a webserver. With an example of SSH using this and sctp-echo. |
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)))