#!/usr/bin/env python3 import hashlib import os import signal import socket import struct import subprocess import sys import tempfile import threading import time failed = 0 procs = [] def check(name, cond): global failed print(("ok " if cond else "FAIL ") + name) failed += not cond def family(host): return socket.AF_INET6 if ":" in host else socket.AF_INET def free_port(host="127.0.0.1"): s = socket.socket(family(host)) s.bind((host, 0)) port = s.getsockname()[1] s.close() return port def start(tmp, listen, *args, lines=1): log = os.path.join(tmp, listen.replace("/", "_") + ".log") with open(log, "w") as f: p = subprocess.Popen(["./usocks", "-l", listen, *args], stderr=f) procs.append(p) for _ in range(100): with open(log) as f: if f.read().count("listening on") >= lines: return p if p.poll() is not None: break time.sleep(0.05) sys.exit(f"usocks -l {listen} did not start, see {log}") def echo_server(host="127.0.0.1"): ls = socket.socket(family(host)) ls.bind((host, 0)) ls.listen(64) def serve(c): while b := c.recv(65536): c.sendall(b) c.shutdown(socket.SHUT_WR) c.close() def loop(): while True: c, _ = ls.accept() threading.Thread(target=serve, args=(c,), daemon=True).start() threading.Thread(target=loop, daemon=True).start() return ls.getsockname()[1] def recvn(s, n): b = b"" while len(b) < n: x = s.recv(n - len(b)) if not x: raise EOFError b += x return b def recvall(s): b = b"" while x := s.recv(65536): b += x return b def request(host, port, cmd=1): for af, atyp in ((socket.AF_INET, 1), (socket.AF_INET6, 4)): try: a = bytes([atyp]) + socket.inet_pton(af, host) break except OSError: pass else: a = bytes([3, len(host)]) + host.encode() return bytes([5, cmd, 0]) + a + struct.pack(">H", port) def reply(s): h = recvn(s, 4) recvn(s, {1: 4, 4: 16}[h[3]] + 2) return h[1] def proxy(addr): if isinstance(addr, str): s = socket.socket(socket.AF_UNIX) s.connect(addr) elif isinstance(addr, tuple): s = socket.create_connection(addr) else: s = socket.create_connection(("127.0.0.1", addr)) s.settimeout(30) return s def connect(addr, host, port, auth=None, extra=b"", cmd=1): """Send the whole handshake in one write; return socket and reply code.""" s = proxy(addr) if auth: u, p = auth msg = b"\5\1\2" + bytes([1, len(u)]) + u + bytes([len(p)]) + p else: msg = b"\5\1\0" s.sendall(msg + request(host, port, cmd) + extra) if recvn(s, 2) != (b"\5\2" if auth else b"\5\0"): return s, -1 if auth and recvn(s, 2) != b"\1\0": return s, -2 return s, reply(s) def nfds(pid): return len(os.listdir(f"/proc/{pid}/fd")) def proc(pid): try: with open(f"/proc/{pid}/stat") as f: s = f.read().rsplit(")", 1)[1].split() except OSError: return None return s[0], int(s[1]) def alive(pid): p = proc(pid) return p is not None and p[0] != "Z" def workers(pid): ps = [int(d) for d in os.listdir("/proc") if d.isdigit()] return [k for k in ps if alive(k) and proc(k)[1] == pid] def main(): os.chdir(os.path.dirname(os.path.abspath(__file__))) echo = echo_server() tmp = tempfile.mkdtemp() unix = os.path.join(tmp, "usocks.sock") port, aport, mport, wport = (free_port() for _ in range(4)) plain = start(tmp, f"127.0.0.1:{port}") start(tmp, f"127.0.0.1:{aport}", "-u", "bob", "-p", "secret") start(tmp, f"127.0.0.1:{mport}", "-m", "2") start(tmp, unix) fds = nfds(plain.pid) s, r = connect(port, "127.0.0.1", echo) s.sendall(b"hello") check("connect ipv4", r == 0 and recvn(s, 5) == b"hello") s.close() try: echo6, port6 = echo_server("::1"), free_port("::1") except OSError: print("skip ipv6: ::1 not available") else: start(tmp, f"[::1]:{port6}") s, r = connect(("::1", port6), "::1", echo6) s.sendall(b"six") check("ipv6 listener and destination", r == 0 and recvn(s, 3) == b"six") s.close() s, r = connect(port, "localhost", echo, extra=b"early") check("domain name with early data", r == 0 and recvn(s, 5) == b"early") s.close() s, r = connect(port, "nonexistent.invalid", echo) check("unresolvable name gets 4", r == 4) s, r = connect(port, "127.0.0.1", free_port()) check("refused gets 5", r == 5) s, r = connect(port, "evil\nlog.example", echo) check("non-printable domain name gets 1", r == 1) s, r = connect(port, "127.0.0.1", echo, cmd=2) check("bind gets 7", r == 7) s = proxy(port) s.sendall(b"\5\1\0\5\1\0\7") recvn(s, 2) check("bad address type gets 8", reply(s) == 8) s = proxy(port) s.sendall(b"\4\1") check("bad version closes", recvall(s) == b"") s = proxy(port) s.sendall(b"\5\1\2") check("no acceptable method", recvn(s, 2) == b"\5\377") s = proxy(aport) for b in b"\5\1\2\1\3bob\6secret" + request("127.0.0.1", echo) + b"x": s.send(bytes([b])) time.sleep(0.002) ok = recvn(s, 4) == b"\5\2\1\0" and reply(s) == 0 check("byte by byte handshake with auth", ok and recvn(s, 1) == b"x") s.close() s, r = connect(aport, "127.0.0.1", echo, auth=(b"bob", b"secret")) check("auth accepted", r == 0) s, r = connect(aport, "127.0.0.1", echo, auth=(b"bob", b"secre")) check("wrong password rejected", r == -2) s, r = connect(aport, "127.0.0.1", echo) check("no auth offered to auth server", r == -1) s, r = connect(port, "127.0.0.1", echo) s.sendall(b"abc") s.shutdown(socket.SHUT_WR) check("client half-close", recvall(s) == b"abc") ls = socket.socket() ls.bind(("127.0.0.1", 0)) ls.listen(1) s, r = connect(port, "127.0.0.1", ls.getsockname()[1]) a, _ = ls.accept() a.sendall(b"bye") a.shutdown(socket.SHUT_WR) got = recvall(s) s.sendall(b"still open") s.shutdown(socket.SHUT_WR) check("remote half-close", got == b"bye" and recvall(a) == b"still open") data = os.urandom(1 << 20) * 32 s, r = connect(port, "127.0.0.1", echo) def write(): s.sendall(data) s.shutdown(socket.SHUT_WR) threading.Thread(target=write).start() got = recvall(s) check( "32 MiB both ways", hashlib.sha256(got).digest() == hashlib.sha256(data).digest(), ) s, r = connect(unix, "127.0.0.1", echo) s.sendall(b"unix") check("unix socket listener", r == 0 and recvn(s, 4) == b"unix") s.close() stale = os.path.join(tmp, "stale.sock") s = socket.socket(socket.AF_UNIX) s.bind(stale) s.close() start(tmp, stale) r = subprocess.run( ["./usocks", "-l", stale], stderr=subprocess.DEVNULL, timeout=5, check=False ) s, rc = connect(stale, "127.0.0.1", echo) check("stale unix socket replaced, live one kept", r.returncode == 1 and rc == 0) s.close() a, _ = connect(mport, "127.0.0.1", echo) b, _ = connect(mport, "127.0.0.1", echo) x = proxy(mport) x.sendall(b"\5\1\0") x.settimeout(0.5) try: x.recv(2) waited = False except TimeoutError: waited = True a.close() x.settimeout(5) check("maxconn queues the third client", waited and recvn(x, 2) == b"\5\0") w = start(tmp, f"127.0.0.1:{wport}", "-w", "2", lines=2) kids = workers(w.pid) base = [nfds(k) for k in kids] held = [connect(wport, "127.0.0.1", echo) for _ in range(16)] used = [nfds(k) > n for k, n in zip(kids, base)] ok = all(r == 0 for _, r in held) and len(kids) == 2 and all(used) check("workers share the port", ok) os.kill(kids[0], signal.SIGKILL) rc = w.wait(5) t = time.time() while alive(kids[1]) and time.time() - t < 5: time.sleep(0.05) check("a dead worker stops all", rc == 1 and not alive(kids[1])) # A listener with a full accept queue drops SYNs: connect never finishes. full = socket.socket() full.bind(("127.0.0.1", 0)) full.listen(0) fill = [] for _ in range(4): f = socket.socket() f.setblocking(False) f.connect_ex(full.getsockname()) fill.append(f) silent = proxy(port) t = time.time() s, r = connect(port, "127.0.0.1", full.getsockname()[1]) check("connect timeout gets 6", r == 6 and 9 < time.time() - t < 13) check("handshake timeout closes", recvall(silent) == b"") time.sleep(0.5) check("no leaked descriptors", nfds(plain.pid) == fds) print("FAILED" if failed else "all ok") sys.exit(1 if failed else 0) try: main() finally: for p in procs: p.terminate() p.wait()