boatramp_server/http_serve.rs
1//! The unified serving front door: every accepted connection — plaintext or TLS,
2//! HTTP/1.1 or HTTP/2 — is driven by [`boatramp_http::serve_connection`], boatramp's
3//! own hand-rolled h1+h2 stack. This is what replaced hyper/`axum_server` on the
4//! serving path (hyper stays only as the reverse-proxy *client*).
5//!
6//! The pieces:
7//! - [`RouterHandler`] bridges the dispatcher to the axum [`Router`] (a tower
8//! `Service`): it swaps body types and attaches the peer, nothing else. It is
9//! **protocol-agnostic** — it never strips hop-by-hop headers, because each codec
10//! owns its own framing rules (the h1 loop reframes and preserves a `101`
11//! upgrade's `Connection`/`Upgrade` verbatim; the h2 codec drops the
12//! connection-specific headers HTTP/2 forbids). One bridge feeds both.
13//! - [`serve_tls`] terminates TLS ourselves (rustls) and serves the negotiated
14//! protocol; it also transparently completes ACME `acme-tls/1` challenge
15//! handshakes. [`ReloadableTls`] lets the ACME renewal loop hot-swap the served
16//! certificate live, the way `axum_server`'s `RustlsConfig::reload` did.
17//! - [`serve_plaintext`] is the same accept→`serve_connection` loop without TLS
18//! (the `:80` HTTP→HTTPS redirect listener).
19
20use std::future::Future;
21use std::net::SocketAddr;
22use std::sync::atomic::{AtomicUsize, Ordering};
23use std::sync::Arc;
24
25use arc_swap::ArcSwap;
26use axum::Router;
27use boatramp_http::{
28 Body as HttpBody, BodyError, Handler, Request as HttpRequest, Response as HttpResponse,
29};
30use futures::StreamExt as _;
31use http_body_util::BodyStream;
32use rustls::ServerConfig;
33use tokio::net::{TcpListener, TcpStream};
34use tokio_rustls::TlsAcceptor;
35
36/// The ALPN identifier for the ACME TLS-ALPN-01 challenge (RFC 8737). A challenge
37/// connection negotiates only this; a completed challenge handshake carries no
38/// request, so [`serve_tls`] drops it rather than handing it to the h1/h2 driver.
39const ACME_TLS_ALPN: &[u8] = b"acme-tls/1";
40
41/// The ALPN protocols our HTTPS listeners advertise, in server-preference order:
42/// HTTP/2 first, then HTTP/1.1. (`serve_connection` also sniffs the h2 preface, so
43/// a client that negotiates neither still gets the right codec — ALPN is the fast
44/// path, the sniff is the backstop.)
45pub fn alpn_h1_h2() -> Vec<Vec<u8>> {
46 vec![b"h2".to_vec(), b"http/1.1".to_vec()]
47}
48
49/// A [`Router`] plus the optional hot-path handle, flowed through the serve loops as
50/// one unit. A bare `Router` converts in with **no** fast path (`From<Router>`), so
51/// auxiliary listeners (the `:80` HTTP→HTTPS redirect, ACME challenge) are unchanged;
52/// the main site listeners pass `(router, fast)` (via `From<(Router, FastServe)>`) to
53/// enable the bypass. Cheap to clone (an `Arc`-y `Router` clone + an `Option`).
54#[derive(Clone)]
55pub struct ServeInput {
56 router: Router,
57 fast: Option<crate::FastServe>,
58}
59
60impl From<Router> for ServeInput {
61 fn from(router: Router) -> Self {
62 Self { router, fast: None }
63 }
64}
65
66impl From<(Router, crate::FastServe)> for ServeInput {
67 fn from((router, fast): (Router, crate::FastServe)) -> Self {
68 Self {
69 router,
70 fast: Some(fast),
71 }
72 }
73}
74
75/// Bridges [`boatramp_http`]'s serving surface to the axum [`Router`] (a tower
76/// `Service`). Both codecs produce a native `http::Request`, so the bridge only
77/// swaps the body type and attaches the peer address; method / URI / headers pass
78/// through untouched. Constructed once per connection. Carries the optional
79/// [`FastServe`](crate::FastServe) hot-path handle: an eligible plain site GET/HEAD is
80/// dispatched straight to `serve_by_host`, skipping the axum router + middleware
81/// composition; everything else falls through to the router unchanged.
82pub struct RouterHandler {
83 serve: ServeInput,
84 peer: SocketAddr,
85}
86
87impl RouterHandler {
88 pub fn new(serve: impl Into<ServeInput>, peer: SocketAddr) -> Self {
89 Self {
90 serve: serve.into(),
91 peer,
92 }
93 }
94}
95
96impl Handler for RouterHandler {
97 async fn handle(&self, req: HttpRequest) -> HttpResponse {
98 // Wrap the streaming request body as an axum body (cheap; `ReqBody` is an
99 // `http_body::Body`) and attach the peer as `ConnectInfo` (IP rules / rate
100 // limiting / access logs read it). Nothing else is rebuilt.
101 let mut request = req.map(axum::body::Body::new);
102 request
103 .extensions_mut()
104 .insert(axum::extract::ConnectInfo(self.peer));
105
106 // Hot path: an eligible plain site GET/HEAD skips the axum router + middleware
107 // future-composition (~15–20% of per-core CPU, profiled) and dispatches straight
108 // to `serve_by_host`. `FastServe::dispatch` still applies the request-id +
109 // access-log/metrics guarantees, and rate-limit / visitor-auth / host-routing /
110 // preview-auth / DV all run inside `serve_by_host_inner` — so the bypass can never
111 // skip a security check. Everything else (and every build without a fast handle)
112 // falls through to the router, byte-identical to before.
113 let resp = match &self.serve.fast {
114 Some(fast) if fast.eligible(&request) => fast.dispatch(request, self.peer).await,
115 _ => {
116 // Call the router as a tower Service (axum's Router is always ready).
117 use tower_service::Service as _;
118 let mut router = self.serve.router.clone();
119 match router.call(request).await {
120 Ok(r) => r,
121 Err(_) => return boatramp_http::response(502, b"bad gateway".to_vec()),
122 }
123 }
124 };
125 let (mut parts, body) = resp.into_parts();
126 // Zero-copy static: `serve_entry` attached the open file as an extension for a
127 // large plaintext static blob. Hand the codec a `Body::File` — it moves the
128 // bytes with `sendfile` over a plaintext socket (no userspace copy) and reads +
129 // writes them otherwise. Only reachable for plaintext large static, so the h1
130 // codec's write half is a bare TCP socket and `sendfile` applies.
131 if let Some(src) = parts
132 .extensions
133 .remove::<crate::serve_pipeline::SendfileSource>()
134 {
135 return axum::http::Response::from_parts(
136 parts,
137 HttpBody::file(src.file, src.offset, src.len),
138 );
139 }
140 // Hand the router's body to the codec as a pull `Stream` it polls itself —
141 // no producer task, no channel, no buffering (unbounded bodies stream too).
142 // Data frames pass through; a mid-stream body error (an upstream that dropped)
143 // becomes a `BodyError` so the codec aborts the response instead of framing a
144 // truncated body as complete; trailer/empty frames are dropped. We do NOT
145 // strip hop-by-hop headers here: the h1 loop reframes them (and preserves a
146 // `101` upgrade's verbatim), and the h2 codec drops the ones it forbids.
147 let chunks = BodyStream::new(body).filter_map(|frame| {
148 std::future::ready(match frame {
149 Ok(f) => f.into_data().ok().filter(|b| !b.is_empty()).map(Ok),
150 Err(_) => Some(Err(BodyError)),
151 })
152 });
153 axum::http::Response::from_parts(parts, HttpBody::try_stream(chunks))
154 }
155}
156
157/// Serve one accepted connection (plaintext or already-TLS-terminated) by driving
158/// it through the unified [`boatramp_http::serve_connection`] dispatcher, bridged
159/// into `router`. A clean close is silent; an unexpected IO error is logged at
160/// debug. Generic over the IO so a raw `TcpStream`, a rewound stream, or a
161/// `TlsStream` all serve identically.
162pub async fn serve_router_conn<IO>(io: IO, peer: SocketAddr, serve: impl Into<ServeInput>)
163where
164 IO: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
165{
166 if let Err(err) = boatramp_http::serve_connection(io, RouterHandler::new(serve, peer)).await {
167 tracing::debug!(%peer, %err, "connection served with error");
168 }
169}
170
171/// Serve one TLS-terminated connection, routing on the **negotiated ALPN** rather
172/// than re-sniffing the stream: ALPN already told us the protocol, so we hand the
173/// decrypted stream straight to the h2 mux driver or the h1 loop with no preface
174/// sniff and no [`Rewind`] wrapper (that wrapper would otherwise sit on the write
175/// path and break the h2 writer's vectored `IoSlice` fast-path). A completed
176/// `acme-tls/1` challenge handshake carries no request and is dropped. A connection
177/// that negotiated no ALPN falls back to the sniffing dispatcher.
178async fn serve_tls_stream(
179 stream: tokio_rustls::server::TlsStream<TcpStream>,
180 peer: SocketAddr,
181 serve: ServeInput,
182) {
183 let alpn = stream.get_ref().1.alpn_protocol().map(<[u8]>::to_vec);
184 let handler = RouterHandler::new(serve, peer);
185 let result = match alpn.as_deref() {
186 // The challenge handshake alone satisfies the CA — nothing to serve.
187 Some(p) if p == ACME_TLS_ALPN => {
188 tracing::debug!(%peer, "completed an ACME tls-alpn-01 challenge");
189 return;
190 }
191 Some(b"h2") => boatramp_http::h2::serve_connection_mux(stream, handler).await,
192 Some(b"http/1.1") => boatramp_http::h1::serve_connection(stream, handler).await,
193 // No ALPN negotiated (a bare TLS client): let the dispatcher sniff h2c-vs-h1.
194 _ => boatramp_http::serve_connection(stream, handler).await,
195 };
196 if let Err(err) = result {
197 tracing::debug!(%peer, %err, "TLS connection served with error");
198 }
199}
200
201/// A rustls [`ServerConfig`] the accept loop reads afresh per connection, so a
202/// background task (ACME renewal) can hot-swap the served certificate without a
203/// restart — the same capability `axum_server`'s `RustlsConfig` gave us. Reads are
204/// a single lock-free atomic load ([`ArcSwap`]).
205#[derive(Clone)]
206pub struct ReloadableTls(Arc<ArcSwap<ServerConfig>>);
207
208impl ReloadableTls {
209 /// Wrap a config that will be served (and may later be [`reload`](Self::reload)ed).
210 pub fn new(config: ServerConfig) -> Self {
211 Self(Arc::new(ArcSwap::from_pointee(config)))
212 }
213
214 /// Atomically replace the served config; connections accepted after this use it,
215 /// in-flight ones are unaffected.
216 pub fn reload(&self, config: ServerConfig) {
217 self.0.store(Arc::new(config));
218 }
219
220 fn current(&self) -> Arc<ServerConfig> {
221 self.0.load_full()
222 }
223}
224
225impl From<ServerConfig> for ReloadableTls {
226 fn from(config: ServerConfig) -> Self {
227 Self::new(config)
228 }
229}
230
231/// How long the serve loops wait for in-flight connections to finish after the
232/// shutdown signal before dropping them — matches the plaintext drain deadline.
233const DRAIN_DEADLINE: std::time::Duration = std::time::Duration::from_secs(30);
234
235/// Serve HTTPS on `addr`: accept TCP, terminate TLS with the (reloadable) rustls
236/// config, and drive the negotiated protocol through [`serve_router_conn`]. An
237/// ACME `acme-tls/1` challenge handshake completes and is dropped (it carries no
238/// request). Returns once `shutdown` resolves, after a bounded drain of in-flight
239/// connections. The config SHOULD advertise ALPN `h2`/`http/1.1` ([`alpn_h1_h2`]);
240/// an ACME config additionally carries `acme-tls/1`.
241pub async fn serve_tls<S>(
242 addr: SocketAddr,
243 tls: ReloadableTls,
244 serve: impl Into<ServeInput>,
245 shutdown: S,
246) -> std::io::Result<()>
247where
248 S: Future<Output = ()> + Send,
249{
250 let listener = TcpListener::bind(addr).await?;
251 serve_tls_listener(listener, tls, serve, shutdown).await
252}
253
254/// [`serve_tls`] on an already-bound [`TcpListener`] — for callers that must learn
255/// the bound port first (an ephemeral `:0` bind) or that inherit the socket
256/// (systemd activation, tests).
257pub async fn serve_tls_listener<S>(
258 listener: TcpListener,
259 tls: ReloadableTls,
260 serve: impl Into<ServeInput>,
261 shutdown: S,
262) -> std::io::Result<()>
263where
264 S: Future<Output = ()> + Send,
265{
266 if let Ok(addr) = listener.local_addr() {
267 tracing::info!(%addr, "serving HTTPS (boatramp-http)");
268 }
269 let serve = serve.into();
270 let inflight = Arc::new(AtomicUsize::new(0));
271 tokio::pin!(shutdown);
272 loop {
273 tokio::select! {
274 _ = &mut shutdown => break,
275 accepted = listener.accept() => {
276 let (mut tcp, peer) = match accepted {
277 Ok(v) => v,
278 Err(err) => {
279 tracing::debug!(%err, "TLS serve: accept error");
280 continue;
281 }
282 };
283 crate::disable_nagle(&mut tcp);
284 let acceptor = TlsAcceptor::from(tls.current());
285 let serve = serve.clone();
286 let inflight = inflight.clone();
287 inflight.fetch_add(1, Ordering::SeqCst);
288 tokio::spawn(async move {
289 match acceptor.accept(tcp).await {
290 Ok(stream) => serve_tls_stream(stream, peer, serve).await,
291 Err(err) => tracing::debug!(%peer, %err, "TLS handshake failed"),
292 }
293 inflight.fetch_sub(1, Ordering::SeqCst);
294 });
295 }
296 }
297 }
298 drain(&inflight).await;
299 Ok(())
300}
301
302/// Serve plaintext HTTP on `addr` through the unified dispatcher (h1, or h2c via
303/// the preface sniff), bridged into `router`. Used by the `:80` HTTP→HTTPS
304/// redirect listener. Returns once `shutdown` resolves, after a bounded drain.
305pub async fn serve_plaintext<S>(
306 addr: SocketAddr,
307 serve: impl Into<ServeInput>,
308 shutdown: S,
309) -> std::io::Result<()>
310where
311 S: Future<Output = ()> + Send,
312{
313 let listener = TcpListener::bind(addr).await?;
314 serve_plaintext_listener(listener, serve, shutdown).await
315}
316
317/// [`serve_plaintext`] on an already-bound [`TcpListener`] (see
318/// [`serve_tls_listener`]).
319pub async fn serve_plaintext_listener<S>(
320 listener: TcpListener,
321 serve: impl Into<ServeInput>,
322 shutdown: S,
323) -> std::io::Result<()>
324where
325 S: Future<Output = ()> + Send,
326{
327 let serve = serve.into();
328 let inflight = Arc::new(AtomicUsize::new(0));
329 tokio::pin!(shutdown);
330 loop {
331 tokio::select! {
332 _ = &mut shutdown => break,
333 accepted = listener.accept() => {
334 let (mut tcp, peer) = match accepted {
335 Ok(v) => v,
336 Err(err) => {
337 tracing::debug!(%err, "plaintext serve: accept error");
338 continue;
339 }
340 };
341 crate::disable_nagle(&mut tcp);
342 let serve = serve.clone();
343 let inflight = inflight.clone();
344 inflight.fetch_add(1, Ordering::SeqCst);
345 tokio::spawn(async move {
346 serve_router_conn(tcp, peer, serve).await;
347 inflight.fetch_sub(1, Ordering::SeqCst);
348 });
349 }
350 }
351 }
352 drain(&inflight).await;
353 Ok(())
354}
355
356/// Wait for in-flight connections to drop to zero, or the [`DRAIN_DEADLINE`],
357/// whichever comes first (then the caller returns and any stragglers are dropped).
358async fn drain(inflight: &AtomicUsize) {
359 let deadline = tokio::time::Instant::now() + DRAIN_DEADLINE;
360 while inflight.load(Ordering::SeqCst) > 0 {
361 if tokio::time::Instant::now() >= deadline {
362 tracing::warn!("TLS/plaintext drain deadline exceeded; dropping in-flight connections");
363 break;
364 }
365 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
366 }
367}