Skip to content

feat(udp): proxy QUIC/HTTP-3 through a SOCKS5 upstream via UDP ASSOCIATE#46

Merged
madeye merged 2 commits into
mainfrom
feature/udp-associate-quic
Jun 30, 2026
Merged

feat(udp): proxy QUIC/HTTP-3 through a SOCKS5 upstream via UDP ASSOCIATE#46
madeye merged 2 commits into
mainfrom
feature/udp-associate-quic

Conversation

@madeye

@madeye madeye commented Jun 30, 2026

Copy link
Copy Markdown
Owner

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-quic was not passed. No new flags.

How it works

  1. Firewall (nftables.rs): a tproxy_prerouting chain 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.
  2. Listener (udp.rs): a TPROXY socket (IP_TRANSPARENT + IP_RECVORIGDSTADDR) recovers each datagram's real destination via recvmsg cmsg.
  3. Relay: each (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-session IP_TRANSPARENT socket so they look native to the client.
  4. Tunnel (tunnel.rs): adds udp_associate() and the UDP-header codec; the SOCKS5 handshake is refactored into reusable auth-negotiation + CONNECT/ASSOCIATE steps.

Safety / fallback

  • TPROXY setup failure (e.g. missing 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.
  • Gateway-originated QUIC (under --local-traffic) is still dropped, since TPROXY only hooks the forward path.
  • HTTP CONNECT upstreams are unchanged (QUIC dropped — CONNECT can't carry UDP).

Tests

  • Unit: SOCKS5 UDP header encode/parse (IPv4/IPv6/domain, malformed, round-trip), udp_associate handshake (success / unspecified-bind → proxy IP / non-SOCKS5 rejection), TPROXY rule builder (v4/v6).
  • e2e Test 5 now drives the drop path via an HTTP CONNECT upstream (SOCKS5 no longer drops UDP 443); the SOCKS5 TPROXY path needs a real forwarding setup and is noted as covered separately.

Verification

  • macOS: cargo fmt --check, cargo clippy --all-targets -- -D warnings, cargo test — 162 passed.
  • Linux (cargo zigbuild, RUSTFLAGS=-D warnings): full workspace incl. udp.rs + e2e compiles clean.

🤖 Generated with Claude Code

madeye and others added 2 commits June 30, 2026 14:19
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>
@madeye
madeye merged commit bcc50a8 into main Jun 30, 2026
6 checks passed
@madeye
madeye deleted the feature/udp-associate-quic branch June 30, 2026 06:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant