QUIC / HTTP/3

Crates hopf-quic and hopf-http (feature h3): a fully in-tree QUIC transport (RFC 9000/9001/9002) driven by a dedicated mio UDP thread, sharing the same TLS 1.3 handshake engine TCP TLS uses. HTTP/3 codecs reuse the same Stream SPI as H1/H2.

Architecture

UDP socket ── QuicDriverHandle (dedicated hopf-quic thread)
                 │
                 ├── plain mode: one ProtocolHandler per bi-stream
                 │                 (QuicListenConfig / QuicConnectConfig)
                 │
                 └── hooks mode: QuicConnection owns control/uni/bi
                                   (used by listen_h3 / connect_h3)
                                         │
                                         ▼
                              H3ServerConnection / H3ClientConnection
                                         │
                                         ▼
                              ServerHandler / ClientHandler  (hopf-http)

Each bidirectional QUIC stream is a QuicStreamEndpoint implementing hopf_core::Endpoint. HTTP/3 framing lives in hopf-http, not in hopf-quic.

TLS 1.3 on QUIC

Standards

Spec Notes
RFC 9000 family QUIC via an in-tree transport (hopf-quic)
TLS 1.3 only In-tree handshake engine (shared with TCP TLS); no TLS 1.2 mode on QUIC. Key exchange is classical X25519 only — not PQC-first, unlike TCP TLS 1.3
ALPN h3 hopf_quic::ALPN_H3 = b"h3"
RFC 9114 / 9204 HTTP/3 + QPACK in hopf-http
0-RTT / early data Off by default; opt in with QuicTlsOptions::with_early_data on *_with PEM builders. Plain client dials open the first bi stream immediately when has_0rtt() (reuse the same ClientConfig Arc so tickets stick). H3 hooks still wait for Event::Connected

QuicListen / QuicConnect configs

Plain bi-stream mode

Type Fields Purpose
QuicListenConfig addr, server: Arc<QuicServerConfig>, factory: HandlerFactory, hardening: QuicListenHardening UDP listen; one ProtocolHandler per accepted bi-stream. Hardening defaults to QuicListenHardening::high_security() (Retry before handshake)
QuicConnectConfig addr, client: Arc<QuicClientConfig>, server_name, factory Dial; factory for the first bi-stream
QuicListenHooksConfig addr, server, connection_factory: ConnectionFactory, hardening: QuicListenHardening Connection-level hooks (H3); same hardening default as plain listen
QuicListenHardening require_address_validation, Incoming caps, Retry/NEW_TOKEN lifetimes, migration RFC 9000 §8 listen hardening. high_security() (default) requires a Retry round trip before accept, caps unfinished handshakes, disables migration; permissive() accepts without Retry

Constructors:

use hopf_quic::{QuicConnectConfig, QuicListenConfig, QuicListenHardening, QuicListenHooksConfig};

QuicListenConfig::new(addr, server_cfg, factory);
QuicListenConfig::new(addr, server_cfg, factory)
    .with_hardening(QuicListenHardening::permissive()); // lab / trusted path
QuicConnectConfig::new(addr, client_cfg, "localhost", factory);
QuicListenHooksConfig::new(addr, server_cfg, connection_factory);

Port 0 binds an ephemeral UDP port. Addresses must be pre-resolved (SocketAddr) — Stage 0. Listeners apply apply_listen_hardening to the server config at bind time; with the default high-security policy, unvalidated Initials receive a Retry packet before any TLS handshake state is allocated.

Driver APIs

Function Returns
listen_quic(QuicListenConfig) io::Result<QuicDriverHandle>
connect_quic(QuicConnectConfig) io::Result<QuicDriverHandle>
listen_quic_hooks(QuicListenHooksConfig) io::Result<QuicDriverHandle>
connect_quic_hooks(addr, client, server_name, ConnectionFactory) io::Result<QuicDriverHandle>

QuicDriverHandle exposes local_addr and shutdown(self). Drop or shut down explicitly — the driver thread is independent of Runtime lifetime.

Hooks traits

Type Role
QuicConnection connected, accept_bi, accept_uni
QuicConnApi open_uni, open_bi, write, finish
ConnectionFactory Arc<dyn Fn() -> Box<dyn QuicConnection> + Send + Sync>

PEM builders

All builders pin TLS 1.3 only and take an ALPN list. Crypto is always the shared aws-lc-rs provider with hybrid-first PQC KX (X25519MLKEM768) — see PQC-first TLS 1.3.

Function Result
server_config_from_pem(cert, key, alpn) Arc<QuicServerConfig>
client_config_from_pem(ca, alpn) Arc<QuicClientConfig> trusting PEM roots
server_config_self_signed(&[names], alpn) (Arc<QuicServerConfig>, leaf_pem: String)
client_config_for_certified_pem / client_config_for_pem_bytes Trust a single certified leaf (smoke / demo)

QuicServerConfig/QuicClientConfig are hopf's own in-tree config types (hopf-quic/src/config.rs).

use hopf_quic::{server_config_from_pem, client_config_from_pem, ALPN_H3};
use std::path::Path;

let server = server_config_from_pem(
    Path::new("cert.pem"),
    Path::new("key.pem"),
    &[ALPN_H3],
)?;
let client = client_config_from_pem(Path::new("ca.pem"), &[ALPN_H3])?;

listen_h3 / connect_h3

Provided by hopf-http under feature h3. Internally install H3ServerConnection / H3ClientConnection via the hooks driver.

use hopf_http::{connect_h3, listen_h3, HttpLimits};
use hopf_quic::{server_config_self_signed, client_config_from_pem, ALPN_H3};
use std::sync::Arc;

// Server
let (server_cfg, leaf_pem) = server_config_self_signed(&["localhost"], &[ALPN_H3])?;
let listen = listen_h3(
    "127.0.0.1:4433".parse()?,
    server_cfg,
    server_factory,
    HttpLimits::default(),
)?;

// Client
let client_cfg = client_config_from_pem(std::path::Path::new("leaf.pem"), &[ALPN_H3])?;
let dial = connect_h3(
    "127.0.0.1:4433".parse()?,
    client_cfg,
    "localhost",
    client_factory,
    HttpLimits::default(),
)?;

On connect, the H3 client opens control + QPACK uni streams, then one bi-stream for the application request (ClientHandler::start). The control-stream SETTINGS frame advertises SETTINGS_QPACK_MAX_TABLE_CAPACITY (4096), SETTINGS_QPACK_BLOCKED_STREAMS (0), and Extended CONNECT; the encoder stays at capacity 0 until the peer's SETTINGS arrive, then grows to at most that advertised ceiling.

Each H3 request maps to a QUIC-stream Endpoint driving the same ServerHandler / ClientHandler API as H1/H2.

QUIC connection and stream teardown mirrors Gumdrop: a peer application or transport CONNECTION_CLOSE, idle timeout, or inbound STOP_SENDING reaches the stream's ProtocolHandler::error with QuicConnectionCloseError / QuicStreamStoppedError (downcast helpers connection_close_error / stream_stopped_error). Clean local shutdown and graceful stream FIN still call disconnected.

ALPN and TLS 1.3

Constant / rule Value
ALPN_H3 b"h3"
Protocol versions TLS 1.3 only — no TLS 1.2 mode exists on QUIC
Key exchange Classical X25519 only today — not PQC-first, unlike TCP TLS 1.3 (see Conformance → QUIC)
Early data Off by default; opt in with QuicTlsOptions::with_early_data

Do not pass TCP ALPN lists (h2, http/1.1) into QUIC H3 configs.

Runtime extension

RuntimeQuicExt adds convenience methods on Runtime:

QUIC I/O still runs on the dedicated hopf-quic thread; these helpers do not move UDP onto the TCP worker reactors.

Examples

Example Command
http3-hello cargo run -p http3-hello -- 127.0.0.1:4433
http-get --http3 cargo run -p http-get -- --http3 --ca <pem> 127.0.0.1:4433 /

Smokes: cargo test -p hopf-quic --features integration, cargo test -p hopf-http --features h3 h3_get_hello.

QUIC versions

hopf-quic speaks QUIC version 1 (RFC 9000) and version 2 (RFC 9369). Version 2 changes only the version number, the Initial salt, the HKDF labels, the long-header type bits and the Retry key, so HTTP/3 (h3) and DNS over QUIC (doq) run over it unchanged.

use hopf_quic::QuicVersion::{V1, V2};

// A listener accepts both by default; restrict it if you need to.
server_cfg.versions(&[V1, V2]);      // QuicServerConfig (or Arc::make_mut on the Arc the builders return)

// A client speaks version 1 unless told otherwise. The first entry opens the
// connection; if the server answers with Version Negotiation the client
// restarts in the first listed version the server offers.
client_cfg.versions(&[V2, V1]);

QUIC-LB connection IDs

A load balancer that forwards UDP by Destination Connection ID needs to find the owning backend from the CID alone, so a datagram keeps reaching the same server when the client's address changes. hopf-quic can issue such IDs following draft-ietf-quic-load-balancers-21 (revision 21; the encoder lives in one module, transport/quic_lb.rs, so a later revision touches only that file).

use hopf_quic::{apply_server_quic_lb, QuicLbConfig};

// Same config ID, key and nonce length on every backend and on the balancer;
// only the server ID differs per backend.
let lb = QuicLbConfig::new(/* config id 0-6 */ 1, &[0xa1, 0x01], /* nonce */ 6)?
    .with_key(shared_16_byte_key)?;

let (mut server_cfg, _pem) = server_config_self_signed(&["example.test"], &[b"h3"])?;
apply_server_quic_lb(&mut server_cfg, &lb);      // or server_cfg.quic_lb(&lb) on a QuicServerConfig
listen_h3(addr, server_cfg, factory, limits)?;   // HTTP/3 takes the same Arc<QuicServerConfig>

Limitations

Implementation status

Shipped: QUIC transport (RFC 9000 loss recovery and congestion control, RFC 9001 packet protection) and the TLS 1.3 handshake are both in-tree, with no external QUIC or TLS library dependency — the handshake is driven by the same engine TCP TLS 1.3 uses. HTTP/3 codecs and QPACK in hopf-http (feature h3) are Hopf-owned. Hopf adds the mio UDP driver, stream-as-Endpoint model, and hooks-mode connection API for H3.

Known gaps: connection migration and NEW_TOKEN address validation aren't implemented. Hybrid PQC key exchange is opt-in rather than default — see Conformance → QUIC for the full, row-by-row detail.

MASQUE: RFC 9298 CONNECT-UDP (full server relay + client) and RFC 9484 CONNECT-IP (accept/capsule plumbing; the application supplies IP forwarding) live in hopf-masque, over HTTP/1.1, HTTP/2, and HTTP/3 alike — not H3-only. See masque.html and Conformance → MASQUE.

See also