Skip to main content

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}