Skip to content

feat: extract tuic-core, add tokio-quiche backend, runtime backend switch#15

Merged
Itsusinn merged 12 commits into
mainfrom
feat/tuic-core-quiche-backend
Jun 6, 2026
Merged

feat: extract tuic-core, add tokio-quiche backend, runtime backend switch#15
Itsusinn merged 12 commits into
mainfrom
feat/tuic-core-quiche-backend

Conversation

@Itsusinn

@Itsusinn Itsusinn commented Jun 5, 2026

Copy link
Copy Markdown
Member

Overview

Splits the TUIC implementation into a shared, backend-agnostic core (tuic-core) plus two interchangeable QUIC backends — the existing quinn-based wind-tuic and a new tokio-quiche-based wind-tuiche — and adds runtime backend selection to tuic-server via a [backend] config section.

What changed

tuic-core (new crate)

Backend-agnostic TUIC primitives shared by both backends: wire codecs (header / command / address), the pure decode_* helpers, the UDP fragment-reassembly state machine (FragmentReassemblyBuffer), and a quinn-decoupled ProtoError.

wind-tuic

Removed the old placeholder quiche backend; proto now re-exports tuic_core::proto::* so existing paths keep working. Keeps the quinn-specific ClientProtoExt and the quinn-coupled UdpStream send path (reassembly delegates to tuic-core).

wind-tuiche (new crate) — tokio-quiche backend

  • Real TUIC server on tokio-quiche's ApplicationOverQuic worker (src/driver.rs): auth, TCP CONNECT relay (bridged to wind-core via a channel-backed duplex QuicheStream), native UDP relay over RFC 9221 datagrams using the shared fragment buffer, heartbeat / dissociate.
  • Auth is fully verified via the RFC 5705 keying-material exporter (recomputed from the live BoringSSL session, constant-time compared), reachable through quiche's impl AsMut<boring::ssl::SslRef> for Connection under boringssl-boring-crate.
  • Hot-reloadable TLS (src/tls.rs): a ConnectionHook installs a per-handshake select_certificate callback that serves the current cert from a hot-swappable CertStore, so certificate rotation needs no listener restart. The cert is set on the per-connection SslRef, keeping quiche's QUIC TLS method on the context intact.
  • Pinned to tokio-quiche 0.18 to keep serde_with/quiche aligned with the workspace. The client (outbound) path is still a config-only placeholder.

wind-acme

Added a CapturingCache (wrapping DirCache) that publishes the issued/cached cert PEM blob via a watch channel, exposed through a new start_acme_with_cert. start_acme stays a thin resolver-only wrapper for the quinn path.

tuic-server — runtime backend switch

  • New [backend] section: backend.mode = "quinn" | "quiche", with per-backend tuning under backend.quinn / backend.quiche.
  • create_inbound dispatches on backend.mode (ServerInbound::{Tuic, Tuiche}).
  • The quiche backend materialises TLS to files for auto_ssl (ACME) / self_sign / explicit paths, and an ACME renewal task hot-reloads the renewed cert into the running listener via CertStore::update.
  • Backward compatible: legacy top-level [quic] section and the older flat keys migrate into backend.quinn.

Build

Re-enabled the vendored datagram-socket patch (newly pulled in by tokio-quiche) to keep musl cross-compilation building.

Tests

  • New crates/tuic-tests/tests/protocol_tests.rs (14 tests): wire-format stability, decode-helper/codec agreement, malformed-input handling, TargetAddr ↔ Address conversions, UDP reassembly via tuic-core, and the [backend] config surface.
  • New backend-config unit tests in tuic-server.

Reviewer notes

  • backend.mode = "quiche" supports auto_ssl (ACME), self_sign, and explicit cert/key paths. ACME renewals hot-reload into the running listener with no restart (via the per-handshake cert callback); the on-disk <data_dir>/wind-tuiche.{cert,key}.pem files are also refreshed for restart persistence. backend.mode defaults to quinn.
  • The wind-tuiche driver + TLS hook build against the full BoringSSL stack and are unit-tested, but have not had an end-to-end run against a live TUIC client / live cert rotation in this change.
  • Verification: cargo +nightly fmt, cargo clippy --workspace (clean), and the touched crates' tests all pass.

🤖 Generated with Claude Code

Itsusinn and others added 12 commits June 6, 2026 07:43
…itch

Split the TUIC implementation into a shared, backend-agnostic core and two
interchangeable QUIC backends, then wire runtime backend selection into
tuic-server.

tuic-core (new):
- Backend-agnostic TUIC protocol primitives shared by both backends:
  wire codecs (header/command/address), the pure decode helpers used on the
  hot path, and the UDP fragment-reassembly state machine.
- ProtoError decoupled from quinn.

wind-tuic:
- Removed the placeholder quiche backend; re-exports tuic_core::proto so
  existing wind_tuic::proto::* paths keep working. Keeps the quinn-specific
  ClientProtoExt and the quinn-coupled UdpStream send path.

wind-tuiche (new): tokio-quiche backend
- Real TUIC server over tokio-quiche's ApplicationOverQuic worker
  (crates/wind-tuiche/src/driver.rs): auth, TCP CONNECT relay (bridged to
  wind-core via a channel-backed duplex stream), native UDP relay over
  RFC 9221 datagrams using the shared fragment buffer, heartbeat, dissociate.
- Pinned to tokio-quiche 0.18 to keep serde_with/quiche aligned with the
  workspace. Known limitation: quiche does not expose the RFC 5705 keying
  material exporter, so TUIC token verification is UUID-gated (documented).

tuic-server:
- New [backend] config section: backend.mode = "quinn" | "quiche" with
  per-backend tuning under backend.quinn / backend.quiche.
- create_inbound dispatches on backend.mode (ServerInbound::{Tuic, Tuiche}).
- Legacy top-level [quic] section and flat keys migrate into backend.quinn,
  so existing configs keep working.

tests:
- New crates/tuic-tests/tests/protocol_tests.rs (14 tests): wire-format
  stability, decode-helper/codec agreement, malformed-input handling,
  TargetAddr<->Address conversions, UDP reassembly via tuic-core, and the
  [backend] config surface.
- Backend-config unit tests in tuic-server.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
tokio-quiche (added with the wind-tuiche backend) newly pulls datagram-socket
into the dependency tree. Re-enable the vendored patch that works around the
libc::msghdr private-padding-field build failure on musl targets (libc >=
0.2.169), so cross-compiled musl builds keep working.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…al exporter

quiche built with `boringssl-boring-crate` (which tokio-quiche enables) exposes
the underlying SSL object via `impl AsMut<boring::ssl::SslRef> for Connection`,
and tokio-quiche's `QuicheConnection` is that `quiche::Connection`. So the RFC
5705 exporter IS reachable from inside `ApplicationOverQuic::process_reads`.

Replace the previous UUID-only auth gate with real token verification: recompute
the token from the live session (label = UUID bytes, context = password) and
compare in constant time, with the same dummy-password timing mitigation as the
quinn backend. The BoringSSL FFI is called directly (rather than the safe
`SslRef::export_keying_material`) because TUIC's label is the raw, non-UTF-8
UUID bytes and the safe wrapper requires a `&str`.

Adds boring / boring-sys / foreign-types-shared (the same builds tokio-quiche
already pulls). Updates the driver/README/config docs that claimed the exporter
was unavailable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The quiche (wind-tuiche / tokio-quiche) backend reads TLS material from files,
so previously it only accepted explicit `tls.certificate` / `tls.private_key`
paths. Materialise certs to disk so `auto_ssl` (ACME) and `self_sign` work too.

wind-acme:
- Wrap `DirCache` in a `CapturingCache` that also publishes the issued/cached
  cert PEM blob (PKCS#8 key + chain) to a `watch` channel.
- Add `start_acme_with_cert` returning `(resolver, watch::Receiver<Option<CertPem>>)`;
  `start_acme` stays a thin wrapper for the quinn path.

tuic-server (quiche backend):
- `resolve_quiche_cert_files` materialises the cert/key to `<data_dir>/wind-tuiche.{cert,key}.pem`:
  - ACME: wait for the first cert (cached or freshly issued), split the PEM into
    cert/key files, and refresh them on renewal (restart applies — tokio-quiche
    reads files once at listen time).
  - self-sign: generate via rcgen and write PEM files.
  - otherwise: use the configured file paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Root cause of the no-reload limitation: tokio-quiche loads the certificate into
the BoringSSL SSL_CTX once at listen() time (settings/config.rs) and reuses that
context for every connection, so renewed cert files were never re-read.

Fix: use tokio-quiche's only TLS seam, `ConnectionHook::create_custom_ssl_context_builder`.
The hook installs a per-handshake `select_certificate` callback that serves the
current cert from a hot-swappable `CertStore` (arc-swap of parsed
X509/chain/PKey). The cert is set on the per-connection `SslRef`
(set_certificate/set_private_key/add_chain_cert) rather than by swapping the
SSL_CTX, so quiche's QUIC TLS method on the context is preserved.

- wind-tuiche: new `tls` module (`CertStore`, `CertReloadHook`); the inbound
  seeds the store from the cert/key files at build time, wires the hook into
  `Hooks`, and exposes `TuicheInbound::cert_store()` for live rotation.
- tuic-server: the ACME renewal task now calls `cert_store.update(...)` to
  hot-reload the renewed certificate into the running quiche listener (and still
  refreshes the on-disk files for restart persistence) — no restart needed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Also wire 0-RTT into wind-tuiche (the inbound now maps ConnectionOpts.enable_0rtt
to QuicSettings.enable_early_data and sizes the flow-control windows from the
receive window).

New tests (in tuic-tests; one client per file because tuic_client::run installs
a process-global connection):
- quiche_integration.rs: end-to-end TCP + UDP relay through a quiche-backed
  tuic-server + quinn tuic-client (self-signed). Exercises the handshake, the
  RFC 5705 exporter auth, the ApplicationOverQuic worker, and the relay.
- quiche_zero_rtt.rs: the 0-RTT-enabled config path relays correctly.
- quiche_cert_reload.rs: a raw quinn client reads the served leaf cert before and
  after CertStore::update; asserts it changes — proving live cert rotation with
  no listener restart.

Shared helpers (config builders + start_quiche_pair + install_crypto_provider)
live in tuic-tests' lib. All three files pass; the quiche relay test confirms the
wind-tuiche driver works end-to-end.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The quiche integration test's UDP relay failed on CI Linux runners with
"socks5 server UDP associate dual-stack socket setting error: Protocol not
available (os error 92)": the test client used `local.dual_stack = Some(false)`,
which makes the SOCKS5 UDP-associate socket call `set_only_v6(true)` on its IPv4
socket — rejected with ENOPROTOOPT on runners without usable IPv6.

Use `dual_stack = None` in the test client config so the dual-stack setsockopt
is skipped entirely. TCP relay (and the rest of the quiche path) was already
passing on CI; this makes the UDP assertion pass there too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… in CI

Replace the ad-hoc `cfg(target_pointer_width="64")` gating with a proper
`quiche` cargo feature on tuic-server, controlled per-target via
`.github/target.toml`. tokio-quiche only compiles on 64-bit (its GSO path
transmutes `u128` -> `Instant`), so the quiche backend is opt-in and enabled
only for 64-bit targets.

- wind-tuiche: move tokio-quiche/boring/boring-sys/foreign-types-shared into a
  `[target.'cfg(target_pointer_width = "64")'.dependencies]` section and gate the
  backend modules (inbound/driver/stream/tls/outbound) on
  `cfg(all(feature = ..., target_pointer_width = "64"))`. On 32-bit the crate
  compiles to just the backend-agnostic `proto`/`udp` re-exports, so the
  `cargo --all` member build succeeds everywhere.
- tuic-server: `wind-tuiche` becomes an optional dep behind a new `quiche`
  feature; the quiche backend code in wind_adapter is gated on
  `cfg(feature = "quiche")` (with a clear runtime error if `backend.mode =
  "quiche"` is selected in a build without the feature).
- .github/target.toml: 64-bit targets get `extra-args =
  "--features tuic-server/quiche"`; 32-bit targets (i686, armv7) don't.
- tuic-tests: enable tuic-server/quiche + pull wind-tuiche/quinn/rcgen only on
  64-bit; the quiche test files are already 64-bit-gated.

Verified: i686 builds wind-tuiche empty (no tokio-quiche) and tuic-server
quinn-only; 64-bit builds with the feature and all quiche tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
tokio-quiche 0.19 requires serde_with ^3.20, which previously conflicted with
the quinn fork's `qlog` feature (qlog 0.17 -> serde_with ~3.17) and forced the
0.18 pin. That `qlog` feature was enabled on the quinn deps but never used in
code, so drop it from tuic-server and tuic-client. With qlog 0.17 gone the
graph resolves cleanly:

  tokio-quiche 0.18 -> 0.19.0
  quiche       0.28 -> 0.29.1
  qlog         0.17 -> 0.18.0   (now only via tokio-quiche)
  serde_with   3.17 -> 3.21.0

No source changes were needed — the tokio-quiche API (listen / ConnectionParams
/ ApplicationOverQuic / AsMut<SslRef> / ConnectionHook) is unchanged across the
bump, and the vendored datagram-socket patch (0.8.0) still matches. The quiche
e2e / 0-RTT / cert-hot-reload tests and the quinn e2e all pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
tokio-quiche only compiles on 64-bit because src/quic/io/gso.rs (the Linux
GSO/SO_TXTIME path) transmutes `u128` -> `std::time::Instant` to get the
monotonic-clock epoch, which is only valid where `Instant` is 16 bytes. Vendor
tokio-quiche 0.19.0 under patches/tokio-quiche and replace that transmute with a
portable `clock_gettime(CLOCK_MONOTONIC)`-correlated conversion, wired via
[patch.crates-io]. Only gso.rs differs from upstream.

- patches/tokio-quiche: vendored lib-only copy (examples/tests/docs removed),
  carries the gso.rs fix and a `disable_all_formatting` rustfmt.toml so the wind
  style never rewrites it. Excluded from the workspace (root `exclude`) so
  fmt/clippy/-D warnings don't apply to upstream code.
- wind-tuiche: tokio-quiche/boring/etc. deps and the backend modules are no
  longer target-gated to 64-bit (the patch makes 32-bit build).
- tuic-server: quiche backend stays behind the `quiche` cargo feature.
- .github/target.toml: enable `--features tuic-server/quiche` on the 32-bit
  targets too (i686-linux, armv7-linux, armv7-linux-hf, i686-windows).

The e2e tests still only *run* on 64-bit (cross-emulated 32-bit socket tests are
unreliable); this change is validated by the 32-bit build/compile in CI. 64-bit
build, clippy, fmt and all quiche tests pass locally.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The prepare job runs a typos spell-checker over the repo. The vendored
tokio-quiche copy under patches/ carries upstream typos (readyness, clsoe,
Inboud, ...); exclude patches/ so third-party code we only patch minimally isn't
spell-checked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Itsusinn Itsusinn merged commit 41d1401 into main Jun 6, 2026
13 checks passed
@Itsusinn Itsusinn deleted the feat/tuic-core-quiche-backend branch June 6, 2026 23:58
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