av_harness/http_serve.rs
1//! Accept loop with client-silence reaping — the slowloris defense
2//! `axum::serve` cannot provide.
3//!
4//! `axum::serve` (0.8) builds hyper-util's connection builder with **no
5//! timer and no `header_read_timeout`** and exposes no way to set one, so
6//! a client that connects and sends a partial request line — or full
7//! headers and then dribbles a `Content-Length` body forever — holds its
8//! fd and connection task indefinitely (verified empirically: 90 s+ holds
9//! with zero reaping). On the shipped k8s/systemd exposure that is an
10//! unauthenticated fd-exhaustion vector on the proxy front door.
11//!
12//! This module owns the accept loop instead, mirroring axum's semantics
13//! (`Router::call` per connection, `serve_connection_with_upgrades`,
14//! graceful drain on shutdown) while adding exactly two client-silence
15//! bounds — deliberately *not* a whole-request deadline, because a
16//! legitimate upstream first token can take minutes and SSE responses
17//! stream far longer than any sane global timeout:
18//!
19//! * **Header phase**: hyper's `header_read_timeout` (30 s), which also
20//! reaps pre-request idle keep-alive connections (hyper arms it
21//! between requests on an idle HTTP/1 connection).
22//! * **Body phase**: a per-*frame* gap timeout on the request body
23//! (30 s between chunks), applied via `SilenceBoundedBody`. A slow
24//! uploader making real progress never trips it; a stalled one is
25//! aborted with a hyper error that resets the stream.
26//! * **HTTP/2**: keep-alive pings (20 s interval / 30 s grace) reap
27//! silent peers; `header_read_timeout` covers the h1 preface path.
28//!
29//! Shutdown mirrors `axum::serve::with_graceful_shutdown`: when the
30//! caller's shutdown future resolves, the listener is dropped (new
31//! connects refused), every live connection gets
32//! `graceful_shutdown()` via [`hyper_util::server::graceful::GracefulShutdown`],
33//! and the returned future resolves once all connections finish — the
34//! caller keeps its own drain-budget `timeout` around that future, so
35//! the drain-timeout accounting in `main.rs` is unchanged.
36
37use std::convert::Infallible;
38use std::future::Future;
39use std::pin::Pin;
40use std::sync::Arc;
41use std::task::{Context, Poll};
42use std::time::Duration;
43
44use axum::Router;
45use hyper::body::{Body, Frame, Incoming};
46use hyper::service::service_fn;
47use hyper_util::rt::{TokioExecutor, TokioIo, TokioTimer};
48use hyper_util::server::conn::auto::Builder;
49use hyper_util::server::graceful::GracefulShutdown;
50use tower::ServiceExt as _;
51
52/// Maximum client silence while reading request headers (also arms
53/// between keep-alive requests, reaping pre-request idle connections).
54/// Production value; passed by `main.rs` into the serve loop.
55pub const HEADER_READ_TIMEOUT: Duration = Duration::from_secs(30);
56/// Maximum gap between request-body frames from the client.
57/// Production value; passed by `main.rs` into the serve loop.
58pub const BODY_FRAME_GAP_TIMEOUT: Duration = Duration::from_secs(30);
59/// HTTP/2 keep-alive probe interval / unacknowledged-ping grace.
60const H2_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(20);
61const H2_KEEPALIVE_TIMEOUT: Duration = Duration::from_secs(30);
62
63/// Serve `router` on `listener` with client-silence reaping, resolving
64/// after `shutdown` fires and every remaining connection drains. The
65/// caller wraps the post-shutdown tail in its own drain-budget timeout.
66/// `on_accept` runs synchronously per accepted socket (socket-option
67/// tuning — the caller owns its failure/log policy). The two silence
68/// bounds are parameters so tests can exercise reaping in milliseconds;
69/// production callers pass [`HEADER_READ_TIMEOUT`] /
70/// [`BODY_FRAME_GAP_TIMEOUT`].
71#[allow(clippy::too_many_arguments)]
72pub async fn serve_with_client_silence_reaping(
73 listener: tokio::net::TcpListener,
74 router: Router,
75 on_accept: impl Fn(&tokio::net::TcpStream) + Send,
76 stalled_reaps: Arc<av_core::metrics::Counter>,
77 accept_failures: Arc<av_core::metrics::Counter>,
78 header_read_timeout: Duration,
79 body_frame_gap: Duration,
80 shutdown: impl Future<Output = ()> + Send + 'static,
81) -> std::io::Result<()> {
82 let graceful = GracefulShutdown::new();
83 let mut shutdown = std::pin::pin!(shutdown);
84 loop {
85 let (stream, _remote) = tokio::select! {
86 accepted = listener.accept() => match accepted {
87 Ok(pair) => pair,
88 Err(error) => {
89 // Transient accept errors (EMFILE under fd pressure,
90 // ECONNABORTED) must not kill the accept loop — that
91 // would turn resource pressure into a full outage.
92 // Warn ONCE per process: under sustained EMFILE the
93 // failing accept never drains the kernel queue, so
94 // this arm loops at ~20 Hz for the whole incident —
95 // an unthrottled warn floods the log pipeline during
96 // the exact resource emergency it reports (same
97 // dampener discipline as the TCP_NODELAY warn in
98 // main.rs). The counter is the primary signal.
99 static WARNED: std::sync::Once = std::sync::Once::new();
100 WARNED.call_once(|| {
101 tracing::warn!(
102 %error,
103 "accept failed; continuing. Subsequent failures counted only \
104 via av_http_accept_failures_total to avoid a log storm"
105 );
106 });
107 accept_failures.inc();
108 tokio::time::sleep(Duration::from_millis(50)).await;
109 continue;
110 }
111 },
112 () = shutdown.as_mut() => break,
113 };
114 on_accept(&stream);
115 let router = router.clone();
116 let reaps = Arc::clone(&stalled_reaps);
117 let watcher = graceful.watcher();
118 tokio::spawn(async move {
119 // First-byte deadline. Hyper-util's auto builder sits in
120 // protocol detection (ReadVersion) BEFORE either timed HTTP
121 // connection exists, so `header_read_timeout` does not
122 // cover a socket that connects and sends NOTHING — the
123 // classic slowloris shape held the socket and this task
124 // forever, exhausting fds without ever authenticating.
125 // Peek (not read — hyper must still see the bytes) for the
126 // first byte under the same header deadline. Residual: a
127 // client stalling MID-version-prefix (e.g. 5 bytes of the
128 // h2 preface) still parks in ReadVersion — closing that
129 // needs a detection deadline inside hyper-util; the
130 // zero-byte hold is the cheap, scripted attack.
131 let mut first_byte = [0u8; 1];
132 match tokio::time::timeout(header_read_timeout, stream.peek(&mut first_byte)).await {
133 Ok(Ok(n)) if n > 0 => {}
134 // Timeout, clean EOF, or socket error before any byte:
135 // reap. Same counter as the in-request silence reaps —
136 // it is the same "client went silent" class.
137 _ => {
138 reaps.inc();
139 return;
140 }
141 }
142 let io = TokioIo::new(stream);
143 let mut builder = Builder::new(TokioExecutor::new());
144 builder
145 .http1()
146 .timer(TokioTimer::new())
147 .header_read_timeout(header_read_timeout);
148 builder
149 .http2()
150 .timer(TokioTimer::new())
151 .keep_alive_interval(H2_KEEPALIVE_INTERVAL)
152 .keep_alive_timeout(H2_KEEPALIVE_TIMEOUT);
153 let service = service_fn(move |request: hyper::Request<Incoming>| {
154 let router = router.clone();
155 let reaps = Arc::clone(&reaps);
156 async move {
157 let request = request.map(|body| {
158 axum::body::Body::new(SilenceBoundedBody::new(body, body_frame_gap, reaps))
159 });
160 // `oneshot` = ready + call; Router is always ready and
161 // its error is Infallible, so this cannot fail.
162 let response = router.oneshot(request).await.unwrap_or_else(|e| match e {});
163 Ok::<_, Infallible>(response)
164 }
165 });
166 let connection = builder.serve_connection_with_upgrades(io, service);
167 if let Err(error) = watcher.watch(connection.into_owned()).await {
168 // Routine peer misbehavior (resets, aborted handshakes,
169 // tripped silence bounds) — connection-level, not fatal.
170 tracing::debug!(%error, "connection ended with error");
171 }
172 });
173 }
174 // Refuse new connects immediately; then wait for live connections.
175 drop(listener);
176 graceful.shutdown().await;
177 Ok(())
178}
179
180/// Request-body wrapper that enforces a maximum silence gap *between
181/// frames*. Progress resets the clock, so arbitrarily large uploads pass
182/// as long as bytes keep arriving; a stalled sender is cut with an error
183/// that hyper turns into a connection/stream reset.
184struct SilenceBoundedBody {
185 inner: Incoming,
186 gap: Duration,
187 sleep: Option<Pin<Box<tokio::time::Sleep>>>,
188 reaps: Arc<av_core::metrics::Counter>,
189}
190
191impl SilenceBoundedBody {
192 fn new(inner: Incoming, gap: Duration, reaps: Arc<av_core::metrics::Counter>) -> Self {
193 Self {
194 inner,
195 gap,
196 sleep: None,
197 reaps,
198 }
199 }
200}
201
202impl Body for SilenceBoundedBody {
203 type Data = hyper::body::Bytes;
204 type Error = Box<dyn std::error::Error + Send + Sync>;
205
206 fn poll_frame(
207 mut self: Pin<&mut Self>,
208 cx: &mut Context<'_>,
209 ) -> Poll<Option<Result<Frame<Self::Data>, Self::Error>>> {
210 match Pin::new(&mut self.inner).poll_frame(cx) {
211 Poll::Ready(ready) => {
212 // Progress (frame, end, or error): disarm the gap clock.
213 self.sleep = None;
214 Poll::Ready(ready.map(|result| result.map_err(Into::into)))
215 }
216 Poll::Pending => {
217 let gap = self.gap;
218 let sleep = self
219 .sleep
220 .get_or_insert_with(|| Box::pin(tokio::time::sleep(gap)));
221 match sleep.as_mut().poll(cx) {
222 Poll::Ready(()) => {
223 self.reaps.inc();
224 Poll::Ready(Some(Err(
225 "request body stalled: no frame within the silence bound".into(),
226 )))
227 }
228 Poll::Pending => Poll::Pending,
229 }
230 }
231 }
232 }
233
234 fn is_end_stream(&self) -> bool {
235 self.inner.is_end_stream()
236 }
237
238 fn size_hint(&self) -> hyper::body::SizeHint {
239 self.inner.size_hint()
240 }
241}
242
243#[cfg(test)]
244mod tests {
245 #![allow(
246 clippy::unwrap_used,
247 clippy::expect_used,
248 clippy::panic,
249 clippy::indexing_slicing
250 )]
251
252 use super::*;
253 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
254
255 /// Test-scale silence bounds: long enough that a healthy request
256 /// never trips them under CI scheduler jitter (the workspace's
257 /// documented 25× drift budget over a 10 ms timer), short enough
258 /// that reap tests finish in well under a second.
259 const TEST_HEADER_TIMEOUT: Duration = Duration::from_millis(300);
260 const TEST_BODY_GAP: Duration = Duration::from_millis(300);
261 /// Client-side hard stop: if reaping is broken the read would hang
262 /// forever (the exact bug this module fixes), so every probe read
263 /// is capped at a bound that is generous against jitter but far
264 /// below "held for 90 s".
265 const CLIENT_READ_DEADLINE: Duration = Duration::from_secs(10);
266
267 fn test_router() -> Router {
268 Router::new().route(
269 "/echo",
270 axum::routing::post(|body: axum::body::Bytes| async move { format!("len={}", body.len()) }),
271 )
272 }
273
274 async fn spawn_server() -> (std::net::SocketAddr, Arc<av_core::metrics::Counter>) {
275 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
276 let addr = listener.local_addr().unwrap();
277 let reaps = Arc::new(av_core::metrics::Counter::default());
278 let reaps_for_server = Arc::clone(&reaps);
279 tokio::spawn(async move {
280 serve_with_client_silence_reaping(
281 listener,
282 test_router(),
283 |_| {},
284 reaps_for_server,
285 Arc::new(av_core::metrics::Counter::default()),
286 TEST_HEADER_TIMEOUT,
287 TEST_BODY_GAP,
288 std::future::pending(),
289 )
290 .await
291 .unwrap();
292 });
293 (addr, reaps)
294 }
295
296 /// Read until the peer closes, bounded by CLIENT_READ_DEADLINE.
297 async fn read_to_close(stream: &mut tokio::net::TcpStream) -> Vec<u8> {
298 let mut collected = Vec::new();
299 tokio::time::timeout(CLIENT_READ_DEADLINE, async {
300 let mut buffer = [0_u8; 1024];
301 loop {
302 match stream.read(&mut buffer).await {
303 Ok(0) | Err(_) => break,
304 Ok(n) => collected.extend_from_slice(&buffer[..n]),
305 }
306 }
307 })
308 .await
309 .expect("connection was never reaped: read still open at the client deadline");
310 collected
311 }
312
313 /// The exact empirical repro from the live probe: partial request
314 /// line, then silence. `axum::serve` held this ≥90 s (unbounded);
315 /// the reaping loop must close it at the header timeout.
316 #[tokio::test]
317 async fn slow_header_connection_is_reaped_at_the_header_timeout() {
318 let (addr, reaps) = spawn_server().await;
319 let mut stream = tokio::net::TcpStream::connect(addr).await.unwrap();
320 stream
321 .write_all(b"POST /echo HTTP/1.1\r\nHost: x\r\nX-Part")
322 .await
323 .unwrap();
324 let response = read_to_close(&mut stream).await;
325 // hyper answers the header timeout with 408 then closes; the
326 // essential property is the close (no unbounded hold). No
327 // body-phase reap is involved.
328 let text = String::from_utf8_lossy(&response);
329 assert!(
330 text.is_empty() || text.contains("408"),
331 "unexpected response to a reaped slow-header connection: {text:?}",
332 );
333 assert_eq!(reaps.get(), 0, "header-phase reap must not count as a body stall");
334 }
335
336 /// A socket that connects and sends NOTHING never leaves
337 /// hyper-util's protocol detection, which sits BEFORE either timed
338 /// HTTP connection — `header_read_timeout` never armed and the
339 /// zero-byte slowloris held the socket + task forever. The
340 /// first-byte peek deadline must close it at the header timeout
341 /// and count it as a silence reap.
342 #[tokio::test]
343 async fn zero_byte_connection_is_reaped_at_the_header_timeout() {
344 let (addr, reaps) = spawn_server().await;
345 let mut stream = tokio::net::TcpStream::connect(addr).await.unwrap();
346 // Send nothing at all.
347 let response = read_to_close(&mut stream).await;
348 assert!(
349 response.is_empty(),
350 "a never-spoke connection must be dropped without a response, got {:?}",
351 String::from_utf8_lossy(&response)
352 );
353 assert_eq!(
354 reaps.get(),
355 1,
356 "the zero-byte hold counts as a client-silence reap"
357 );
358 }
359
360 /// Full headers + Content-Length, a few bytes, then silence: the
361 /// body-phase half of the probe. Must be cut at the frame-gap bound
362 /// and counted in the reap metric.
363 #[tokio::test]
364 async fn stalled_request_body_is_reaped_at_the_frame_gap() {
365 let (addr, reaps) = spawn_server().await;
366 let mut stream = tokio::net::TcpStream::connect(addr).await.unwrap();
367 stream
368 .write_all(b"POST /echo HTTP/1.1\r\nHost: x\r\nContent-Length: 100000\r\n\r\nxxxxxxxxxx")
369 .await
370 .unwrap();
371 let response = read_to_close(&mut stream).await;
372 let text = String::from_utf8_lossy(&response);
373 // Axum surfaces the failed body extraction as a 4xx client
374 // error rather than a hang; the connection must terminate.
375 assert!(
376 text.contains("HTTP/1.1 4"),
377 "expected a 4xx client-error response to the stalled body, got: {text:?}",
378 );
379 assert_eq!(reaps.get(), 1, "stall must be counted in the reap metric");
380 }
381
382 /// A slow-but-progressing upload must NOT be reaped: the gap clock
383 /// resets on every frame, so total transfer time has no bound —
384 /// only per-frame silence does. Total time here (8 × 60 ms) exceeds
385 /// the 300 ms gap bound, proving the clock resets.
386 #[tokio::test]
387 async fn progressing_slow_upload_is_never_reaped() {
388 let (addr, reaps) = spawn_server().await;
389 let mut stream = tokio::net::TcpStream::connect(addr).await.unwrap();
390 stream
391 .write_all(b"POST /echo HTTP/1.1\r\nHost: x\r\nContent-Length: 80\r\n\r\n")
392 .await
393 .unwrap();
394 for _ in 0_u8..8 {
395 stream.write_all(&[b'y'; 10]).await.unwrap();
396 stream.flush().await.unwrap();
397 tokio::time::sleep(Duration::from_millis(60)).await;
398 }
399 let mut response = Vec::new();
400 tokio::time::timeout(CLIENT_READ_DEADLINE, async {
401 let mut buffer = [0_u8; 512];
402 while !response.windows(6).any(|w| w == b"len=80") {
403 let n = stream.read(&mut buffer).await.unwrap();
404 assert!(n > 0, "server closed a progressing upload");
405 response.extend_from_slice(&buffer[..n]);
406 }
407 })
408 .await
409 .expect("upload response never arrived");
410 let text = String::from_utf8_lossy(&response);
411 assert!(text.contains("200 OK"), "upload must succeed: {text:?}");
412 assert_eq!(reaps.get(), 0, "no reap for a progressing upload");
413 }
414
415 /// Ordinary fast requests and keep-alive reuse must be unaffected:
416 /// two requests on one connection, both answered, zero reaps.
417 #[tokio::test]
418 async fn fast_requests_and_keepalive_reuse_pass_through() {
419 let (addr, reaps) = spawn_server().await;
420 let mut stream = tokio::net::TcpStream::connect(addr).await.unwrap();
421 for _ in 0_u8..2 {
422 stream
423 .write_all(b"POST /echo HTTP/1.1\r\nHost: x\r\nContent-Length: 2\r\n\r\nok")
424 .await
425 .unwrap();
426 let mut collected = Vec::new();
427 tokio::time::timeout(CLIENT_READ_DEADLINE, async {
428 let mut buffer = [0_u8; 512];
429 while !collected.windows(5).any(|w| w == b"len=2") {
430 let n = stream.read(&mut buffer).await.unwrap();
431 assert!(n > 0, "server closed a healthy keep-alive connection");
432 collected.extend_from_slice(&buffer[..n]);
433 }
434 })
435 .await
436 .expect("keep-alive response never arrived");
437 }
438 assert_eq!(reaps.get(), 0);
439 }
440
441 /// Shutdown contract: resolving the shutdown future must make the
442 /// serve future return once connections drain (here: none live).
443 #[tokio::test]
444 async fn shutdown_future_resolves_the_serve_loop() {
445 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
446 let (tx, rx) = tokio::sync::oneshot::channel::<()>();
447 let serve = tokio::spawn(serve_with_client_silence_reaping(
448 listener,
449 test_router(),
450 |_| {},
451 Arc::new(av_core::metrics::Counter::default()),
452 Arc::new(av_core::metrics::Counter::default()),
453 TEST_HEADER_TIMEOUT,
454 TEST_BODY_GAP,
455 async move {
456 let _ = rx.await;
457 },
458 ));
459 tx.send(()).unwrap();
460 tokio::time::timeout(Duration::from_secs(5), serve)
461 .await
462 .expect("serve loop must resolve after shutdown")
463 .unwrap()
464 .unwrap();
465 }
466}