feat(udp): proxy QUIC/HTTP-3 through a SOCKS5 upstream via UDP ASSOCIATE#46
Merged
Conversation
Until now QUIC (UDP 443) was only ever dropped so HTTP/3-preferring clients would fall back to the proxied TCP path. When the upstream speaks SOCKS5 — the one protocol that can carry UDP — we can do better and genuinely proxy HTTP/3. New behaviour (Linux only), enabled automatically when the upstream is SOCKS5 and --allow-quic was not passed: - nftables TPROXY redirects forwarded QUIC datagrams to a transparent UDP listener (IP_TRANSPARENT + IP_RECVORIGDSTADDR), with policy routing (fwmark -> local route table) delivering them locally. Setup failure falls back to the previous QUIC drop, so HTTP/3 still can't leak. - src/udp.rs relays each (client, original-dest) flow over a SOCKS5 UDP ASSOCIATE session, wrapping/unwrapping the RFC 1928 §7 UDP header and source-spoofing replies from the real destination via IP_TRANSPARENT. - tunnel.rs gains udp_associate() plus encode/parse helpers for the SOCKS5 UDP request header; the SOCKS5 handshake is split into reusable auth-negotiation and CONNECT/ASSOCIATE steps. Gateway-originated QUIC (under --local-traffic) is still dropped, since TPROXY only hooks the forward path. HTTP CONNECT upstreams are unchanged (QUIC dropped, since CONNECT cannot carry UDP). Adds unit tests for the UDP header codec, the ASSOCIATE handshake (success / unspecified-bind / non-SOCKS5 rejection), and the TPROXY rule builder. e2e Test 5 now exercises the drop path via an HTTP CONNECT upstream (SOCKS5 no longer drops UDP 443). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The QUIC-drop test sends a loopback UDP probe to 127.0.0.1:443 and expected the send to succeed but the datagram to never arrive. But the drop lives in the nftables OUTPUT hook, and the kernel reports a locally-generated packet killed there by returning EPERM directly to send_to(). The test propagated that EPERM via `?` and failed with "Operation not permitted (os error 1)" — a pre-existing failure on main (commit 8d8c8f8), not a regression from the UDP relay work. Accept either EPERM on send or non-delivery as proof the probe was blocked; only actual delivery to the server is a bypass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Previously QUIC (UDP 443) was only ever dropped, forcing HTTP/3-preferring clients to fall back to the proxied TCP path (#44). When the upstream speaks SOCKS5 — the one protocol that can carry UDP — we can do better and genuinely proxy HTTP/3.
Enabled automatically (Linux only) when the upstream is SOCKS5 and
--allow-quicwas not passed. No new flags.How it works
nftables.rs): atproxy_preroutingchain TPROXY-redirects forwarded QUIC datagrams to the proxy's UDP listener, plus policy routing (fwmark → local route table) so the kernel delivers the foreign-destined datagrams locally. IPv4 + IPv6.udp.rs): a TPROXY socket (IP_TRANSPARENT+IP_RECVORIGDSTADDR) recovers each datagram's real destination viarecvmsgcmsg.(client, original-dest)flow gets a SOCKS5 UDP ASSOCIATE session; datagrams are wrapped/unwrapped per RFC 1928 §7, and replies are source-spoofed from the real destination via a per-sessionIP_TRANSPARENTsocket so they look native to the client.tunnel.rs): addsudp_associate()and the UDP-header codec; the SOCKS5 handshake is refactored into reusable auth-negotiation + CONNECT/ASSOCIATE steps.Safety / fallback
CAP_NET_ADMIN) falls back to dropping QUIC, so HTTP/3 still can't bypass the proxy and leak the client IP — it just can't be proxied.--local-traffic) is still dropped, since TPROXY only hooks the forward path.Tests
udp_associatehandshake (success / unspecified-bind → proxy IP / non-SOCKS5 rejection), TPROXY rule builder (v4/v6).Verification
cargo fmt --check,cargo clippy --all-targets -- -D warnings,cargo test— 162 passed.cargo zigbuild,RUSTFLAGS=-D warnings): full workspace incl.udp.rs+ e2e compiles clean.🤖 Generated with Claude Code