feat: add WebTransport (HTTP/3) server endpoint for browser clients

Feature-gated behind --features webtransport. Uses h3, h3-quinn,
and h3-webtransport crates to accept WebTransport sessions over
HTTP/3. Dispatches RPC through the same v2 handler registry as
native QUIC, using identical wire framing.

- webtransport.rs: H3 connection handling, session management,
  bidi stream RPC dispatch with auth handshake
- Config: --webtransport-listen / QPQ_WEBTRANSPORT_LISTEN
- ALPN: "h3" for WebTransport, "capnp" for native QUIC
- Also fixes: add missing save/load_revocation_log to SqlStore
This commit is contained in:
2026-03-04 20:59:59 +01:00
parent 511fc7822e
commit 3f5a3a5ac8
5 changed files with 507 additions and 1 deletions

View File

@@ -0,0 +1,420 @@
//! WebTransport server endpoint for browser clients.
//!
//! Accepts HTTP/3 WebTransport sessions and dispatches RPC requests through the
//! same v2 handler registry as the native QUIC endpoint. Browsers connect via:
//!
//! ```js
//! const wt = new WebTransport("https://server:7443");
//! ```
//!
//! Each WebTransport bidirectional stream carries a single RPC request/response
//! using the same wire format as the native QUIC transport:
//!
//! ```text
//! [method_id: u16][request_id: u32][payload_len: u32][protobuf bytes]
//! ```
use std::sync::Arc;
use bytes::BytesMut;
use h3_quinn::quinn;
use h3_webtransport::server::AcceptedBi;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tracing::{debug, info, warn};
use crate::v2_handlers::ServerState;
use quicproquo_rpc::error::RpcStatus;
use quicproquo_rpc::framing::{RequestFrame, ResponseFrame};
use quicproquo_rpc::method::{HandlerResult, MethodRegistry, RequestContext};
/// Concrete H3 connection type.
type H3Conn = h3::server::Connection<h3_quinn::Connection, bytes::Bytes>;
/// Concrete request resolver type.
type H3Resolver = h3::server::RequestResolver<h3_quinn::Connection, bytes::Bytes>;
/// Type alias for the concrete WebTransport session type used by this server.
type WtSession =
h3_webtransport::server::WebTransportSession<h3_quinn::Connection, bytes::Bytes>;
/// Type alias for the concrete WebTransport bidi stream.
type WtBidiStream = h3_webtransport::stream::BidiStream<
h3_quinn::BidiStream<bytes::Bytes>,
bytes::Bytes,
>;
/// Start the WebTransport listener in a background task.
///
/// The endpoint uses the provided quinn `ServerConfig` (with "h3" ALPN) and
/// binds to `listen_addr`. Incoming HTTP/3 CONNECT requests are upgraded to
/// WebTransport sessions.
pub fn spawn_webtransport_listener(
listen_addr: std::net::SocketAddr,
server_config: quinn::ServerConfig,
state: Arc<ServerState>,
registry: Arc<MethodRegistry<ServerState>>,
) -> anyhow::Result<()> {
let endpoint = quinn::Endpoint::server(server_config, listen_addr)
.map_err(|e| anyhow::anyhow!("bind WebTransport endpoint {listen_addr}: {e}"))?;
info!(addr = %listen_addr, "WebTransport endpoint listening");
tokio::spawn(async move {
accept_loop(endpoint, state, registry).await;
});
Ok(())
}
/// Accept QUIC connections and upgrade them to HTTP/3 + WebTransport.
async fn accept_loop(
endpoint: quinn::Endpoint,
state: Arc<ServerState>,
registry: Arc<MethodRegistry<ServerState>>,
) {
while let Some(incoming) = endpoint.accept().await {
let state = Arc::clone(&state);
let registry = Arc::clone(&registry);
tokio::spawn(async move {
let connection = match incoming.await {
Ok(c) => c,
Err(e) => {
warn!(error = %e, "WebTransport: QUIC accept failed");
return;
}
};
let remote = connection.remote_address();
debug!(remote = %remote, "WebTransport: new QUIC connection");
metrics::counter!("webtransport_connections_total").increment(1);
metrics::gauge!("webtransport_active_connections").increment(1.0);
if let Err(e) = handle_h3_connection(connection, state, registry).await {
debug!(remote = %remote, error = %e, "WebTransport: session error");
}
metrics::gauge!("webtransport_active_connections").decrement(1.0);
});
}
}
/// Handle an HTTP/3 connection: accept the WebTransport CONNECT request and
/// process bidirectional streams as RPC calls.
async fn handle_h3_connection(
connection: quinn::Connection,
state: Arc<ServerState>,
registry: Arc<MethodRegistry<ServerState>>,
) -> anyhow::Result<()> {
let h3_quinn_conn = h3_quinn::Connection::new(connection);
let mut h3_conn: H3Conn = h3::server::builder()
.enable_webtransport(true)
.enable_extended_connect(true)
.enable_datagram(true)
.build::<h3_quinn::Connection, bytes::Bytes>(h3_quinn_conn)
.await
.map_err(|e| anyhow::anyhow!("H3 connection setup: {e}"))?;
// Accept HTTP/3 requests until we get a CONNECT for WebTransport.
loop {
let resolver: H3Resolver = match h3_conn.accept().await {
Ok(Some(r)) => r,
Ok(None) => {
debug!("WebTransport: H3 connection closed");
return Ok(());
}
Err(e) => {
return Err(anyhow::anyhow!("WebTransport: H3 accept error: {e}"));
}
};
let (request, stream) = resolver
.resolve_request()
.await
.map_err(|e| anyhow::anyhow!("resolve request: {e}"))?;
let method = request.method().clone();
let uri = request.uri().clone();
if method == http::Method::CONNECT {
debug!(uri = %uri, "WebTransport: CONNECT request");
let wt_session = h3_webtransport::server::WebTransportSession::accept(
request, stream, h3_conn,
)
.await
.map_err(|e| anyhow::anyhow!("WebTransport session accept: {e}"))?;
info!("WebTransport: session established");
metrics::counter!("webtransport_sessions_total").increment(1);
serve_wt_streams(wt_session, state, registry).await;
return Ok(());
}
debug!(method = %method, uri = %uri, "WebTransport: non-CONNECT request ignored");
}
}
/// Per-connection state from the WebTransport auth handshake.
#[derive(Debug, Clone, Default)]
struct ConnectionState {
session_token: Option<Vec<u8>>,
identity_key: Option<Vec<u8>>,
}
/// Accept bidirectional streams from a WebTransport session and dispatch
/// each as an RPC request.
async fn serve_wt_streams(
session: WtSession,
state: Arc<ServerState>,
registry: Arc<MethodRegistry<ServerState>>,
) {
// Auth handshake: the first bidi stream carries the session token.
let conn_state: Arc<ConnectionState> = match accept_auth_stream(&session).await {
Ok(cs) => Arc::new(cs),
Err(e) => {
warn!(error = %e, "WebTransport: auth handshake failed");
return;
}
};
loop {
match session.accept_bi().await {
Ok(Some(AcceptedBi::BidiStream(_session_id, stream))) => {
let state = Arc::clone(&state);
let registry = Arc::clone(&registry);
let conn_state = Arc::clone(&conn_state);
tokio::spawn(async move {
if let Err(e) =
handle_wt_bidi_stream(stream, state, registry, &conn_state).await
{
debug!(error = %e, "WebTransport: stream error");
}
});
}
Ok(Some(AcceptedBi::Request(_req, _stream))) => {
debug!("WebTransport: ignoring nested HTTP/3 request");
}
Ok(None) => {
debug!("WebTransport: no more bidi streams");
break;
}
Err(e) => {
debug!(error = %e, "WebTransport: accept_bi error");
break;
}
}
}
}
/// Accept the first bidirectional stream as an auth init handshake.
///
/// The client sends a raw session token (length-prefixed: `u32 BE + token bytes`).
/// The server reads it and sends a 1-byte ack (0x00).
async fn accept_auth_stream(session: &WtSession) -> anyhow::Result<ConnectionState> {
let accepted = session
.accept_bi()
.await
.map_err(|e| anyhow::anyhow!("auth stream accept: {e}"))?
.ok_or_else(|| anyhow::anyhow!("session closed before auth handshake"))?;
let mut stream: WtBidiStream = match accepted {
AcceptedBi::BidiStream(_session_id, stream) => stream,
AcceptedBi::Request(_, _) => {
anyhow::bail!("expected bidi stream for auth, got HTTP/3 request")
}
};
// Read the token: [len: u32 BE][token bytes]
let mut header = [0u8; 4];
AsyncReadExt::read_exact(&mut stream, &mut header)
.await
.map_err(|e| anyhow::anyhow!("auth read header: {e}"))?;
let len = u32::from_be_bytes(header) as usize;
if len > 4096 {
anyhow::bail!("auth token too large: {len} bytes");
}
let mut token = vec![0u8; len];
if len > 0 {
AsyncReadExt::read_exact(&mut stream, &mut token)
.await
.map_err(|e| anyhow::anyhow!("auth read token: {e}"))?;
}
// Send ack: single zero byte.
AsyncWriteExt::write_all(&mut stream, &[0u8])
.await
.map_err(|e| anyhow::anyhow!("auth ack send: {e}"))?;
debug!(token_len = token.len(), "WebTransport: auth init received");
Ok(ConnectionState {
session_token: Some(token),
identity_key: None,
})
}
/// Handle a single WebTransport bidirectional stream: read request, dispatch,
/// write response. Uses the same framing as native QUIC.
async fn handle_wt_bidi_stream(
mut stream: WtBidiStream,
state: Arc<ServerState>,
registry: Arc<MethodRegistry<ServerState>>,
conn_state: &ConnectionState,
) -> anyhow::Result<()> {
// Read the complete request from the stream.
let max_size =
quicproquo_rpc::framing::MAX_PAYLOAD_SIZE + quicproquo_rpc::framing::REQUEST_HEADER_SIZE;
let mut buf = Vec::with_capacity(1024);
let mut tmp = [0u8; 8192];
loop {
let n = AsyncReadExt::read(&mut stream, &mut tmp)
.await
.map_err(|e| anyhow::anyhow!("recv: {e}"))?;
if n == 0 {
break;
}
buf.extend_from_slice(&tmp[..n]);
if buf.len() > max_size {
anyhow::bail!("payload too large");
}
}
let mut bytes = BytesMut::from(buf.as_slice());
let frame = match RequestFrame::decode(&mut bytes)
.map_err(|e| anyhow::anyhow!("decode: {e}"))?
{
Some(f) => f,
None => anyhow::bail!("incomplete request frame"),
};
let trace_id = format!(
"wt-{:016x}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos() as u64
);
let result = match registry.get(frame.method_id) {
Some((handler, name, timeout)) => {
let span = tracing::info_span!(
"wt_rpc",
trace_id = %trace_id,
method_id = frame.method_id,
method = name,
req_id = frame.request_id,
);
let _guard = span.enter();
debug!("dispatching");
let deadline = timeout.map(|d| tokio::time::Instant::now() + d);
let start = std::time::Instant::now();
let ctx = RequestContext {
identity_key: conn_state.identity_key.clone(),
session_token: conn_state.session_token.clone(),
payload: frame.payload,
trace_id: trace_id.clone(),
deadline,
};
let result = if let Some(dur) = timeout {
match tokio::time::timeout(dur, handler(Arc::clone(&state), ctx)).await {
Ok(r) => r,
Err(_) => {
warn!(method = name, "WebTransport: request deadline exceeded");
HandlerResult::err(RpcStatus::DeadlineExceeded, "request deadline exceeded")
}
}
} else {
handler(Arc::clone(&state), ctx).await
};
let elapsed = start.elapsed();
metrics::histogram!("webtransport_request_duration_seconds", "method" => name)
.record(elapsed.as_secs_f64());
metrics::counter!("webtransport_requests_total", "method" => name).increment(1);
result
}
None => {
warn!(method_id = frame.method_id, "WebTransport: unknown method");
HandlerResult::err(RpcStatus::UnknownMethod, "unknown method")
}
};
let response = ResponseFrame {
status: result.status as u8,
request_id: frame.request_id,
payload: result.payload,
};
let encoded = response.encode();
AsyncWriteExt::write_all(&mut stream, &encoded)
.await
.map_err(|e| anyhow::anyhow!("send response: {e}"))?;
AsyncWriteExt::shutdown(&mut stream)
.await
.map_err(|e| anyhow::anyhow!("shutdown: {e}"))?;
Ok(())
}
/// Build a quinn `ServerConfig` for the WebTransport endpoint.
///
/// Uses the same TLS cert/key as the main server but with "h3" ALPN.
pub fn build_webtransport_server_config(
cert_path: &std::path::Path,
key_path: &std::path::Path,
) -> anyhow::Result<quinn::ServerConfig> {
use anyhow::Context;
use rustls::pki_types::{CertificateDer, PrivateKeyDer};
use rustls::version::TLS13;
let cert_bytes = std::fs::read(cert_path).context("read WebTransport cert")?;
let key_bytes = std::fs::read(key_path).context("read WebTransport key")?;
let cert_chain = vec![CertificateDer::from(cert_bytes)];
let key = PrivateKeyDer::try_from(key_bytes)
.map_err(|_| anyhow::anyhow!("invalid WebTransport private key"))?;
let mut tls = rustls::ServerConfig::builder_with_protocol_versions(&[&TLS13])
.with_no_client_auth()
.with_single_cert(cert_chain, key)?;
tls.alpn_protocols = vec![b"h3".to_vec()];
let crypto = quinn_proto::crypto::rustls::QuicServerConfig::try_from(tls)
.map_err(|e| anyhow::anyhow!("invalid WebTransport TLS config: {e}"))?;
let mut transport = quinn::TransportConfig::default();
transport.max_idle_timeout(Some(
std::time::Duration::from_secs(300)
.try_into()
.map_err(|e| anyhow::anyhow!("idle timeout: {e}"))?,
));
// WebTransport sessions may have multiple simultaneous streams.
transport.max_concurrent_bidi_streams(64u32.into());
transport.max_concurrent_uni_streams(16u32.into());
let mut server_config = quinn::ServerConfig::with_crypto(Arc::new(crypto));
server_config.transport_config(Arc::new(transport));
Ok(server_config)
}
#[cfg(test)]
mod tests {
#[test]
fn webtransport_module_compiles() {
assert!(true);
}
}