QUIC Proxy Mode
How OpenSnell's v5 QUIC envelope was reverse-engineered from real Surge HTTP/3 captures, and how the server interoperates with the official client.
QUIC proxy mode is the marquee v5 feature: it lets a Surge client send
HTTP/3 traffic through a snell proxy without TCP fallback. The official
Surge snell-server ships it; OpenSnell's server implements the same
wire format, reverse-engineered from real captures.
The problem QUIC mode solves
Snell's TCP wire format is fine for CONNECT host:port over TCP. It
breaks down the moment the application protocol is QUIC (RFC 9000),
because:
- QUIC is a UDP-based transport. You can't tunnel it through a TCP CONNECT without re-encapsulating, which destroys QUIC's loss-recovery and congestion-control assumptions.
- HTTP/3, the dominant QUIC user, requires the SNI / target hostname to reach the destination so it can pick the right TLS cert. If the proxy just forwarded raw UDP packets, you'd leak the SNI on the wire to the proxy operator and to anyone on the path between client and proxy.
The v5 QUIC envelope solves both: the first UDP packet on a flow is
wrapped in a snell-encrypted envelope that carries (host, port); every
subsequent UDP packet in either direction is forwarded as raw QUIC, so
QUIC's transport behaves as if the proxy weren't there.
Envelope layout (v5, client → server, first packet of a flow)
[salt(16B random)]
[AEAD-Seal(K, nonce=0, [0x04, 0, 0, padLen_be, payloadLen_be]) || 16B tag]
[padding(padLen)]
[AEAD-Seal(K, nonce=1, request_header || inner_QUIC_packet) || 16B tag]
request_header = [0x01, 0x01, 0x00, hostlen, host, port_be]
K = Argon2id(psk_utf8, salt, 3, 8 KiB, 1, 32)[:16]
AEAD = AES-128-GCMThe first frame is the same shape as the TCP v4/v5 frame header
(type=0x04, padding-length, payload-length), just with payloadLen
sized to cover the inner request header plus the wrapped QUIC Initial.
Server-side state machine
After OpenSnell's server decodes the first envelope:
- Verify the AEAD tag against
K. Reject silently on failure (this makes brute-forcing the PSK indistinguishable from random UDP noise on the wire). - Parse
request_headerto extract(host, port). - Dial the upstream UDP socket (with the same
egress-interface/ipv6policy as TCP). - Forward the inner QUIC Initial packet to the upstream as-is.
- Store
(client_src_ip, client_src_port) → upstream_connin an in-process map.
Every subsequent UDP packet:
- From the client's
(src_ip, src_port): look up the upstream, forward raw bytes (no snell framing, no crypto). - From the upstream toward the client: same map in reverse.
The map's TTL matches QUIC's idle-timeout convention; entries are garbage-collected after a few seconds of bidirectional silence.
How OpenSnell adapts to the official format
There's no published spec for the v5 QUIC envelope — only the deployed binary. The format above was reverse-engineered by:
- Running Surge against a known-PSK OpenSnell server, with HTTP/3 enabled on a known target.
- Capturing the resulting UDP traffic to the snell port with
tcpdump. - Decrypting the first packet with the PSK and AES-128-GCM, walking
the byte layout until the inner payload's first 5 bytes matched the
QUIC long-header pattern (
1100 ????). - Re-implementing the layout in Go and round-tripping live HTTP/3 traffic through it.
A real 1359-byte capture is committed as a fixture in
components/snell/quic_test.go,
so any future change to the envelope parser is regression-tested against
a packet that genuinely came off the wire from Surge.
Why Argon2id?
The key-derivation function is Argon2id with parameters
(t=3, m=8 KiB, p=1) — the exact same parameters the official server
uses, derived from the PSK as a UTF-8 string (not base64). This matters
because:
- The KDF runs once per envelope (which is once per flow, not once per packet), so the 8 KiB memory cost is amortized away in real traffic — but it raises the brute-force cost per attempted PSK by several orders of magnitude compared to a single SHA-256 round.
- Matching the parameters byte-for-byte is what makes interop work; any deviation produces an AEAD tag mismatch on packet 1 and the connection is silently dropped.
Adapting in both directions
OpenSnell's QUIC mode is unique in two ways relative to the official binary:
-
Open AEAD path. The Go AES-GCM stdlib (
crypto/aes+crypto/cipher) hits AES-NI on amd64 and ARMv8 crypto extensions on aarch64, so per-packet decode is hardware-accelerated and on par with the OpenSSLGCM module for x86_64the official binary links to. -
Same upstream socket policy.
egress-interface,ipv6toggle, anddns = …all apply identically to QUIC upstream dials and TCP upstream dials — there's no second-class path. This lets you, for example, force HTTP/3 traffic out a specific WAN interface on a multi-homed server without separate UDP-specific config.
Limits
- Only the server speaks QUIC. The Go client is SOCKS5-only.
- QUIC mode only handles the snell envelope; OpenSnell does not parse
any QUIC frames internally and does not multiplex. One flow per
(client_src_ip, client_src_port)upstream, identical to how a stateful firewall would behave. - The server intentionally does not retry envelopes; if the first packet is dropped, Surge's QUIC stack handles the retransmit and the second copy of the envelope decrypts identically (same salt → same key → same tag).