Skip to content

feat(wind-tuic): HTTP/3 masquerade for non-TUIC clients#24

Merged
Itsusinn merged 10 commits into
mainfrom
feat/h3-masquerade
Jun 13, 2026
Merged

feat(wind-tuic): HTTP/3 masquerade for non-TUIC clients#24
Itsusinn merged 10 commits into
mainfrom
feat/h3-masquerade

Conversation

@Itsusinn

Copy link
Copy Markdown
Member

What & why

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 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

  • Backend-agnostic h3::quic adapter over wind_quic::QuicConnection (crates/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) → TUIC, anything else → masquerade. The peeked byte is replayed via PrefixedRecv so both the TUIC parser and the h3 adapter read from byte 0.
  • The adapter owns stream acceptance, which is the decisive reason it isn't built on quiche::h3: quiche has no stream peek/unread, so a consumed detection byte can't be handed back to quiche::h3 (it reads the client control stream itself from byte 0). The adapter can replay it.
  • The masquerade runner (crates/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, so the masquerade holds.

Config

[masquerade]
enabled = true
upstream = "https://example.com"

Threaded into both the quinn and quiche inbounds. Gated by the wind-tuic masquerade cargo feature (on by default); disabled at runtime by default.

Files

  • wind-quic: traits.rs / quinn/mod.rs / quiche/stream.rs add stream id() (the one abstraction gap h3::quic needed); new prefixed.rs (PrefixedRecv) and h3_adapter.rs.
  • wind-tuic: new server/masquerade.rs; detection/dispatch + generalized handle_uni_stream in server/mod.rs; masquerade config threaded through quinn/inbound.rs and quiche/inbound.rs.
  • tuic-server: [masquerade] config in config.rs, wired in wind_adapter.rs.

Verification

  • ✅ Builds: quinn+masquerade, --features quiche, masquerade-off, and full --workspace.
  • clippy --all-targets clean on the changed crates.
  • ✅ Existing tuic-tests pass — quinn relay (7), quiche TCP+UDP relay, 0-RTT, cert-reload — confirming the 0x05 discriminator routes real TUIC unchanged on both backends.

Reviewer notes / follow-up

  • The masquerade's runtime path (h3 framing + reverse proxy) has no automated test yet — the existing harness only has TUIC-client pairs, no HTTP/3 client. Recommend a follow-up integration test driving the h3 client (over a quinn client connection, reusing the adapter) against the server with masquerade enabled and a trivial local HTTP upstream. Manual check: curl --http3 should return the upstream's HTML, not a reset.
  • The adapter targets h3 = 0.0.8.

🤖 Generated with Claude Code

Itsusinn and others added 10 commits June 13, 2026 04:24
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>
@Itsusinn Itsusinn merged commit 188f470 into main Jun 13, 2026
13 checks passed
@Itsusinn Itsusinn deleted the feat/h3-masquerade branch June 13, 2026 13:28
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