This commit is contained in:
gru committed 2026-10-03 00:27:09 +01:00
commit 5e23e9af02
240 files changed
+99957

No files matched your search

+574
View File
@@ -0,0 +1,574 @@
//! RFC 9114 transport adapter: Quinn (QUIC/TLS 1.3) + h3 (HTTP/3/QPACK).
//! Every request uses the SAME router, ACLs, auth, cache and response policies.
//! No full-body buffering or 0-RTT replay. RFC 9220 WebSocket Extended CONNECT is supported; WebTransport is not.
use crate::{
metrics::Metrics,
runtime::App,
server::{LocalAddr, PeerAddr, ServerShutdown, Transport},
vhost::Listener,
};
use anyhow::{ensure, Context as _, Result};
use axum::{body::Body, Router};
use bytes::{Buf, Bytes};
use h3::error::Code;
use http::{HeaderMap, HeaderValue, Method, Version};
use http_body::{Body as HttpBody, Frame, SizeHint};
use http_body_util::BodyExt;
use serde::{Deserialize, Serialize};
use std::{
io,
net::SocketAddr,
pin::Pin,
sync::{atomic::Ordering, Arc},
task::{Context, Poll},
time::Duration,
};
use tokio::{
sync::{OwnedSemaphorePermit, Semaphore},
task::JoinSet,
};
use tower::ServiceExt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Settings {
pub alt_svc_max_age: u64,
pub max_concurrent_connections: u32,
pub max_concurrent_bidistream: u32,
pub max_concurrent_unistream: u32,
/// QUIC idle timeout in seconds; zero disables the transport idle timeout.
pub max_idle_timeout: u64,
}
impl Default for Settings {
fn default() -> Self {
Self {
alt_svc_max_age: 3600,
max_concurrent_connections: 512,
max_concurrent_bidistream: 100,
max_concurrent_unistream: 100,
max_idle_timeout: 10,
}
}
}
impl Settings {
pub fn validate(&self) -> Result<()> {
ensure!(
self.alt_svc_max_age <= 604800,
"HTTP/3 Alt-Svc lifetime must be at most seven days"
);
ensure!(
(1..=4096).contains(&self.max_concurrent_connections),
"HTTP/3 connections per listener must be 1..4096"
);
ensure!(
(1..=1024).contains(&self.max_concurrent_bidistream),
"HTTP/3 bidirectional streams must be 1..1024"
);
ensure!((3..=1024).contains(&self.max_concurrent_unistream),"HTTP/3 requires at least three peer unidirectional streams (control and QPACK), maximum 1024");
ensure!(
self.max_idle_timeout <= 86400,
"HTTP/3 idle timeout must be 0..86400 seconds"
);
Ok(())
}
pub fn transport(&self) -> Result<quinn::TransportConfig> {
self.validate()?;
let mut transport = quinn::TransportConfig::default();
transport
.max_concurrent_bidi_streams(self.max_concurrent_bidistream.into())
.max_concurrent_uni_streams(self.max_concurrent_unistream.into())
.max_idle_timeout(if self.max_idle_timeout == 0 {
None
} else {
Some(Duration::from_secs(self.max_idle_timeout).try_into()?)
})
.stream_receive_window((256 * 1024u32).into())
.receive_window((8 * 1024 * 1024u32).into())
.send_window(8 * 1024 * 1024)
.datagram_receive_buffer_size(None);
Ok(transport)
}
}
pub fn crypto(
tls: &Arc<rustls::ServerConfig>,
policy: &crate::tls::ProtocolPolicy,
) -> Result<Arc<quinn::crypto::rustls::QuicServerConfig>> {
ensure!(
policy.allows_http3(),
"HTTP/3 requires TLS 1.3 and a TLS 1.3 cipher suite"
);
let mut provider = rustls::crypto::ring::default_provider();
if !policy.cipher_suites.is_empty() {
provider.cipher_suites.retain(|suite| {
let name = format!("{:?}", suite.suite());
policy
.cipher_suites
.iter()
.any(|selected| selected == &name)
});
}
let mut quic = rustls::ServerConfig::builder_with_provider(Arc::new(provider))
.with_protocol_versions(&[&rustls::version::TLS13])?
.with_no_client_auth()
.with_cert_resolver(tls.cert_resolver.clone());
quic.alpn_protocols = vec![b"h3".to_vec()];
quic.max_early_data_size = 0;
Ok(Arc::new(quinn::crypto::rustls::QuicServerConfig::try_from(
quic,
)?))
}
pub struct Bound {
endpoint: quinn::Endpoint,
server_config: quinn::ServerConfig,
address: SocketAddr,
settings: Settings,
}
pub fn bind(listener: Listener, state: &App) -> Result<Bound> {
let settings = listener
.http3
.context("HTTP/3 is disabled on this listener")?;
settings.validate()?;
ensure!(listener.tls, "HTTP/3 listener requires TLS");
let crypto = state
.config
.load()
.quic_tls
.clone()
.context("HTTP/3 certificate unavailable")?;
let mut server_config = quinn::ServerConfig::with_crypto(crypto);
server_config
.transport_config(Arc::new(settings.transport()?))
.migration(false)
.max_incoming(settings.max_concurrent_connections as usize)
.incoming_buffer_size(64 * 1024)
.incoming_buffer_size_total(1024 * 1024);
let endpoint = quinn::Endpoint::server(server_config.clone(), listener.address)
.with_context(|| format!("cannot bind HTTP/3 UDP {}", listener.address))?;
Ok(Bound {
endpoint,
server_config,
address: listener.address,
settings,
})
}
/// Derive the advertised endpoint from ACTUALLY bound sockets, never saved intent
/// or a backend Alt-Svc header. A pending H3 enable is not advertised before restart.
pub fn advertise(
headers: &mut HeaderMap,
local: Option<SocketAddr>,
tls: bool,
active: &[Listener],
) {
headers.remove("alt-svc");
if !tls {
return;
}
let Some(local) = local else {
return;
};
if let Some(settings) = active
.iter()
.find(|binding| binding.matches(local, true))
.and_then(|binding| binding.http3)
{
let value = format!("h3=\":{}\"; ma={}", local.port(), settings.alt_svc_max_age);
if let Ok(value) = HeaderValue::from_str(&value) {
headers.insert("alt-svc", value);
}
}
}
struct Lease {
_permit: OwnedSemaphorePermit,
metrics: Arc<Metrics>,
}
impl Drop for Lease {
fn drop(&mut self) {
self.metrics
.http3_active_connections
.fetch_sub(1, Ordering::Relaxed);
self.metrics
.active_connections
.fetch_sub(1, Ordering::Relaxed);
}
}
pub async fn serve(bound: Bound, router: Router, state: Arc<App>, grace: Duration) -> Result<()> {
let slots = Arc::new(Semaphore::new(
bound.settings.max_concurrent_connections as usize,
));
let mut tasks = JoinSet::new();
loop {
tokio::select! {
biased;
_=state.shutdown.cancelled()=>break,
_=tasks.join_next(),if !tasks.is_empty()=>{},
incoming=bound.endpoint.accept()=>{
let Some(incoming)=incoming else {break;};
if !incoming.remote_address_validated() {let _=incoming.retry();continue;}
let Ok(permit)=slots.clone().try_acquire_owned() else {
state.metrics.http3_rejected_connections.fetch_add(1,Ordering::Relaxed);
state.metrics.rejected_connections.fetch_add(1,Ordering::Relaxed);
incoming.refuse();continue;
};
let mut config=bound.server_config.clone();
let Some(crypto)=state.config.load().quic_tls.clone() else {incoming.refuse();continue;};
// Certificate changes apply to new handshakes without changing
// active transport limits or regenerating the Retry token key.
config.crypto=crypto;
let connecting=match incoming.accept_with(Arc::new(config)) {Ok(c)=>c,Err(_)=>continue};
let router=router.clone();let state=state.clone();let address=bound.address;
let limit=bound.settings.max_concurrent_bidistream as usize;
tasks.spawn(async move {
let connection=tokio::select! {
_=state.shutdown.cancelled()=>return,
result=tokio::time::timeout(Duration::from_secs(10),connecting)=>match result {
Ok(Ok(connection))=>connection,
_=>{state.metrics.tls_handshake_errors.fetch_add(1,Ordering::Relaxed);return;},
}
};
// A wildcard without destination metadata must fail closed.
let local=match connection.local_ip() {
Some(ip)=>SocketAddr::new(ip,address.port()),
None if !address.ip().is_unspecified()=>address,
None=>{connection.close(0x102u32.into(),b"local destination unavailable");return;},
};
state.metrics.connections.fetch_add(1,Ordering::Relaxed);
state.metrics.active_connections.fetch_add(1,Ordering::Relaxed);
state.metrics.http3_connections.fetch_add(1,Ordering::Relaxed);
state.metrics.http3_active_connections.fetch_add(1,Ordering::Relaxed);
state.metrics.tls_connections.fetch_add(1,Ordering::Relaxed);
let _lease=Lease {_permit:permit,metrics:state.metrics.clone()};
if let Err(error)=connection_loop(connection,local,router,state,limit,grace).await {
tracing::debug!(%error,"HTTP/3 connection ended");
}
});
}
}
}
// Connection tasks send GOAWAY and drain; only close the endpoint after the
// grace period, not immediately after a response FIN (which can lose data).
let _ = tokio::time::timeout(grace, async { while tasks.join_next().await.is_some() {} }).await;
tasks.abort_all();
while tasks.join_next().await.is_some() {}
bound.endpoint.close(0x100u32.into(), b"shutdown");
let _ = tokio::time::timeout(Duration::from_secs(1), bound.endpoint.wait_idle()).await;
Ok(())
}
async fn connection_loop(
connection: quinn::Connection,
local: SocketAddr,
router: Router,
state: Arc<App>,
limit: usize,
grace: Duration,
) -> Result<()> {
let mut builder = h3::server::builder();
builder.max_field_section_size(32 * 1024);
// RFC 9220 remains an explicit experimental opt-in. Avoid advertising
// Extended CONNECT on listeners that only use classic HTTP/1.1 WebSockets.
builder.enable_extended_connect(state.config.load().websocket_rfc9220_on(local));
let mut h3 = builder
.build(h3::quinn::Connection::new(connection.clone()))
.await?;
let mut requests = JoinSet::new();
let mut draining = false;
let deadline = tokio::time::sleep(Duration::from_secs(86400));
tokio::pin!(deadline);
loop {
tokio::select! {
biased;
_=state.shutdown.cancelled(),if !draining=>{
draining=true;deadline.as_mut().reset(tokio::time::Instant::now()+grace);
h3.shutdown(0).await?;
},
_=&mut deadline,if draining=>break,
_=requests.join_next(),if !requests.is_empty()=>{},
next=h3.accept()=>match next {
Ok(Some(resolver))=>{
// Completed JoinSet entries are drained before accepting more.
if requests.len()>=limit {drop(resolver);continue;}
let router=router.clone();let state=state.clone();let peer=connection.remote_address();
requests.spawn(async move {
let Ok(Ok((mut request,stream)))=tokio::time::timeout(state.header_timeout,resolver.resolve_request()).await else {return;};
*request.version_mut()=Version::HTTP_3;
let protocol=request.extensions().get::<h3::ext::Protocol>().map(|protocol|protocol.as_str().to_owned());
let extended_connect=request.method()==Method::CONNECT && protocol.is_some();
let websocket=extended_connect && protocol.as_deref()==Some("websocket");
let (mut send,receive)=stream.split();
if invalid_headers(request.headers()) {
send.stop_stream(Code::H3_MESSAGE_ERROR);return;
}
let expected=request.headers().get("content-length").and_then(|v|v.to_str().ok()).and_then(|v|v.parse::<u64>().ok());
let mut tunnel_receive=None;
let mut request=if websocket {
// RFC 9220 DATA belongs to the WebSocket byte stream, not
// to an HTTP request body. Keep the receive half for the
// transport bridge after the router accepts the tunnel.
tunnel_receive=Some(receive);
request.map(|_|Body::empty())
} else if extended_connect {
// Unsupported Extended CONNECT protocols are answered by
// the router (501). They are not interpreted as HTTP bodies.
drop(receive);
request.map(|_|Body::empty())
} else {
request.map(|_|Body::new(ReceiveBody {stream:receive,expected,received:0,data_done:false,done:false}))
};
request.extensions_mut().insert(PeerAddr(peer));request.extensions_mut().insert(LocalAddr(local));
request.extensions_mut().insert(Transport {tls:true});
request.extensions_mut().insert(ServerShutdown(state.shutdown.clone()));
let response=match router.oneshot(request).await {Ok(response)=>response,Err(never)=>match never {}};
let (mut parts,mut body)=response.into_parts();
let tunnel=parts.extensions.remove::<crate::proxy::websocket::H3Tunnel>();
crate::proxy::headers::strip_hop_by_hop(&mut parts.headers);
parts.version=Version::HTTP_3;
let response=http::Response::from_parts(parts,());
let sent=async {
tokio::time::timeout(state.body_idle_timeout,send.send_response(response)).await??;
if let Some(tunnel)=tunnel {
let Some(mut receive)=tunnel_receive.take() else {
send.stop_stream(Code::H3_INTERNAL_ERROR);
return Err(anyhow::anyhow!("HTTP/3 tunnel response without a WebSocket Extended CONNECT request"));
};
drop(body);
if let Err(error)=crate::proxy::websocket::run_h3(state.clone(),&mut send,&mut receive,tunnel).await {
// run_h3 owns RFC 9220 tunnel shutdown/reset semantics.
// Do not overwrite its H3_REQUEST_CANCELLED with a
// second generic H3_INTERNAL_ERROR here.
tracing::debug!(%error,"HTTP/3 WebSocket tunnel closed");
}
return Ok::<(),anyhow::Error>(());
}
if let Some(mut receive)=tunnel_receive.take() {
// The WebSocket bootstrap was rejected before becoming
// a tunnel. Stop accepting request DATA after sending the
// ordinary HTTP error response.
receive.stop_sending(Code::H3_REQUEST_CANCELLED);
}
while let Some(frame)=body.frame().await {
let frame=frame?;
match frame.into_data() {
Ok(data)=>{if !data.is_empty() {tokio::time::timeout(state.body_idle_timeout,send.send_data(data)).await??;}},
Err(frame)=>{if let Ok(mut trailers)=frame.into_trailers() {
crate::proxy::headers::strip_hop_by_hop(&mut trailers);
tokio::time::timeout(state.body_idle_timeout,send.send_trailers(trailers)).await??;
}},
}
}
tokio::time::timeout(state.body_idle_timeout,send.finish()).await??;
Ok::<(),anyhow::Error>(())
}.await;
if let Err(error)=sent {tracing::debug!(%error,"HTTP/3 response interrupted");send.stop_stream(Code::H3_INTERNAL_ERROR);}
});
},
Ok(None)=>break,
Err(error)=>{requests.abort_all();return Err(error.into());},
}
}
}
let _ = tokio::time::timeout(grace, async {
while requests.join_next().await.is_some() {}
})
.await;
requests.abort_all();
while requests.join_next().await.is_some() {}
// Retain the transport until the client closes or the bounded drain expires.
if draining {
let _ = tokio::time::timeout(grace, connection.closed()).await;
}
Ok(())
}
fn invalid_headers(headers: &HeaderMap) -> bool {
[
"connection",
"keep-alive",
"proxy-connection",
"transfer-encoding",
"upgrade",
]
.iter()
.any(|name| headers.contains_key(*name))
|| headers
.get_all("te")
.iter()
.any(|value| value.as_bytes() != b"trailers")
|| headers.get_all("content-length").iter().count() > 1
|| headers.get("content-length").is_some_and(|value| {
value
.to_str()
.ok()
.and_then(|s| s.parse::<u64>().ok())
.is_none()
})
}
struct ReceiveBody {
stream: h3::server::RequestStream<h3::quinn::RecvStream, Bytes>,
expected: Option<u64>,
received: u64,
data_done: bool,
done: bool,
}
impl HttpBody for ReceiveBody {
type Data = Bytes;
type Error = io::Error;
fn poll_frame(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<Option<std::result::Result<Frame<Bytes>, io::Error>>> {
let this = self.get_mut();
if this.done {
return Poll::Ready(None);
}
if !this.data_done {
match this.stream.poll_recv_data(cx) {
Poll::Pending => return Poll::Pending,
Poll::Ready(Err(error)) => {
this.done = true;
return Poll::Ready(Some(Err(io::Error::other(error.to_string()))));
}
Poll::Ready(Ok(Some(mut data))) => {
let length = data.remaining();
this.received = this.received.saturating_add(length as u64);
if this
.expected
.is_some_and(|expected| this.received > expected)
{
drop(data);
this.done = true;
this.stream.stop_sending(Code::H3_MESSAGE_ERROR);
return Poll::Ready(Some(Err(io::Error::other(
"HTTP/3 Content-Length exceeded",
))));
}
return Poll::Ready(Some(Ok(Frame::data(data.copy_to_bytes(length)))));
}
Poll::Ready(Ok(None)) => this.data_done = true,
}
}
match this.stream.poll_recv_trailers(cx) {
Poll::Pending => Poll::Pending,
Poll::Ready(Err(error)) => {
this.done = true;
Poll::Ready(Some(Err(io::Error::other(error.to_string()))))
}
Poll::Ready(Ok(trailers)) => {
this.done = true;
if this
.expected
.is_some_and(|expected| expected != this.received)
{
this.stream.stop_sending(Code::H3_MESSAGE_ERROR);
return Poll::Ready(Some(Err(io::Error::other(
"HTTP/3 Content-Length mismatch",
))));
}
Poll::Ready(trailers.map(|mut headers| {
crate::proxy::headers::strip_hop_by_hop(&mut headers);
Ok(Frame::trailers(headers))
}))
}
}
}
fn is_end_stream(&self) -> bool {
self.done
}
fn size_hint(&self) -> SizeHint {
// A declared zero is not proof that the peer ended its stream. Unknown
// lengths are verified by the cache's bounded empty-body probe.
let mut hint = SizeHint::new();
if self.done {
hint.set_exact(0);
} else if let Some(remaining) = self
.expected
.map(|n| n.saturating_sub(self.received))
.filter(|n| *n > 0)
{
hint.set_upper(remaining);
}
hint
}
}
impl Drop for ReceiveBody {
fn drop(&mut self) {
// Dropping the request stream cancels the receive side at QUIC level.
// Explicit STOP_SENDING is used only from poll_frame paths where the
// stream handle is available and a protocol error was detected.
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn requested_defaults_and_infinite_idle_are_valid() {
let s = Settings::default();
assert_eq!(
(
s.alt_svc_max_age,
s.max_concurrent_connections,
s.max_concurrent_bidistream,
s.max_concurrent_unistream,
s.max_idle_timeout
),
(3600, 512, 100, 100, 10)
);
assert!(s.transport().is_ok());
assert!(Settings {
max_idle_timeout: 0,
..s
}
.transport()
.is_ok());
}
#[test]
fn critical_streams_and_resource_limits_are_validated() {
assert!(Settings {
max_concurrent_unistream: 2,
..Default::default()
}
.validate()
.is_err());
assert!(Settings {
max_concurrent_connections: 0,
..Default::default()
}
.validate()
.is_err());
assert!(Settings {
max_concurrent_bidistream: 0,
..Default::default()
}
.validate()
.is_err());
}
#[test]
fn alt_svc_only_for_actual_tls_udp_listener() {
let binding = Listener {
address: "127.0.0.1:8443".parse().unwrap(),
tls: true,
http2: true,
http2_settings: Some(crate::settings::Http2Connections::default()),
h2c: false,
http3: Some(Settings::default()),
};
let mut h = HeaderMap::new();
h.insert("alt-svc", "h3=\":9999\"".parse().unwrap());
advertise(&mut h, Some(binding.address), true, &[binding]);
assert_eq!(h["alt-svc"], "h3=\":8443\"; ma=3600");
advertise(&mut h, Some(binding.address), false, &[binding]);
assert!(!h.contains_key("alt-svc"));
advertise(
&mut h,
Some("127.0.0.2:8443".parse().unwrap()),
true,
&[binding],
);
assert!(!h.contains_key("alt-svc"));
}
#[test]
fn h3_rejects_http1_connection_specific_headers() {
for name in ["connection", "transfer-encoding", "upgrade"] {
let mut h = HeaderMap::new();
h.insert(name, "anything".parse().unwrap());
assert!(invalid_headers(&h));
}
}
}