feat(wind-tuic): HTTP/3 masquerade for non-TUIC clients#24
Merged
Conversation
TUIC advertises the `h3` ALPN to disguise its traffic, so real TUIC clients and active HTTP/3 probers negotiate the identical ALPN. Today a prober speaking real HTTP/3 (e.g. `curl --http3`) gets its QUIC handshake accepted but then every stream is reset — an "accept then reset" fingerprint that distinguishes the server from a real web server. This adds an HTTP/3 masquerade: when a connecting client isn't TUIC, the server poses as a genuine HTTP/3 web server by reverse-proxying the request to a configured upstream site and relaying the response. Approach (one engine, both backends): - A backend-agnostic `h3::quic` adapter over `wind_quic::QuicConnection` (new `wind-quic/src/h3_adapter.rs`, feature `h3`), so the hyperium `h3` crate drives the masquerade over either the quinn or quiche backend. - Detection lives once in `serve_connection`: peek the first uni stream's first byte — `0x05` (TUIC VER) routes to TUIC, anything else to the masquerade. The peeked byte is replayed via `PrefixedRecv` so both the TUIC parser and the h3 adapter read from byte 0. This is the key reason the adapter owns stream acceptance rather than using `quiche::h3` (which can't be handed back a consumed byte). - The masquerade runner (`wind-tuic/src/server/masquerade.rs`) runs an `h3::server` over the adapter and reverse-proxies each request to the upstream via a pooled hyper + rustls (platform-verifier) client; on upstream failure it returns 502 rather than resetting. Config: new `[masquerade] enabled/upstream` in tuic-server, threaded into both the quinn and quiche inbounds. Gated by the wind-tuic `masquerade` cargo feature (on by default); disabled at runtime by default. Verified: builds for quinn+masquerade, `--features quiche`, masquerade off, and the full workspace; clippy clean; existing tuic-tests (quinn relay, quiche relay, 0-RTT, cert-reload) still pass — the `0x05` discriminator routes real TUIC unchanged on both backends. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Studied Itsusinn/tuic's `camouflage.rs`, which reverse-proxies with `reqwest` rather than a hand-rolled HTTP client. Apply the same simplification and add an end-to-end test that probes the masquerade with reqwest's HTTP/3 client. Simplify the masquerade reverse proxy: - Replace the hand-rolled hyper + hyper-rustls + rustls-platform-verifier client (ensure_provider / build_tls / shared_client / manual request building) with a single shared `reqwest::Client`; reqwest owns TLS, roots, pooling and ALPN. - Drop the hyper/hyper-util/hyper-rustls/http-body-util deps from the `masquerade` feature (reqwest was already in the tree). - Add robustness borrowed from upstream: stream the response body chunk-by-chunk (instead of buffering it whole) and cap request/response bodies (16 MB / 64 MB). - Keep the backend-agnostic `h3::quic` adapter (works over quinn and quiche), rather than the quinn-only adapter upstream uses. Add the end-to-end test (tuic-tests/tests/masquerade.rs): - A trivial HTTP/1.1 upstream + the quinn tuic-server with masquerade enabled. - reqwest's experimental HTTP/3 client (`http3_prior_knowledge`) sends a real HTTP/3 GET and asserts it gets back the upstream's 200 body over HTTP/3 — proving a non-TUIC prober is reverse-proxied, not reset. - Gated behind the opt-in `h3-masquerade-test` feature + the `--cfg reqwest_unstable` rustc flag (passed via RUSTFLAGS); without them the test is cfg'd out, so default builds pull no http3 stack and are unaffected. Run: RUSTFLAGS="--cfg reqwest_unstable" cargo test -p tuic-tests --features h3-masquerade-test Verified: the e2e test passes; default builds + clippy stay clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The masquerade code was written without running the repo's formatter; apply `cargo +nightly fmt` so it matches CI's stable + RUSTC_BOOTSTRAP=1 fmt check (which honors the unstable rustfmt.toml options). No functional change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The h3::quic adapter previously boxed every stream as `Box<dyn QuicRecvStream>` / `Box<dyn QuicSendStream>` purely to unify the first (prefix-replayed) control stream with regular streams — costing a heap allocation + dynamic dispatch per stream and relying on the traits' object-safety. Make `H3Recv<C>` / `H3Send<C>` / `H3Bidi<C>` generic over the connection, using the `C::RecvStream` / `C::SendStream` associated types. Every recv stream is now a `PrefixedRecv<C::RecvStream>` (empty prefix when nothing was peeked), so they share one concrete type without boxing. `server_connection` / `run_masquerade` take `PrefixedRecv<C::RecvStream>` directly instead of `Box<dyn ...>`. No behavior change: the masquerade e2e test still passes, and both the quinn and quiche backends compile (the adapter stays backend-agnostic). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ni stream The TUIC `Auth` command is not guaranteed to be the first thing the server observes — a `Connect` bidi stream, a heartbeat datagram, or plain QUIC reordering can arrive first. The classifier previously only `accept_uni()`'d and assumed the first uni stream was the discriminator, so a connection whose first event was a bidi/datagram could stall or misclassify (and for h3, the request bidi could be consumed and lost). Mirrors Itsusinn/tuic's classifier. - Race `accept_uni()` + `accept_bi()` + `read_datagram()` for the first event. - Classify on the first **two** bytes: `[VER, CmdType]` with `CmdType <= Heartbeat`, so an h3 stream that happens to start with `VER` (e.g. a stray PUSH_PROMISE frame type) isn't misread as TUIC. - Prefetch the consumed first event into the right handler so nothing is lost: TUIC uni/bi/datagram handlers, or the h3 adapter (which now also accepts a prefetched first **bidi** request stream, not just a uni control stream). - On classification timeout, fall back to the masquerade (a silent prober still sees a web server) instead of closing. Verified: masquerade e2e test, quinn + quiche TUIC relay tests, clippy, fmt. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Following Itsusinn/tuic (whose peekable recv stream *is* its h3 RecvStream), implement `h3::quic::RecvStream` for `PrefixedRecv` itself instead of wrapping it in a separate `H3Recv` type. `PrefixedRecv` already owns the replayed prefix and the backend stream and exposes `id()`/`stop()`, so the wrapper added nothing but indirection. Fresh accepted streams use an empty-prefix `PrefixedRecv`. This deletes the `H3Recv` struct and its `new`/`passthrough` constructors; the adapter's `RecvStream` associated types are now `PrefixedRecv<C::RecvStream>` directly. The send side keeps `H3Send` (the orphan rule forbids implementing the foreign `h3::quic::SendStream` on the backend's `C::SendStream`). No behavior change: masquerade e2e test, clippy, fmt, and the quiche backend all pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…nt" logic Classification was connection-level + prefetch: race the first uni/bi/datagram, peek 2 bytes, decide TUIC vs h3 once, and hand that first stream forward as a one-off (`FirstEvent`/`TuicFirst`) or as the h3 adapter's prefetched `first_recv`/`first_bidi`. That left two code paths — the special "first" stream and the acceptor-loop "rest." Replace it with uniform per-stream classification: every accepted stream reads its own 2-byte prefix and routes itself — TUIC streams to the existing `handle_uni_stream`/`handle_bi_stream`/`handle_datagram`, non-TUIC streams to the h3 masquerade. One path, no "first" special case. - wind-quic h3 adapter is now **channel-fed**: `H3Conn` pulls accepted streams from two `mpsc` receivers (`server_connection(conn, recv_rx, bidi_rx)`); `poll_accept_recv`/`poll_accept_bidi` just `poll_recv`. Dropped `first_recv`, `first_bidi`, the boxed accept-futures, and `boxed_accept_uni/bi`. `conn` is kept only for opening the server's own control/QPACK streams. - The per-stream router in `serve_connection` reads each stream's prefix, wraps it in `PrefixedRecv`, and either runs the TUIC handler or sends it to the h3 channels. Removed `FirstEvent`, `TuicFirst`, the classification race, and `dispatch_masquerade`. - Lazy h3 start: `run_masquerade` is spawned parked and waits on a `Notify` the router fires on the first non-TUIC stream before building the h3 server (which opens the control stream) — so a pure-TUIC connection never opens h3 streams. The auth-timeout guard skips closing once a stream has classified as h3. Trade-off (documented): a connection no longer commits to a mode up front — each stream routes independently, so one connection could carry both TUIC and h3 streams. Real clients never do; a mixer isn't exploitable (fixed-upstream proxy). Verified: masquerade e2e (reqwest HTTP/3), quinn + quiche TUIC relay tests, masquerade-off build, clippy, nightly fmt. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`.cargo/config.toml` was gitignored, so its `--cfg reqwest_unstable` flag (and
`RUSTC_BOOTSTRAP=1`, needed for the repo's nightly rustfmt.toml options) only
existed locally. Un-ignore and commit it so the flag is the workspace default:
the `h3-masquerade-test` now runs with just
cargo test -p tuic-tests --features h3-masquerade-test
(no manual RUSTFLAGS). The cfg stays inert for normal builds — reqwest's http3
code needs both the cfg and the `http3` feature, and only the opt-in test feature
enables that. Updated the test docs accordingly.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The reusable build workflow exports its `rustflags` input as the `RUSTFLAGS` env var, which overrides `.cargo/config.toml [build] rustflags` — so the committed `--cfg reqwest_unstable` was being dropped in CI. Mirror it in the workflow input so the cfg stays enabled in CI (inert unless a crate also enables reqwest's `http3` feature). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three review findings: - The parked `run_masquerade` task was spawned with its `Result` dropped. On failure (invalid upstream URL, h3 setup error) the error vanished AND the connection leaked: a non-TUIC stream had already flipped `h3_active`, so the auth-timeout guard would never reap it. Now log the error and close the connection, mirroring that guard's cleanup. - The response body size cap was checked only *after* the response head was sent, so an over-cap body produced a truncated-but-"200" reply (the 502 fallback can't fire once headers are out). Now reject over-cap responses by `Content-Length` before committing the head (clean 502), and for streamed / under-reported bodies that overflow mid-relay, reset the h3 stream (`stop_stream`) instead of finishing it — the prober sees an aborted response, not a silent truncation. Any post-head failure now resets rather than attempting a second response. - The e2e test ignored `tuic_server::run`'s result and slept 1s on a fixed port, so a startup failure (e.g. port in use) surfaced only as an opaque 10s client timeout. Now wait for readiness via `select!` on the server JoinHandle and surface the real startup error immediately. Co-Authored-By: Claude Opus 4.8 <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.
What & why
TUIC advertises the
h3ALPN to disguise its traffic, so real TUIC clients and active HTTP/3 probers negotiate the identical ALPN. Today a prober speaking real HTTP/3 (e.g.curl --http3 https://server/) gets its QUIC handshake accepted but then every stream is reset — an "accept then reset" fingerprint that distinguishes the server from a real web server.This PR adds an HTTP/3 masquerade: when a connecting client isn't TUIC, the server poses as a genuine HTTP/3 web server by reverse-proxying the request to a configured upstream site and relaying the response. Real TUIC clients are unaffected.
Approach — one engine, both backends
h3::quicadapter overwind_quic::QuicConnection(crates/wind-quic/src/h3_adapter.rs, featureh3), so the hyperiumh3crate drives the masquerade over either the quinn or quiche backend.serve_connection: peek the first uni stream's first byte —0x05(TUICVER) → TUIC, anything else → masquerade. The peeked byte is replayed viaPrefixedRecvso both the TUIC parser and the h3 adapter read from byte 0.quiche::h3: quiche has no stream peek/unread, so a consumed detection byte can't be handed back toquiche::h3(it reads the client control stream itself from byte 0). The adapter can replay it.crates/wind-tuic/src/server/masquerade.rs) runs anh3::serverover the adapter and reverse-proxies each request to the upstream via a pooled hyper + rustls (platform-verifier) client; on upstream failure it returns502rather than resetting, so the masquerade holds.Config
Threaded into both the quinn and quiche inbounds. Gated by the wind-tuic
masqueradecargo feature (on by default); disabled at runtime by default.Files
wind-quic:traits.rs/quinn/mod.rs/quiche/stream.rsadd streamid()(the one abstraction gaph3::quicneeded); newprefixed.rs(PrefixedRecv) andh3_adapter.rs.wind-tuic: newserver/masquerade.rs; detection/dispatch + generalizedhandle_uni_streaminserver/mod.rs; masquerade config threaded throughquinn/inbound.rsandquiche/inbound.rs.tuic-server:[masquerade]config inconfig.rs, wired inwind_adapter.rs.Verification
--features quiche, masquerade-off, and full--workspace.clippy --all-targetsclean on the changed crates.tuic-testspass — quinn relay (7), quiche TCP+UDP relay, 0-RTT, cert-reload — confirming the0x05discriminator routes real TUIC unchanged on both backends.Reviewer notes / follow-up
h3client (over a quinn client connection, reusing the adapter) against the server with masquerade enabled and a trivial local HTTP upstream. Manual check:curl --http3should return the upstream's HTML, not a reset.h3 = 0.0.8.🤖 Generated with Claude Code