From a2ad6280ef4d819319fbf8aa57a4a8e9fb8c3544 Mon Sep 17 00:00:00 2001 From: hiroTamada <88675973+hiroTamada@users.noreply.github.com> Date: Wed, 28 Oct 2026 04:47:54 +0000 Subject: [PATCH 1/2] Add isolated SOCKS5 UDP browser experiment --- experiments/udp-egress/.gitignore | 1 + experiments/udp-egress/Dockerfile | 8 ++ experiments/udp-egress/README.md | 138 +++++++++++++++++++ experiments/udp-egress/chromium-launcher.sh | 4 + experiments/udp-egress/common.py | 66 +++++++++ experiments/udp-egress/http_proxy.py | 51 +++++++ experiments/udp-egress/network.conf | 21 +++ experiments/udp-egress/network.py | 60 ++++++++ experiments/udp-egress/probe.cjs | 68 +++++++++ experiments/udp-egress/test_protocol.py | 86 ++++++++++++ experiments/udp-egress/udp.py | 117 ++++++++++++++++ experiments/udp-egress/upstream.example.json | 11 ++ 12 files changed, 631 insertions(+) create mode 100644 experiments/udp-egress/.gitignore create mode 100644 experiments/udp-egress/Dockerfile create mode 100644 experiments/udp-egress/README.md create mode 100644 experiments/udp-egress/chromium-launcher.sh create mode 100644 experiments/udp-egress/common.py create mode 100644 experiments/udp-egress/http_proxy.py create mode 100644 experiments/udp-egress/network.conf create mode 100644 experiments/udp-egress/network.py create mode 100644 experiments/udp-egress/probe.cjs create mode 100644 experiments/udp-egress/test_protocol.py create mode 100644 experiments/udp-egress/udp.py create mode 100644 experiments/udp-egress/upstream.example.json diff --git a/experiments/udp-egress/.gitignore b/experiments/udp-egress/.gitignore new file mode 100644 index 00000000..c18dd8d8 --- /dev/null +++ b/experiments/udp-egress/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/experiments/udp-egress/Dockerfile b/experiments/udp-egress/Dockerfile new file mode 100644 index 00000000..4dff599b --- /dev/null +++ b/experiments/udp-egress/Dockerfile @@ -0,0 +1,8 @@ +ARG BASE_IMAGE=kernel-images:isp-base +FROM ${BASE_IMAGE} +RUN apt-get update && apt-get install -y --no-install-recommends iproute2 iptables python3 socat tcpdump && rm -rf /var/lib/apt/lists/* +COPY experiments/udp-egress/*.py experiments/udp-egress/*.cjs experiments/udp-egress/chromium-launcher.sh /opt/udp-egress/ +RUN chmod +x /opt/udp-egress/chromium-launcher.sh && \ + sed -i 's|command=/usr/local/bin/chromium-launcher|command=/opt/udp-egress/chromium-launcher.sh|' /etc/supervisor/conf.d/services/chromium.conf && \ + cd /opt/udp-egress && PYTHONDONTWRITEBYTECODE=1 python3 -m unittest -v test_protocol.py +COPY experiments/udp-egress/network.conf /etc/supervisor/conf.d/services/udp-egress.conf diff --git a/experiments/udp-egress/README.md b/experiments/udp-egress/README.md new file mode 100644 index 00000000..7d2bed23 --- /dev/null +++ b/experiments/udp-egress/README.md @@ -0,0 +1,138 @@ +# Isolated browser UDP experiment + +This opt-in headful image experiment keeps HTTPS on an HTTP CONNECT upstream and +forwards actual browser UDP through a SOCKS5 UDP association on the same upstream +binding. It does not change the normal images or implement a production egress +service. Use only a disposable, locally run container. + +## Isolation + +Chromium runs as the unprivileged `kernel` user in the `browser-udp` network +namespace. The desktop, live-view server, HTTP forwarder and UDP relay remain in +the container's original namespace. + +The browser's veth is the only interface captured by the UDP DNAT rules. Each +explicit test destination has its own local listener, which wraps the unchanged +payload in a SOCKS5 UDP header and forwards it to that destination through the +upstream. Returned payloads pass through normal reverse NAT, without changing +STUN contents or browser candidates. Each flow holds its SOCKS control connection +open. Unlisted UDP destinations are denied. + +This intentionally uses explicit per-test routes rather than general transparent +UDP interception. On some kernels, ancillary destination information contains the +post-DNAT address, not the original destination. General interception needs a +separately validated original-destination mechanism; this experiment does not +assume that mechanism works. + +An independent FORWARD deny rule is installed before the browser gets its default +route. If the UDP listener stops, packets reach a closed local port. If the DNAT +rule disappears, IPv4 forwarding remains denied. The browser namespace has IPv6 +disabled when the kernel supports it; this is not a dual-stack forwarding test. +This design does not require the netfilter owner match, a TUN device, or policy +routing. DevTools uses TCP loopback bridges; live-view media is not intercepted. + +The HTTP forwarder adds upstream authentication and otherwise copies bytes. It is +not a reproduction of the host-side TLS MITM or request-mutation stack. + +## Build and run + +Build the base image from the repository root using the normal headful Dockerfile, +then build this experiment: + +```sh +GITHUB_TOKEN="$(gh auth token)" DOCKER_BUILDKIT=1 \ + docker build --secret id=github_token,env=GITHUB_TOKEN \ + -t kernel-images:udp-base -f images/chromium-headful/Dockerfile . +docker build --build-arg BASE_IMAGE=kernel-images:udp-base \ + -t kernel-images:udp-experiment -f experiments/udp-egress/Dockerfile . +``` + +Create a root-owned `0600` upstream JSON file outside the checkout using +`upstream.example.json` as the schema. Supply an existing IPv4 ISP endpoint, +its actual HTTP and SOCKS ports, and the same credentials for both protocols. +Set `udp_targets` to the controlled STUN receivers' actual public IPv4 addresses +and ports, plus the public DNS resolver used by the browser namespace. DNS is +configured to use `8.8.8.8` in this experiment. The documentation addresses in the +example must be replaced. Configure the file before starting the container; do +not change it while running. Do not bake credentials into an image or commit them. + +Run the container through the repository's local-service workflow. It needs +privilege for its _container-local_ namespace and netfilter setup. Mount the JSON +read-only at `/run/udp-egress/upstream.json`. Keep HTTP/API/CDP ports bound to +loopback, publish only the live-view media range needed by authorized viewers, +and configure the live-view NAT address for the local environment. Do not attach +the container to the host network. + +For example, use this foreground command as the local service, substituting the +root-owned configuration path and the address authorized viewers can reach: + +```sh +docker run --rm --privileged --tmpfs /dev/shm:size=2g \ + --name udp-egress-test \ + -p 127.0.0.1:8080:8080 -p 127.0.0.1:19222:9222 \ + -p 127.0.0.1:10001:10001 -p 59000-59100:59000-59100/udp \ + --mount "type=bind,source=$CONFIG,target=/run/udp-egress/upstream.json,readonly" \ + -e WITHDOCKER=false -e ENABLE_WEBRTC=true \ + -e NEKO_WEBRTC_EPR=59000-59100 -e NEKO_WEBRTC_NAT1TO1="$VIEWER_REACHABLE_IP" \ + -e CHROMIUM_FLAGS="--no-sandbox --no-zygote --disable-dev-shm-usage --disable-quic --proxy-server=http://192.0.2.1:18080 --webrtc-ip-handling-policy=disable_non_proxied_udp" \ + kernel-images:udp-experiment +``` + +The normal launcher still supplies the display and DevTools settings. To enable +the experiment, merge `--webrtc-ip-handling-policy=default` into `/chromium/flags` +and restart only the Chromium supervisor program. Keep the relay and deny rule +in place before enabling UDP. Do not relax the policy in a normal browser image. +The local API provides a merge-and-restart operation: + +```sh +curl --fail -X PATCH http://127.0.0.1:10001/chromium/flags \ + -H 'Content-Type: application/json' \ + -d '{"flags":["--webrtc-ip-handling-policy=default"]}' +``` + +## Validation + +Use HTTPS and STUN receivers you control, or equivalent independently verifiable +receiver-side observations. The HTTPS echo endpoint used by `probe.cjs` must +return JSON containing the observed source address as `ip`. + +```sh +python3 -m unittest discover -s experiments/udp-egress -p 'test_*.py' +docker exec CONTAINER node /opt/udp-egress/probe.cjs \ + https://receiver.example.test/request stun:receiver.example.test:3478 baseline +``` + +Repeat with WebRTC UDP enabled. Require the HTTPS receiver source IP, the STUN +receiver's XOR-MAPPED-ADDRESS, and the browser's server-reflexive candidate to +agree. Correlate the receiver response transaction IDs in +`/var/log/supervisord/udp-relay` with outgoing requests. Candidates alone are not +proof of the forwarding path. + +Capture the container's external interface while testing. Check that no direct +browser UDP reaches the STUN receivers; encapsulated packets should instead go +to the upstream relay. Test all of: + +- fresh peer connections and SOCKS associations; +- a Chromium restart; +- `supervisorctl stop udp-relay`, with HTTPS and live view still working; +- relay restart and successful return traffic; +- deletion of only this experiment's DNAT rule, checking FORWARD deny counters; +- whole-container restart, re-enabling UDP only after the guard is installed; +- IPv6 behavior, distinguishing an IPv6-disabled kernel from an IPv6-capable test. + +Verify live-view video frames advance and the service records a connected ICE +state. A healthy HTTP endpoint does not establish media health. + +## Limits + +The relay is intentionally IPv4-only, permits only the explicit public test +destinations, rejects SOCKS fragments, and caps itself at 32 flows with a +60-second idle timeout. Credentials and relay endpoints are static. This is not +a production-ready open proxy. +Receiver observations must establish provider parity before testing the browser. +Results for an ISP binding do not establish behavior for rotating residential +bindings, other providers, different kernels, or the deployed stealth browser. + +No production credentials belong in captures. Detach the private exposure before +stopping the container, then remove the runtime JSON file. Do not leave an +enabled experiment running unattended. diff --git a/experiments/udp-egress/chromium-launcher.sh b/experiments/udp-egress/chromium-launcher.sh new file mode 100644 index 00000000..032be44e --- /dev/null +++ b/experiments/udp-egress/chromium-launcher.sh @@ -0,0 +1,4 @@ +#!/bin/sh +set -eu +while [ ! -f /run/udp-egress-ready ]; do sleep 0.1; done +exec ip netns exec browser-udp /usr/local/bin/chromium-launcher "$@" diff --git a/experiments/udp-egress/common.py b/experiments/udp-egress/common.py new file mode 100644 index 00000000..c35cadcc --- /dev/null +++ b/experiments/udp-egress/common.py @@ -0,0 +1,66 @@ +import ipaddress +import json +import socket +import struct + + +def config(): + with open('/run/udp-egress/upstream.json') as f: + return json.load(f) + + +def udp_targets(settings): + targets = settings['udp_targets'] + if not isinstance(targets, list) or not 0 < len(targets) <= 16: + raise ValueError('configure between 1 and 16 explicit UDP targets') + seen = set() + for target in targets: + address = ipaddress.ip_address(target['host']) + port = target['port'] + if address.version != 4 or not address.is_global: + raise ValueError('UDP targets must be public IPv4 addresses') + if type(port) is not int or not 1 <= port <= 65535: + raise ValueError('invalid UDP target port') + endpoint = (str(address), port) + if endpoint in seen: + raise ValueError('duplicate UDP target') + seen.add(endpoint) + return targets + + +def receive(sock, count): + result = b'' + while len(result) < count: + part = sock.recv(count - len(result)) + if not part: + raise OSError('SOCKS control connection closed') + result += part + return result + + +def associate(settings, port): + sock = socket.create_connection((settings['host'], settings['socks_port']), timeout=8) + try: + sock.sendall(b'\x05\x01\x02') + if receive(sock, 2) != b'\x05\x02': + raise OSError('SOCKS username/password authentication unavailable') + user, password = settings['user'].encode(), settings['password'].encode() + if not 0 < len(user) < 256 or not 0 < len(password) < 256: + raise ValueError('invalid SOCKS credential length') + sock.sendall(b'\x01' + bytes([len(user)]) + user + bytes([len(password)]) + password) + if receive(sock, 2) != b'\x01\x00': + raise OSError('SOCKS authentication failed') + sock.sendall(b'\x05\x03\x00\x01\x00\x00\x00\x00' + struct.pack('!H', port)) + version, status, reserved, address_type = receive(sock, 4) + if version != 5 or status != 0 or reserved != 0 or address_type != 1: + raise OSError('SOCKS UDP association requires a successful IPv4 reply') + host = socket.inet_ntoa(receive(sock, 4)) + bound_port = struct.unpack('!H', receive(sock, 2))[0] + if host == '0.0.0.0': + host = settings['host'] + if host != settings['host'] or bound_port == 0: + raise OSError('unexpected SOCKS relay endpoint') + return sock, (host, bound_port) + except Exception: + sock.close() + raise diff --git a/experiments/udp-egress/http_proxy.py b/experiments/udp-egress/http_proxy.py new file mode 100644 index 00000000..312ba05d --- /dev/null +++ b/experiments/udp-egress/http_proxy.py @@ -0,0 +1,51 @@ +import base64 +import selectors +import socket +import socketserver + +from common import config + +SETTINGS = config() + + +class Proxy(socketserver.BaseRequestHandler): + def handle(self): + self.request.settimeout(30) + header = b'' + while not header.endswith(b'\r\n\r\n'): + part = self.request.recv(1) + if not part: + return + header += part + if len(header) > 16384: + return + lines = header[:-4].split(b'\r\n') + lines = [line for line in lines if not line.lower().startswith(b'proxy-authorization:')] + credentials = base64.b64encode((SETTINGS['user'] + ':' + SETTINGS['password']).encode()) + lines.append(b'Proxy-Authorization: Basic ' + credentials) + with socket.create_connection((SETTINGS['host'], SETTINGS['http_port']), timeout=8) as upstream: + upstream.settimeout(30) + upstream.sendall(b'\r\n'.join(lines) + b'\r\n\r\n') + with selectors.DefaultSelector() as selector: + selector.register(self.request, selectors.EVENT_READ, upstream) + selector.register(upstream, selectors.EVENT_READ, self.request) + while True: + events = selector.select(timeout=30) + if not events: + return + for event, _ in events: + data = event.fileobj.recv(65536) + if not data: + return + event.data.sendall(data) + + +class Server(socketserver.ThreadingTCPServer): + allow_reuse_address = True + daemon_threads = True + + +if __name__ == '__main__': + with Server(('192.0.2.1', 18080), Proxy) as server: + print('HTTP forwarding ready', flush=True) + server.serve_forever() diff --git a/experiments/udp-egress/network.conf b/experiments/udp-egress/network.conf new file mode 100644 index 00000000..7480cd84 --- /dev/null +++ b/experiments/udp-egress/network.conf @@ -0,0 +1,21 @@ +[program:udp-egress] +command=python3 -u /opt/udp-egress/network.py +priority=1 +autostart=true +autorestart=false +startsecs=1 +stopasgroup=true +killasgroup=true +stdout_logfile=/var/log/supervisord/udp-egress +redirect_stderr=true + +[program:udp-relay] +command=python3 -u /opt/udp-egress/udp.py +priority=2 +autostart=true +autorestart=false +startsecs=1 +stopasgroup=true +killasgroup=true +stdout_logfile=/var/log/supervisord/udp-relay +redirect_stderr=true diff --git a/experiments/udp-egress/network.py b/experiments/udp-egress/network.py new file mode 100644 index 00000000..29184fb2 --- /dev/null +++ b/experiments/udp-egress/network.py @@ -0,0 +1,60 @@ +import pathlib +import subprocess +import time + +from common import config, udp_targets + + +def run(*args): + subprocess.run(args, check=True) + + +def main(): + ready = pathlib.Path('/run/udp-egress-ready') + ready.unlink(missing_ok=True) + run('ip', 'netns', 'add', 'browser-udp') + run('ip', 'link', 'add', 'udp-host', 'type', 'veth', 'peer', 'name', 'udp-browser') + run('ip', 'link', 'set', 'udp-browser', 'netns', 'browser-udp') + run('iptables', '-I', 'FORWARD', '1', '-i', 'udp-host', '-j', 'DROP') + targets = udp_targets(config()) + for index, target in enumerate(targets): + run('iptables', '-t', 'nat', '-A', 'PREROUTING', '-i', 'udp-host', '-p', 'udp', + '-d', target['host'], '--dport', str(target['port']), '-j', 'DNAT', + '--to-destination', f'192.0.2.1:{40000 + index}') + run('ip', 'addr', 'add', '192.0.2.1/30', 'dev', 'udp-host') + run('ip', 'link', 'set', 'udp-host', 'up') + prefix = ('ip', 'netns', 'exec', 'browser-udp') + if pathlib.Path('/proc/sys/net/ipv6').exists(): + run(*prefix, 'sysctl', '-w', 'net.ipv6.conf.all.disable_ipv6=1', + 'net.ipv6.conf.default.disable_ipv6=1') + run(*prefix, 'ip', 'link', 'set', 'lo', 'up') + run(*prefix, 'ip', 'addr', 'add', '192.0.2.2/30', 'dev', 'udp-browser') + run(*prefix, 'ip', 'link', 'set', 'udp-browser', 'up') + run(*prefix, 'ip', 'route', 'add', 'default', 'via', '192.0.2.1') + resolv = pathlib.Path('/etc/netns/browser-udp') + resolv.mkdir(parents=True) + (resolv / 'resolv.conf').write_text('nameserver 8.8.8.8\n') + + processes = [ + subprocess.Popen(['python3', '-u', '/opt/udp-egress/http_proxy.py']), + subprocess.Popen(['socat', 'TCP4-LISTEN:9223,bind=127.0.0.1,reuseaddr,fork', + 'TCP4:192.0.2.2:9223']), + subprocess.Popen([*prefix, 'socat', + 'TCP4-LISTEN:9223,bind=192.0.2.2,reuseaddr,fork', + 'TCP4:127.0.0.1:9223']), + ] + time.sleep(1) + if any(p.poll() is not None for p in processes): + raise RuntimeError('network helper failed during startup') + ready.touch() + print('browser namespace ready; forwarding outside the relay is denied', flush=True) + while True: + for p in processes: + if p.poll() is not None: + ready.unlink(missing_ok=True) + raise RuntimeError('network helper exited; namespace forwarding remains denied') + time.sleep(1) + + +if __name__ == '__main__': + main() diff --git a/experiments/udp-egress/probe.cjs b/experiments/udp-egress/probe.cjs new file mode 100644 index 00000000..381cbcbf --- /dev/null +++ b/experiments/udp-egress/probe.cjs @@ -0,0 +1,68 @@ +const { chromium } = require("/usr/local/lib/node_modules/playwright-core"); + +async function main() { + const [httpsURL, stunURL, label = "probe"] = process.argv.slice(2); + if (!httpsURL || !stunURL) + throw new Error("usage: node probe.cjs HTTPS_ECHO_URL STUN_URL [LABEL]"); + const browser = await chromium.connectOverCDP("http://127.0.0.1:9222"); + try { + const context = browser.contexts()[0]; + const page = context.pages()[0] || (await context.newPage()); + await page.goto(httpsURL, { + waitUntil: "domcontentloaded", + timeout: 30000, + }); + const echo = JSON.parse(await page.locator("body").innerText()); + const rtc = await page.evaluate(async (url) => { + const connection = new RTCPeerConnection({ iceServers: [{ urls: url }] }); + const candidates = []; + const errors = []; + connection.onicecandidate = ({ candidate }) => { + if (candidate) + candidates.push({ + type: candidate.type, + address: candidate.address, + port: candidate.port, + protocol: candidate.protocol, + }); + }; + connection.onicecandidateerror = (event) => + errors.push({ code: event.errorCode, text: event.errorText }); + connection.createDataChannel("udp-egress-test"); + await connection.setLocalDescription(await connection.createOffer()); + await new Promise((resolve) => { + const timeout = setTimeout(resolve, 12000); + connection.onicegatheringstatechange = () => { + if (connection.iceGatheringState === "complete") { + clearTimeout(timeout); + resolve(); + } + }; + }); + const state = connection.iceGatheringState; + connection.close(); + return { candidates, errors, state }; + }, stunURL); + const serverReflexive = rtc.candidates.filter( + (candidate) => candidate.type === "srflx", + ); + console.log( + JSON.stringify({ + label, + browserVersion: browser.version(), + httpsIP: echo.ip, + browserIPsMatchHTTPS: + serverReflexive.length > 0 && + serverReflexive.every((candidate) => candidate.address === echo.ip), + rtc, + }), + ); + } finally { + await browser.close(); + } +} + +main().catch((error) => { + console.error(error.message); + process.exitCode = 1; +}); diff --git a/experiments/udp-egress/test_protocol.py b/experiments/udp-egress/test_protocol.py new file mode 100644 index 00000000..4f4321b3 --- /dev/null +++ b/experiments/udp-egress/test_protocol.py @@ -0,0 +1,86 @@ +import socket +import struct +import threading +import unittest + +from common import associate, receive, udp_targets +from udp import stun_address + + +class ProtocolTests(unittest.TestCase): + def proxy(self, auth_status=0, command_status=0): + listener = socket.socket() + listener.bind(('127.0.0.1', 0)) + listener.listen(1) + self.addCleanup(listener.close) + errors = [] + + def serve(): + try: + with listener.accept()[0] as connection: + self.assertEqual(receive(connection, 3), b'\x05\x01\x02') + connection.sendall(b'\x05\x02') + self.assertEqual(receive(connection, 1), b'\x01') + self.assertEqual(receive(connection, receive(connection, 1)[0]), b'user') + self.assertEqual(receive(connection, receive(connection, 1)[0]), b'password') + connection.sendall(bytes([1, auth_status])) + if auth_status: + return + self.assertEqual(receive(connection, 10), bytes.fromhex('05030001000000003039')) + connection.sendall(bytes([5, command_status, 0, 1]) + socket.inet_aton('127.0.0.1') + struct.pack('!H', 45678)) + except Exception as error: + errors.append(error) + + thread = threading.Thread(target=serve, daemon=True) + thread.start() + settings = {'host': '127.0.0.1', 'socks_port': listener.getsockname()[1], + 'user': 'user', 'password': 'password'} + return settings, thread, errors + + def test_association(self): + settings, thread, errors = self.proxy() + control, relay = associate(settings, 12345) + control.close() + thread.join(2) + self.assertFalse(thread.is_alive()) + self.assertEqual(errors, []) + self.assertEqual(relay, ('127.0.0.1', 45678)) + + def test_authentication_failure(self): + settings, thread, errors = self.proxy(auth_status=1) + with self.assertRaisesRegex(OSError, 'authentication failed'): + associate(settings, 12345) + thread.join(2) + self.assertEqual(errors, []) + + def test_command_failure(self): + settings, thread, errors = self.proxy(command_status=7) + with self.assertRaisesRegex(OSError, 'successful IPv4 reply'): + associate(settings, 12345) + thread.join(2) + self.assertEqual(errors, []) + + def test_explicit_targets(self): + valid = [{'host': '8.8.8.8', 'port': 3478}] + self.assertEqual(udp_targets({'udp_targets': valid}), valid) + invalid = [[], valid * 17, valid * 2, + [{'host': '127.0.0.1', 'port': 3478}], + [{'host': '2001:4860:4860::8888', 'port': 3478}], + [{'host': '8.8.8.8', 'port': 0}], + [{'host': '8.8.8.8', 'port': True}]] + for targets in invalid: + with self.subTest(targets=targets), self.assertRaises(ValueError): + udp_targets({'udp_targets': targets}) + + def test_stun_address(self): + ip = int.from_bytes(socket.inet_aton('198.51.100.25'), 'big') ^ 0x2112a442 + value = struct.pack('!BBHI', 0, 1, 50000 ^ 0x2112, ip) + response = bytes.fromhex('0101000c2112a442') + b'abcdefghijkl' + response += struct.pack('!HH', 32, 8) + value + self.assertEqual(stun_address(response), '198.51.100.25') + self.assertIsNone(stun_address(response[:-1])) + self.assertIsNone(stun_address(b'not a STUN packet')) + + +if __name__ == '__main__': + unittest.main() diff --git a/experiments/udp-egress/udp.py b/experiments/udp-egress/udp.py new file mode 100644 index 00000000..810aaf54 --- /dev/null +++ b/experiments/udp-egress/udp.py @@ -0,0 +1,117 @@ +import json +import pathlib +import select +import socket +import struct +import time + +from common import associate, config, udp_targets + + +def log(**values): + print(json.dumps(values), flush=True) + + +def stun_address(data): + if len(data) < 20 or data[:2] != b'\x01\x01' or data[4:8] != bytes.fromhex('2112a442'): + return None + offset = 20 + while offset + 4 <= len(data): + kind, length = struct.unpack('!HH', data[offset:offset + 4]) + value = data[offset + 4:offset + 4 + length] + if kind == 32 and length == 8 and len(value) == 8 and value[1] == 1: + return socket.inet_ntoa(bytes(a ^ b for a, b in zip(value[4:], bytes.fromhex('2112a442')))) + offset += 4 + ((length + 3) // 4) * 4 + return None + + +def main(): + settings = config() + deadline = time.monotonic() + 30 + while not pathlib.Path('/run/udp-egress-ready').exists(): + if time.monotonic() > deadline: + raise RuntimeError('browser network did not become ready') + time.sleep(0.1) + listeners = {} + for index, target in enumerate(udp_targets(settings)): + destination = (target['host'], target['port']) + listener = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + listener.bind(('192.0.2.1', 40000 + index)) + listeners[listener] = destination + flows = {} + sockets = {} + + def close(key): + flow = flows.pop(key) + for sock in [flow['udp'], flow['control']]: + sockets.pop(sock, None) + sock.close() + + log(state='ready') + while True: + ready, _, _ = select.select(list(listeners) + list(sockets), [], [], 1) + for sock in ready: + if sock in listeners: + data, source = sock.recvfrom(65535) + destination = listeners[sock] + key = (source, destination) + if key not in flows: + if len(flows) >= 32: + log(drop='flow_limit') + continue + udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + udp.bind(('0.0.0.0', 0)) + control = None + try: + control, relay = associate(settings, udp.getsockname()[1]) + udp.connect(relay) + except OSError as error: + udp.close() + if control is not None: + control.close() + log(drop='association_failed', error=str(error)) + continue + flows[key] = {'udp': udp, 'control': control, 'listener': sock, 'last': time.monotonic()} + sockets[udp] = (key, 'udp') + sockets[control] = (key, 'control') + log(association=relay, source=source, destination=destination) + flow = flows[key] + flow['last'] = time.monotonic() + packet = b'\x00\x00\x00\x01' + socket.inet_aton(destination[0]) + flow['udp'].send(packet + struct.pack('!H', destination[1]) + data) + if len(data) >= 20 and data[:2] == b'\x00\x01' and data[4:8] == bytes.fromhex('2112a442'): + log(direction='out', destination=destination, transaction=data[8:20].hex()) + else: + entry = sockets.get(sock) + if entry is None: + continue + key, kind = entry + if kind == 'control': + sock.recv(1024) + close(key) + continue + try: + packet = sock.recv(65535) + except OSError: + close(key) + continue + if len(packet) < 10 or packet[:4] != b'\x00\x00\x00\x01': + log(drop='unsupported_relay_header') + continue + origin = (socket.inet_ntoa(packet[4:8]), struct.unpack('!H', packet[8:10])[0]) + if origin != key[1]: + log(drop='unexpected_origin') + continue + payload = packet[10:] + flows[key]['listener'].sendto(payload, key[0]) + observed = stun_address(payload) + if observed: + log(direction='return', receiver=origin, transaction=payload[8:20].hex(), + server_observed_ip=observed) + for key, flow in list(flows.items()): + if time.monotonic() - flow['last'] > 60: + close(key) + + +if __name__ == '__main__': + main() diff --git a/experiments/udp-egress/upstream.example.json b/experiments/udp-egress/upstream.example.json new file mode 100644 index 00000000..41e934a2 --- /dev/null +++ b/experiments/udp-egress/upstream.example.json @@ -0,0 +1,11 @@ +{ + "host": "203.0.113.10", + "http_port": 8080, + "socks_port": 1080, + "user": "replace-at-runtime", + "password": "replace-at-runtime", + "udp_targets": [ + { "host": "8.8.8.8", "port": 53 }, + { "host": "203.0.113.20", "port": 3478 } + ] +} From a21b3b65ea2614f023ca7d2988241ee8b9044556 Mon Sep 17 00:00:00 2001 From: hiroTamada <88675973+hiroTamada@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:21:56 +0000 Subject: [PATCH 2/2] Route experiment UDP through egress-proxy connect-udp --- experiments/udp-egress/Dockerfile | 2 +- experiments/udp-egress/README.md | 135 ++++++++++--------- experiments/udp-egress/common.py | 96 ++++++++----- experiments/udp-egress/egress.example.json | 8 ++ experiments/udp-egress/http_proxy.py | 51 ------- experiments/udp-egress/network.py | 15 ++- experiments/udp-egress/probe.cjs | 4 +- experiments/udp-egress/test_protocol.py | 77 +++++------ experiments/udp-egress/udp.py | 85 ++++++------ experiments/udp-egress/upstream.example.json | 11 -- 10 files changed, 234 insertions(+), 250 deletions(-) create mode 100644 experiments/udp-egress/egress.example.json delete mode 100644 experiments/udp-egress/http_proxy.py delete mode 100644 experiments/udp-egress/upstream.example.json diff --git a/experiments/udp-egress/Dockerfile b/experiments/udp-egress/Dockerfile index 4dff599b..af00a320 100644 --- a/experiments/udp-egress/Dockerfile +++ b/experiments/udp-egress/Dockerfile @@ -1,6 +1,6 @@ ARG BASE_IMAGE=kernel-images:isp-base FROM ${BASE_IMAGE} -RUN apt-get update && apt-get install -y --no-install-recommends iproute2 iptables python3 socat tcpdump && rm -rf /var/lib/apt/lists/* +RUN apt-get update && apt-get install -y --no-install-recommends iproute2 iptables libnss3-tools python3 socat tcpdump && rm -rf /var/lib/apt/lists/* COPY experiments/udp-egress/*.py experiments/udp-egress/*.cjs experiments/udp-egress/chromium-launcher.sh /opt/udp-egress/ RUN chmod +x /opt/udp-egress/chromium-launcher.sh && \ sed -i 's|command=/usr/local/bin/chromium-launcher|command=/opt/udp-egress/chromium-launcher.sh|' /etc/supervisor/conf.d/services/chromium.conf && \ diff --git a/experiments/udp-egress/README.md b/experiments/udp-egress/README.md index 7d2bed23..402500b8 100644 --- a/experiments/udp-egress/README.md +++ b/experiments/udp-egress/README.md @@ -1,22 +1,29 @@ # Isolated browser UDP experiment -This opt-in headful image experiment keeps HTTPS on an HTTP CONNECT upstream and -forwards actual browser UDP through a SOCKS5 UDP association on the same upstream -binding. It does not change the normal images or implement a production egress -service. Use only a disposable, locally run container. +This opt-in headful image experiment sends a browser's HTTP(S) and UDP through +one egress-proxy process: HTTP(S) as an ordinary HTTP proxy (CONNECT, with +egress-proxy's MITM) and UDP as connect-udp +([RFC 9298](https://www.rfc-editor.org/rfc/rfc9298)) on the same listener. It +needs an egress-proxy built with connect-udp support and started with +`EGRESS_PROXY_UDP_ENABLED=true`. It does not change the normal images. Use only +a disposable, locally run container. + +The container holds no proxy credentials. egress-proxy authorizes both kinds of +traffic by the container's source address, selects the session's upstream, and +relays UDP through that upstream's SOCKS5 UDP association. ## Isolation Chromium runs as the unprivileged `kernel` user in the `browser-udp` network -namespace. The desktop, live-view server, HTTP forwarder and UDP relay remain in -the container's original namespace. +namespace. The desktop, live-view server, DevTools bridge, TCP relay and UDP +shim stay in the container's original namespace. -The browser's veth is the only interface captured by the UDP DNAT rules. Each -explicit test destination has its own local listener, which wraps the unchanged -payload in a SOCKS5 UDP header and forwards it to that destination through the -upstream. Returned payloads pass through normal reverse NAT, without changing -STUN contents or browser candidates. Each flow holds its SOCKS control connection -open. Unlisted UDP destinations are denied. +Chromium's only proxy is `192.0.2.1:18080`, a plain TCP relay to egress-proxy. +The UDP DNAT rules capture only the browser's veth. Each explicit test +destination has its own local listener; the shim opens one connect-udp request +per browser flow and carries the unchanged payload as DATAGRAM capsules. +Returned payloads pass through normal reverse NAT, without changing STUN +contents or browser candidates. Unlisted UDP destinations are denied. This intentionally uses explicit per-test routes rather than general transparent UDP interception. On some kernels, ancillary destination information contains the @@ -25,14 +32,11 @@ separately validated original-destination mechanism; this experiment does not assume that mechanism works. An independent FORWARD deny rule is installed before the browser gets its default -route. If the UDP listener stops, packets reach a closed local port. If the DNAT -rule disappears, IPv4 forwarding remains denied. The browser namespace has IPv6 -disabled when the kernel supports it; this is not a dual-stack forwarding test. -This design does not require the netfilter owner match, a TUN device, or policy -routing. DevTools uses TCP loopback bridges; live-view media is not intercepted. - -The HTTP forwarder adds upstream authentication and otherwise copies bytes. It is -not a reproduction of the host-side TLS MITM or request-mutation stack. +route. If the shim stops, packets reach a closed local port. If the DNAT rule +disappears, forwarding remains denied. If egress-proxy refuses or drops a flow, +nothing else carries it. The browser namespace has IPv6 disabled when the kernel +supports it; this is not a dual-stack forwarding test. DevTools uses TCP loopback +bridges; live-view media is not intercepted. ## Build and run @@ -47,31 +51,26 @@ docker build --build-arg BASE_IMAGE=kernel-images:udp-base \ -t kernel-images:udp-experiment -f experiments/udp-egress/Dockerfile . ``` -Create a root-owned `0600` upstream JSON file outside the checkout using -`upstream.example.json` as the schema. Supply an existing IPv4 ISP endpoint, -its actual HTTP and SOCKS ports, and the same credentials for both protocols. -Set `udp_targets` to the controlled STUN receivers' actual public IPv4 addresses -and ports, plus the public DNS resolver used by the browser namespace. DNS is -configured to use `8.8.8.8` in this experiment. The documentation addresses in the -example must be replaced. Configure the file before starting the container; do -not change it while running. Do not bake credentials into an image or commit them. - -Run the container through the repository's local-service workflow. It needs -privilege for its _container-local_ namespace and netfilter setup. Mount the JSON -read-only at `/run/udp-egress/upstream.json`. Keep HTTP/API/CDP ports bound to -loopback, publish only the live-view media range needed by authorized viewers, -and configure the live-view NAT address for the local environment. Do not attach -the container to the host network. - -For example, use this foreground command as the local service, substituting the -root-owned configuration path and the address authorized viewers can reach: +Create a directory outside the checkout containing `egress.json`, using +`egress.example.json` as the schema, and egress-proxy's MITM CA certificate as +`mitm-ca.crt` (certificate only, never the key). `egress_host` must be the +egress-proxy address the container reaches; egress-proxy sees the container's +address as the source. Set `udp_targets` to the controlled STUN receivers' +public IPv4 addresses and ports, plus the public DNS resolver used by the +browser namespace (`8.8.8.8`). Configure it before starting the container. + +egress-proxy must authorize the container's address as a claimed instance whose +route resolves to a Byteful ISP upstream. Give the container a fixed address on +a dedicated network so that mapping stays valid across restarts. Keep HTTP, API +and DevTools ports on loopback and publish only the live-view media range: ```sh +docker network create --subnet 172.30.0.0/24 udp-egress-net docker run --rm --privileged --tmpfs /dev/shm:size=2g \ - --name udp-egress-test \ - -p 127.0.0.1:8080:8080 -p 127.0.0.1:19222:9222 \ + --name udp-egress-test --network udp-egress-net --ip 172.30.0.10 \ + -p 127.0.0.1:18081:8080 -p 127.0.0.1:19222:9222 \ -p 127.0.0.1:10001:10001 -p 59000-59100:59000-59100/udp \ - --mount "type=bind,source=$CONFIG,target=/run/udp-egress/upstream.json,readonly" \ + -v "$CONFIG_DIR:/run/udp-egress:ro" \ -e WITHDOCKER=false -e ENABLE_WEBRTC=true \ -e NEKO_WEBRTC_EPR=59000-59100 -e NEKO_WEBRTC_NAT1TO1="$VIEWER_REACHABLE_IP" \ -e CHROMIUM_FLAGS="--no-sandbox --no-zygote --disable-dev-shm-usage --disable-quic --proxy-server=http://192.0.2.1:18080 --webrtc-ip-handling-policy=disable_non_proxied_udp" \ @@ -79,43 +78,47 @@ docker run --rm --privileged --tmpfs /dev/shm:size=2g \ ``` The normal launcher still supplies the display and DevTools settings. To enable -the experiment, merge `--webrtc-ip-handling-policy=default` into `/chromium/flags` -and restart only the Chromium supervisor program. Keep the relay and deny rule -in place before enabling UDP. Do not relax the policy in a normal browser image. -The local API provides a merge-and-restart operation: +WebRTC UDP, set the policy in `/chromium/flags` and restart only the Chromium +supervisor program. Keep the shim and deny rule in place first, and do not relax +the policy in a normal browser image. `PATCH /chromium/flags` appends a second +policy rather than replacing the first, so write the file directly: ```sh -curl --fail -X PATCH http://127.0.0.1:10001/chromium/flags \ - -H 'Content-Type: application/json' \ - -d '{"flags":["--webrtc-ip-handling-policy=default"]}' +docker exec udp-egress-test sh -c \ + 'mkdir -p /chromium && + printf "{\"flags\":[\"--webrtc-ip-handling-policy=default\"]}" > /chromium/flags && + supervisorctl restart chromium' ``` ## Validation Use HTTPS and STUN receivers you control, or equivalent independently verifiable receiver-side observations. The HTTPS echo endpoint used by `probe.cjs` must -return JSON containing the observed source address as `ip`. +return JSON containing the observed source address as `ip`. Add a unique query +string so a cached response cannot stand in for a request. ```sh python3 -m unittest discover -s experiments/udp-egress -p 'test_*.py' -docker exec CONTAINER node /opt/udp-egress/probe.cjs \ - https://receiver.example.test/request stun:receiver.example.test:3478 baseline +docker exec udp-egress-test node /opt/udp-egress/probe.cjs \ + "https://receiver.example.test/request?nonce=$RANDOM" stun:203.0.113.20:3478 baseline ``` Repeat with WebRTC UDP enabled. Require the HTTPS receiver source IP, the STUN receiver's XOR-MAPPED-ADDRESS, and the browser's server-reflexive candidate to -agree. Correlate the receiver response transaction IDs in -`/var/log/supervisord/udp-relay` with outgoing requests. Candidates alone are not -proof of the forwarding path. +agree, and the HTTPS issuer to be the MITM CA. Correlate the receiver response +transaction IDs in `/var/log/supervisord/udp-relay` with outgoing requests. +Candidates alone are not proof of the forwarding path. -Capture the container's external interface while testing. Check that no direct -browser UDP reaches the STUN receivers; encapsulated packets should instead go -to the upstream relay. Test all of: +Capture the container's external interface and the egress host's while testing. +No browser UDP should leave the container, and the egress host should exchange +UDP only with the upstream's relay, never with the STUN receivers. Test all of: -- fresh peer connections and SOCKS associations; +- fresh peer connections and associations; - a Chromium restart; - `supervisorctl stop udp-relay`, with HTTPS and live view still working; -- relay restart and successful return traffic; +- egress-proxy with UDP disabled, then stopped, checking there is no direct path; +- an egress-proxy restart while an association is open; +- instance invalidation while an association is open; - deletion of only this experiment's DNAT rule, checking FORWARD deny counters; - whole-container restart, re-enabling UDP only after the guard is installed; - IPv6 behavior, distinguishing an IPv6-disabled kernel from an IPv6-capable test. @@ -125,14 +128,12 @@ state. A healthy HTTP endpoint does not establish media health. ## Limits -The relay is intentionally IPv4-only, permits only the explicit public test -destinations, rejects SOCKS fragments, and caps itself at 32 flows with a -60-second idle timeout. Credentials and relay endpoints are static. This is not -a production-ready open proxy. -Receiver observations must establish provider parity before testing the browser. -Results for an ISP binding do not establish behavior for rotating residential -bindings, other providers, different kernels, or the deployed stealth browser. +The shim is intentionally IPv4-only, permits only the explicit public test +destinations, and caps itself at 32 flows with a 60-second idle timeout. It is +not a production design for capturing browser UDP. Results for an ISP binding do +not establish behavior for rotating residential bindings, other providers, +different kernels, or the deployed stealth browser. No production credentials belong in captures. Detach the private exposure before -stopping the container, then remove the runtime JSON file. Do not leave an +stopping the container, then remove the runtime directory. Do not leave an enabled experiment running unattended. diff --git a/experiments/udp-egress/common.py b/experiments/udp-egress/common.py index c35cadcc..14059550 100644 --- a/experiments/udp-egress/common.py +++ b/experiments/udp-egress/common.py @@ -1,11 +1,10 @@ import ipaddress import json import socket -import struct def config(): - with open('/run/udp-egress/upstream.json') as f: + with open('/run/udp-egress/egress.json') as f: return json.load(f) @@ -28,39 +27,72 @@ def udp_targets(settings): return targets -def receive(sock, count): - result = b'' - while len(result) < count: - part = sock.recv(count - len(result)) - if not part: - raise OSError('SOCKS control connection closed') - result += part - return result +def encode_varint(value): + for size, prefix in ((1, 0), (2, 0x40), (4, 0x80), (8, 0xc0)): + if value < 1 << (8 * size - 2): + data = bytearray(value.to_bytes(size, 'big')) + data[0] |= prefix + return bytes(data) + raise ValueError('varint too large') -def associate(settings, port): - sock = socket.create_connection((settings['host'], settings['socks_port']), timeout=8) +def decode_varint(data, offset=0): + """Returns (value, next offset), or None when data is incomplete.""" + if offset >= len(data): + return None + size = 1 << (data[offset] >> 6) + if offset + size > len(data): + return None + value = data[offset] & 0x3f + for byte in data[offset + 1:offset + size]: + value = value << 8 | byte + return value, offset + size + + +def datagram_capsule(payload): + return encode_varint(0) + encode_varint(len(payload) + 1) + b'\x00' + payload + + +def parse_capsules(buffer): + """Splits complete capsules off buffer. Returns (datagram payloads, rest).""" + payloads = [] + offset = 0 + while True: + kind = decode_varint(buffer, offset) + if kind is None: + break + length = decode_varint(buffer, kind[1]) + if length is None or length[1] + length[0] > len(buffer): + break + body = buffer[length[1]:length[1] + length[0]] + offset = length[1] + length[0] + context = decode_varint(body) + if kind[0] == 0 and context is not None and context[0] == 0: + payloads.append(body[context[1]:]) + return payloads, buffer[offset:] + + +def open_tunnel(settings, destination): + """Opens a connect-udp tunnel to destination through egress-proxy.""" + sock = socket.create_connection((settings['egress_host'], settings['egress_port']), timeout=10) try: - sock.sendall(b'\x05\x01\x02') - if receive(sock, 2) != b'\x05\x02': - raise OSError('SOCKS username/password authentication unavailable') - user, password = settings['user'].encode(), settings['password'].encode() - if not 0 < len(user) < 256 or not 0 < len(password) < 256: - raise ValueError('invalid SOCKS credential length') - sock.sendall(b'\x01' + bytes([len(user)]) + user + bytes([len(password)]) + password) - if receive(sock, 2) != b'\x01\x00': - raise OSError('SOCKS authentication failed') - sock.sendall(b'\x05\x03\x00\x01\x00\x00\x00\x00' + struct.pack('!H', port)) - version, status, reserved, address_type = receive(sock, 4) - if version != 5 or status != 0 or reserved != 0 or address_type != 1: - raise OSError('SOCKS UDP association requires a successful IPv4 reply') - host = socket.inet_ntoa(receive(sock, 4)) - bound_port = struct.unpack('!H', receive(sock, 2))[0] - if host == '0.0.0.0': - host = settings['host'] - if host != settings['host'] or bound_port == 0: - raise OSError('unexpected SOCKS relay endpoint') - return sock, (host, bound_port) + host, port = destination + sock.sendall((f'GET /.well-known/masque/udp/{host}/{port}/ HTTP/1.1\r\n' + f'Host: {settings["egress_host"]}:{settings["egress_port"]}\r\n' + 'Connection: Upgrade\r\nUpgrade: connect-udp\r\n' + 'Capsule-Protocol: ?1\r\n\r\n').encode()) + response = b'' + while b'\r\n\r\n' not in response: + part = sock.recv(4096) + if not part or len(response) > 16384: + raise OSError('egress closed during connect-udp') + response += part + head, rest = response.split(b'\r\n\r\n', 1) + status = head.split(b'\r\n', 1)[0] + if status.split(b' ')[1:2] != [b'101']: + raise OSError(f'connect-udp refused: {status.decode(errors="replace")}') + sock.settimeout(None) + return sock, rest except Exception: sock.close() raise diff --git a/experiments/udp-egress/egress.example.json b/experiments/udp-egress/egress.example.json new file mode 100644 index 00000000..816f89de --- /dev/null +++ b/experiments/udp-egress/egress.example.json @@ -0,0 +1,8 @@ +{ + "egress_host": "192.0.2.10", + "egress_port": 3129, + "udp_targets": [ + { "host": "8.8.8.8", "port": 53 }, + { "host": "203.0.113.20", "port": 3478 } + ] +} diff --git a/experiments/udp-egress/http_proxy.py b/experiments/udp-egress/http_proxy.py deleted file mode 100644 index 312ba05d..00000000 --- a/experiments/udp-egress/http_proxy.py +++ /dev/null @@ -1,51 +0,0 @@ -import base64 -import selectors -import socket -import socketserver - -from common import config - -SETTINGS = config() - - -class Proxy(socketserver.BaseRequestHandler): - def handle(self): - self.request.settimeout(30) - header = b'' - while not header.endswith(b'\r\n\r\n'): - part = self.request.recv(1) - if not part: - return - header += part - if len(header) > 16384: - return - lines = header[:-4].split(b'\r\n') - lines = [line for line in lines if not line.lower().startswith(b'proxy-authorization:')] - credentials = base64.b64encode((SETTINGS['user'] + ':' + SETTINGS['password']).encode()) - lines.append(b'Proxy-Authorization: Basic ' + credentials) - with socket.create_connection((SETTINGS['host'], SETTINGS['http_port']), timeout=8) as upstream: - upstream.settimeout(30) - upstream.sendall(b'\r\n'.join(lines) + b'\r\n\r\n') - with selectors.DefaultSelector() as selector: - selector.register(self.request, selectors.EVENT_READ, upstream) - selector.register(upstream, selectors.EVENT_READ, self.request) - while True: - events = selector.select(timeout=30) - if not events: - return - for event, _ in events: - data = event.fileobj.recv(65536) - if not data: - return - event.data.sendall(data) - - -class Server(socketserver.ThreadingTCPServer): - allow_reuse_address = True - daemon_threads = True - - -if __name__ == '__main__': - with Server(('192.0.2.1', 18080), Proxy) as server: - print('HTTP forwarding ready', flush=True) - server.serve_forever() diff --git a/experiments/udp-egress/network.py b/experiments/udp-egress/network.py index 29184fb2..2cdc0b3e 100644 --- a/experiments/udp-egress/network.py +++ b/experiments/udp-egress/network.py @@ -16,7 +16,8 @@ def main(): run('ip', 'link', 'add', 'udp-host', 'type', 'veth', 'peer', 'name', 'udp-browser') run('ip', 'link', 'set', 'udp-browser', 'netns', 'browser-udp') run('iptables', '-I', 'FORWARD', '1', '-i', 'udp-host', '-j', 'DROP') - targets = udp_targets(config()) + settings = config() + targets = udp_targets(settings) for index, target in enumerate(targets): run('iptables', '-t', 'nat', '-A', 'PREROUTING', '-i', 'udp-host', '-p', 'udp', '-d', target['host'], '--dport', str(target['port']), '-j', 'DNAT', @@ -35,8 +36,18 @@ def main(): resolv.mkdir(parents=True) (resolv / 'resolv.conf').write_text('nameserver 8.8.8.8\n') + ca = pathlib.Path('/run/udp-egress/mitm-ca.crt') + if ca.exists(): + nssdb = pathlib.Path('/home/kernel/.pki/nssdb') + run('runuser', '-u', 'kernel', '--', 'mkdir', '-p', str(nssdb)) + if not (nssdb / 'cert9.db').exists(): + run('runuser', '-u', 'kernel', '--', 'certutil', '-N', '-d', f'sql:{nssdb}', '--empty-password') + run('runuser', '-u', 'kernel', '--', 'certutil', '-A', '-d', f'sql:{nssdb}', + '-t', 'C,,', '-n', 'egress-proxy-mitm', '-i', str(ca)) + processes = [ - subprocess.Popen(['python3', '-u', '/opt/udp-egress/http_proxy.py']), + subprocess.Popen(['socat', 'TCP4-LISTEN:18080,bind=192.0.2.1,reuseaddr,fork', + f'TCP4:{settings["egress_host"]}:{settings["egress_port"]}']), subprocess.Popen(['socat', 'TCP4-LISTEN:9223,bind=127.0.0.1,reuseaddr,fork', 'TCP4:192.0.2.2:9223']), subprocess.Popen([*prefix, 'socat', diff --git a/experiments/udp-egress/probe.cjs b/experiments/udp-egress/probe.cjs index 381cbcbf..96cfc257 100644 --- a/experiments/udp-egress/probe.cjs +++ b/experiments/udp-egress/probe.cjs @@ -8,10 +8,11 @@ async function main() { try { const context = browser.contexts()[0]; const page = context.pages()[0] || (await context.newPage()); - await page.goto(httpsURL, { + const response = await page.goto(httpsURL, { waitUntil: "domcontentloaded", timeout: 30000, }); + const security = await response.securityDetails(); const echo = JSON.parse(await page.locator("body").innerText()); const rtc = await page.evaluate(async (url) => { const connection = new RTCPeerConnection({ iceServers: [{ urls: url }] }); @@ -51,6 +52,7 @@ async function main() { label, browserVersion: browser.version(), httpsIP: echo.ip, + httpsIssuer: security && security.issuer, browserIPsMatchHTTPS: serverReflexive.length > 0 && serverReflexive.every((candidate) => candidate.address === echo.ip), diff --git a/experiments/udp-egress/test_protocol.py b/experiments/udp-egress/test_protocol.py index 4f4321b3..ad4f33c6 100644 --- a/experiments/udp-egress/test_protocol.py +++ b/experiments/udp-egress/test_protocol.py @@ -3,62 +3,63 @@ import threading import unittest -from common import associate, receive, udp_targets +from common import (datagram_capsule, decode_varint, encode_varint, open_tunnel, + parse_capsules, udp_targets) from udp import stun_address class ProtocolTests(unittest.TestCase): - def proxy(self, auth_status=0, command_status=0): + def egress(self, reply): listener = socket.socket() listener.bind(('127.0.0.1', 0)) listener.listen(1) self.addCleanup(listener.close) - errors = [] + requests = [] def serve(): - try: - with listener.accept()[0] as connection: - self.assertEqual(receive(connection, 3), b'\x05\x01\x02') - connection.sendall(b'\x05\x02') - self.assertEqual(receive(connection, 1), b'\x01') - self.assertEqual(receive(connection, receive(connection, 1)[0]), b'user') - self.assertEqual(receive(connection, receive(connection, 1)[0]), b'password') - connection.sendall(bytes([1, auth_status])) - if auth_status: - return - self.assertEqual(receive(connection, 10), bytes.fromhex('05030001000000003039')) - connection.sendall(bytes([5, command_status, 0, 1]) + socket.inet_aton('127.0.0.1') + struct.pack('!H', 45678)) - except Exception as error: - errors.append(error) + with listener.accept()[0] as connection: + request = b'' + while b'\r\n\r\n' not in request: + request += connection.recv(4096) + requests.append(request) + connection.sendall(reply) thread = threading.Thread(target=serve, daemon=True) thread.start() - settings = {'host': '127.0.0.1', 'socks_port': listener.getsockname()[1], - 'user': 'user', 'password': 'password'} - return settings, thread, errors + settings = {'egress_host': '127.0.0.1', 'egress_port': listener.getsockname()[1]} + return settings, thread, requests - def test_association(self): - settings, thread, errors = self.proxy() - control, relay = associate(settings, 12345) - control.close() + def test_tunnel(self): + settings, thread, requests = self.egress( + b'HTTP/1.1 101 Switching Protocols\r\nUpgrade: connect-udp\r\n\r\n' + datagram_capsule(b'early')) + sock, rest = open_tunnel(settings, ('8.8.8.8', 53)) + sock.close() thread.join(2) - self.assertFalse(thread.is_alive()) - self.assertEqual(errors, []) - self.assertEqual(relay, ('127.0.0.1', 45678)) + self.assertTrue(requests[0].startswith(b'GET /.well-known/masque/udp/8.8.8.8/53/ HTTP/1.1\r\n')) + self.assertIn(b'Upgrade: connect-udp\r\n', requests[0]) + self.assertNotIn(b'Proxy-Authorization', requests[0]) + self.assertEqual(parse_capsules(rest), ([b'early'], b'')) - def test_authentication_failure(self): - settings, thread, errors = self.proxy(auth_status=1) - with self.assertRaisesRegex(OSError, 'authentication failed'): - associate(settings, 12345) + def test_tunnel_refused(self): + settings, thread, _ = self.egress(b'HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n') + with self.assertRaisesRegex(OSError, '403'): + open_tunnel(settings, ('8.8.8.8', 443)) thread.join(2) - self.assertEqual(errors, []) - def test_command_failure(self): - settings, thread, errors = self.proxy(command_status=7) - with self.assertRaisesRegex(OSError, 'successful IPv4 reply'): - associate(settings, 12345) - thread.join(2) - self.assertEqual(errors, []) + def test_varint(self): + for value in [0, 63, 64, 16383, 16384, 2**30 - 1, 2**30, 2**62 - 1]: + encoded = encode_varint(value) + self.assertEqual(decode_varint(encoded), (value, len(encoded))) + self.assertIsNone(decode_varint(encoded[:-1]) if len(encoded) > 1 else decode_varint(b'')) + + def test_capsules(self): + stream = datagram_capsule(b'one') + encode_varint(7) + encode_varint(2) + b'xx' + stream += datagram_capsule(b'two') + self.assertEqual(parse_capsules(stream), ([b'one', b'two'], b'')) + partial = datagram_capsule(b'three') + self.assertEqual(parse_capsules(stream + partial[:3]), ([b'one', b'two'], partial[:3])) + nonzero_context = encode_varint(0) + encode_varint(2) + b'\x01x' + self.assertEqual(parse_capsules(nonzero_context), ([], b'')) def test_explicit_targets(self): valid = [{'host': '8.8.8.8', 'port': 3478}] diff --git a/experiments/udp-egress/udp.py b/experiments/udp-egress/udp.py index 810aaf54..bd9c673e 100644 --- a/experiments/udp-egress/udp.py +++ b/experiments/udp-egress/udp.py @@ -5,7 +5,7 @@ import struct import time -from common import associate, config, udp_targets +from common import config, datagram_capsule, open_tunnel, parse_capsules, udp_targets def log(**values): @@ -34,22 +34,30 @@ def main(): time.sleep(0.1) listeners = {} for index, target in enumerate(udp_targets(settings)): - destination = (target['host'], target['port']) listener = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) listener.bind(('192.0.2.1', 40000 + index)) - listeners[listener] = destination + listeners[listener] = (target['host'], target['port']) flows = {} - sockets = {} + tunnels = {} def close(key): flow = flows.pop(key) - for sock in [flow['udp'], flow['control']]: - sockets.pop(sock, None) - sock.close() + tunnels.pop(flow['tunnel'], None) + flow['tunnel'].close() - log(state='ready') + def deliver(key, data): + flow = flows[key] + payloads, flow['buffer'] = parse_capsules(flow['buffer'] + data) + for payload in payloads: + flow['listener'].sendto(payload, key[0]) + observed = stun_address(payload) + if observed: + log(direction='return', receiver=key[1], transaction=payload[8:20].hex(), + server_observed_ip=observed) + + log(state='ready', egress=f'{settings["egress_host"]}:{settings["egress_port"]}') while True: - ready, _, _ = select.select(list(listeners) + list(sockets), [], [], 1) + ready, _, _ = select.select(list(listeners) + list(tunnels), [], [], 1) for sock in ready: if sock in listeners: data, source = sock.recvfrom(65535) @@ -59,55 +67,38 @@ def close(key): if len(flows) >= 32: log(drop='flow_limit') continue - udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - udp.bind(('0.0.0.0', 0)) - control = None try: - control, relay = associate(settings, udp.getsockname()[1]) - udp.connect(relay) + tunnel, rest = open_tunnel(settings, destination) except OSError as error: - udp.close() - if control is not None: - control.close() - log(drop='association_failed', error=str(error)) + log(drop='tunnel_failed', destination=destination, error=str(error)) continue - flows[key] = {'udp': udp, 'control': control, 'listener': sock, 'last': time.monotonic()} - sockets[udp] = (key, 'udp') - sockets[control] = (key, 'control') - log(association=relay, source=source, destination=destination) - flow = flows[key] - flow['last'] = time.monotonic() - packet = b'\x00\x00\x00\x01' + socket.inet_aton(destination[0]) - flow['udp'].send(packet + struct.pack('!H', destination[1]) + data) + flows[key] = {'tunnel': tunnel, 'listener': sock, 'buffer': b'', 'last': time.monotonic()} + tunnels[tunnel] = key + log(tunnel='open', source=source, destination=destination) + if rest: + deliver(key, rest) + flows[key]['last'] = time.monotonic() + try: + flows[key]['tunnel'].sendall(datagram_capsule(data)) + except OSError: + close(key) + continue if len(data) >= 20 and data[:2] == b'\x00\x01' and data[4:8] == bytes.fromhex('2112a442'): log(direction='out', destination=destination, transaction=data[8:20].hex()) else: - entry = sockets.get(sock) - if entry is None: - continue - key, kind = entry - if kind == 'control': - sock.recv(1024) - close(key) + key = tunnels.get(sock) + if key is None: continue try: - packet = sock.recv(65535) + data = sock.recv(65536) except OSError: + data = b'' + if not data: + log(tunnel='closed', destination=key[1]) close(key) continue - if len(packet) < 10 or packet[:4] != b'\x00\x00\x00\x01': - log(drop='unsupported_relay_header') - continue - origin = (socket.inet_ntoa(packet[4:8]), struct.unpack('!H', packet[8:10])[0]) - if origin != key[1]: - log(drop='unexpected_origin') - continue - payload = packet[10:] - flows[key]['listener'].sendto(payload, key[0]) - observed = stun_address(payload) - if observed: - log(direction='return', receiver=origin, transaction=payload[8:20].hex(), - server_observed_ip=observed) + flows[key]['last'] = time.monotonic() + deliver(key, data) for key, flow in list(flows.items()): if time.monotonic() - flow['last'] > 60: close(key) diff --git a/experiments/udp-egress/upstream.example.json b/experiments/udp-egress/upstream.example.json deleted file mode 100644 index 41e934a2..00000000 --- a/experiments/udp-egress/upstream.example.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "host": "203.0.113.10", - "http_port": 8080, - "socks_port": 1080, - "user": "replace-at-runtime", - "password": "replace-at-runtime", - "udp_targets": [ - { "host": "8.8.8.8", "port": 53 }, - { "host": "203.0.113.20", "port": 3478 } - ] -}