Skip to main content

pjson_rs/infrastructure/http/
serve.rs

1//! Connection-level protection for servers hosting this crate's [`Router`]s.
2//!
3//! [`apply_common_layers`](super::axum_adapter) bounds handler-execution
4//! concurrency and pre-response request time, but — as documented on
5//! `RESPONSE_BODY_IDLE_TIMEOUT` in `axum_adapter.rs` — none of those tower
6//! layers can detect a client that stops reading the response socket: once
7//! hyper's write buffer fills and `poll_flush` parks waiting on socket
8//! writability, the response body is never polled again, so a
9//! poll-driven idle timeout never fires. Closing that gap requires owning
10//! the accept loop and the raw connection, which is what [`serve_with_limits`]
11//! does in place of `axum::serve`.
12
13use std::{
14    io,
15    net::{IpAddr, Ipv6Addr, SocketAddr},
16    sync::Arc,
17    time::Duration,
18};
19
20use axum::{Extension, Router, extract::ConnectInfo};
21use hyper_util::{
22    rt::{TokioExecutor, TokioIo, TokioTimer},
23    server::conn::auto::Builder,
24    service::TowerToHyperService,
25};
26use tokio::{net::TcpListener, sync::Semaphore};
27use tower::Layer;
28use tracing::{debug, error, warn};
29
30use crate::security::rate_limit::{
31    RateLimitConfig, RateLimitError, RateLimitGuard, WebSocketRateLimiter,
32};
33
34/// Interval between HTTP/2 keep-alive `PING` frames sent on an established
35/// connection.
36///
37/// Bounds an *unresponsive* HTTP/2 connection below `max_connection_duration`
38/// by detecting a peer that stops answering pings. This is a responsiveness
39/// check only, not an idleness check: a peer that keeps acknowledging pings
40/// every interval survives the full `max_connection_duration` ceiling
41/// regardless of how idle the connection otherwise is. Inert unless an
42/// interval is set, since hyper's h2 keep-alive defaults to disabled.
43const H2_KEEP_ALIVE_INTERVAL: Duration = Duration::from_secs(30);
44
45/// Deadline for a peer to acknowledge an HTTP/2 keep-alive `PING` before the
46/// connection is dropped as unresponsive.
47///
48/// 20s matches hyper's own current default (`hyper::proto::h2::server`);
49/// pinned explicitly here (rather than left implicit) so a future change to
50/// that upstream default doesn't silently change this crate's behavior.
51const H2_KEEP_ALIVE_TIMEOUT: Duration = Duration::from_secs(20);
52
53/// Connection-level limits enforced by [`serve_with_limits`], independent of
54/// (and in addition to) the request-level tower layers applied by this
55/// crate's router constructors.
56///
57/// Constructed via [`ConnectionLimits::default`] and overridden per field;
58/// `#[non_exhaustive]` so new limits can be added without a breaking change.
59///
60/// # Examples
61///
62/// ```
63/// use std::time::Duration;
64/// use pjson_rs::infrastructure::http::ConnectionLimits;
65///
66/// // Long-lived WebSocket listeners disable the connection-duration ceiling
67/// // (see that field's docs) while keeping the other defaults.
68/// let mut limits = ConnectionLimits::default();
69/// limits.max_connection_duration = None;
70/// assert_eq!(limits.header_read_timeout, Some(Duration::from_secs(10)));
71/// ```
72#[derive(Debug, Clone)]
73#[non_exhaustive]
74pub struct ConnectionLimits {
75    /// Deadline for a client to finish sending request headers after the
76    /// connection is accepted, `None` to disable.
77    ///
78    /// Defaults to 10s. Hyper's own default is 30s and nginx uses 60s;
79    /// this crate picks 10s to cut the cost of a slowloris-style
80    /// header-trickle attack roughly 6x relative to hyper's default while
81    /// remaining far above the time any real client needs to send headers.
82    ///
83    /// Also gates [`serve_with_limits`]'s preface-read wait (see that
84    /// function's implementation) — setting this to `None` together with
85    /// `max_connection_duration: None` lets a connection that never sends a
86    /// byte hold its `max_connections` slot (and, if assigned one, its
87    /// `max_connections_per_ip` slot) indefinitely; at least one of the two
88    /// should normally stay `Some`.
89    pub header_read_timeout: Option<Duration>,
90
91    /// Hard ceiling on a single connection's total lifetime, `None` to
92    /// disable.
93    ///
94    /// Defaults to 300s (5 minutes). Response payload size is bounded by
95    /// `MAX_FRAMES_PER_REQUEST` (see `domain::config::limits`) together with
96    /// the 10MB `DefaultBodyLimit` applied in `apply_common_layers`, which
97    /// implies a real client only ever needs to sustain roughly 33 KB/s to
98    /// finish reading within this window — far under any real client's
99    /// throughput and far over what a stalling client can fake.
100    ///
101    /// **WebSocket caveat**: this is a hard deadline on the whole
102    /// connection, including any upgraded protocol — it will terminate a
103    /// legitimate long-lived WebSocket session just as readily as a
104    /// stalling one. A listener that serves WebSocket upgrade routes should
105    /// set this to `None` and rely on WS-level idle/ping timeouts instead;
106    /// `crates/pjs-demo/src/servers/websocket_streaming.rs` does exactly
107    /// that. In particular, if serving a router that mounts this crate's own
108    /// `/pjs/ws/{session_id}` upgrade route (see `infrastructure::websocket`),
109    /// set this to `None` — the default 300s ceiling will otherwise kill
110    /// every WebSocket session it outlives.
111    pub max_connection_duration: Option<Duration>,
112
113    /// Maximum number of concurrently open connections.
114    ///
115    /// Defaults to 1024: conservative, overridable, and comfortably under
116    /// the file-descriptor soft limit on typical deployment targets. This
117    /// bounds accept-loop backpressure (how many connections are being
118    /// served at once), not per-request concurrency, which is a separate
119    /// concern already covered by `MAX_CONCURRENT_REQUESTS` in
120    /// `apply_common_layers`.
121    pub max_connections: usize,
122
123    /// Hard cap on concurrently open HTTP/2 streams per connection, `None`
124    /// to leave hyper's own default in place.
125    ///
126    /// Defaults to `Some(128)`. Hyper's own default is `Some(200)` and is
127    /// documented as explicitly unstable ("not part of the stability of
128    /// hyper... encouraged to set your own limit") — 128 sits strictly
129    /// below that default while remaining far above what any legitimate
130    /// browser or client needs. Combined with `max_connections`, this
131    /// bounds the worst case at `max_connections * max_concurrent_streams`
132    /// in-flight streams before `MAX_CONCURRENT_REQUESTS` (see
133    /// `apply_common_layers`) parks the rest.
134    pub max_concurrent_streams: Option<u32>,
135
136    /// Hard cap on concurrently open connections from a single accept-level
137    /// source IP, `None` to disable.
138    ///
139    /// Defaults to `Some(64)` — 1/16th of the default `max_connections`
140    /// pool, so at least 16 distinct source IPs are needed to fully exhaust
141    /// it. All three `pjs-demo` servers bind `127.0.0.1`, so every local
142    /// connection (including load/CI test loops) shares this one budget;
143    /// 64 concurrent connections from a single source is still far above
144    /// realistic demo or local-test load, so this is not special-cased.
145    ///
146    /// Enforced by a private `WebSocketRateLimiter` instance owned by
147    /// [`serve_with_limits`], independent of (and never sharing state with)
148    /// any `RateLimitMiddleware` the router itself may apply. `None`
149    /// disables the cap entirely — no limiter instance is constructed, no
150    /// cleanup task is spawned, and no per-connection map entry is made, so
151    /// a reverse-proxy deployment that sets this to `None` (see below) pays
152    /// no cost for it.
153    ///
154    /// **Reverse-proxy caveat**: like `max_connection_duration`, this is
155    /// enforced at the accept level, before any HTTP request is parsed — no
156    /// headers exist yet, so `X-Forwarded-For`/trusted-proxy configuration
157    /// cannot apply here. Every connection arriving through a
158    /// connection-pooling reverse proxy (nginx, a load balancer, etc.)
159    /// shares that proxy's single source IP, so this cap would apply to all
160    /// of them combined rather than to each real client individually. A
161    /// deployment behind such a proxy must set this to `None` and rely on
162    /// the proxy's own per-client limiting instead.
163    pub max_connections_per_ip: Option<usize>,
164}
165
166impl Default for ConnectionLimits {
167    fn default() -> Self {
168        Self {
169            header_read_timeout: Some(Duration::from_secs(10)),
170            max_connection_duration: Some(Duration::from_secs(300)),
171            max_connections: 1024,
172            max_concurrent_streams: Some(128),
173            max_connections_per_ip: Some(64),
174        }
175    }
176}
177
178/// Serve `router` on `listener`, enforcing `limits` at the connection level.
179///
180/// A drop-in replacement for `axum::serve(listener, router).await?` that
181/// additionally protects against the class of client that establishes a
182/// connection and then never finishes sending a request, or stops reading
183/// the response — neither of which the request-level tower layers in
184/// `apply_common_layers` can detect (see the module docs). Each accepted
185/// connection is served on its own task via
186/// [`serve_connection_with_upgrades`](hyper_util::server::conn::auto::Builder::serve_connection_with_upgrades),
187/// so WebSocket upgrade routes keep working; the peer address obtained from
188/// `accept()` is injected into each connection's request extensions as
189/// [`ConnectInfo<SocketAddr>`](axum::extract::ConnectInfo), matching what
190/// `axum::serve(listener, router.into_make_service_with_connect_info())`
191/// would provide, so `ConnectInfo`-based extractors (this crate's own
192/// WebSocket upgrade handler and per-IP rate limiter included) keep working.
193///
194/// A single slow or misbehaving *connection* is dropped (and logged at
195/// debug level), never propagated out of this function, so it cannot bring
196/// down the accept loop. A transient error from [`TcpListener::accept`]
197/// itself (e.g. `EMFILE`, or a peer that reset the connection between the
198/// kernel accepting it and userspace calling `accept()`) does not terminate
199/// the loop either — mirroring axum's own `Listener::accept` retry
200/// behavior, connection-class errors (`ConnectionRefused`,
201/// `ConnectionAborted`, `ConnectionReset`) are retried immediately and any
202/// other error is logged and retried after a 1s backoff. In practice this
203/// function only returns if a future revision adds an explicit exit path;
204/// today it runs until the process is torn down, the same as
205/// `axum::serve(listener, app).await` today.
206///
207/// # Examples
208///
209/// ```no_run
210/// # async fn run() -> std::io::Result<()> {
211/// use axum::Router;
212/// use pjson_rs::infrastructure::http::{ConnectionLimits, serve_with_limits};
213/// use tokio::net::TcpListener;
214///
215/// let listener = TcpListener::bind("127.0.0.1:0").await?;
216/// let router = Router::new();
217/// serve_with_limits(listener, router, ConnectionLimits::default()).await?;
218/// # Ok(())
219/// # }
220/// ```
221pub async fn serve_with_limits(
222    listener: TcpListener,
223    router: Router,
224    limits: ConnectionLimits,
225) -> std::io::Result<()> {
226    let mut builder = Builder::new(TokioExecutor::new());
227    builder.http1().timer(TokioTimer::new());
228    builder.http2().timer(TokioTimer::new());
229    // Gated rather than passed through unconditionally: hyper's
230    // `max_concurrent_streams(None)` means "remove the limit entirely", the
231    // opposite of this field's `None` = "leave hyper's own default in
232    // place" contract — skipping the call when `None` is what actually
233    // preserves hyper's default of 200.
234    if let Some(max_concurrent_streams) = limits.max_concurrent_streams {
235        builder
236            .http2()
237            .max_concurrent_streams(max_concurrent_streams);
238    }
239    builder
240        .http2()
241        .keep_alive_interval(Some(H2_KEEP_ALIVE_INTERVAL))
242        .keep_alive_timeout(H2_KEEP_ALIVE_TIMEOUT);
243    if let Some(header_read_timeout) = limits.header_read_timeout {
244        builder.http1().header_read_timeout(header_read_timeout);
245    }
246    let builder = Arc::new(builder);
247    let semaphore = Arc::new(Semaphore::new(limits.max_connections));
248    let max_connection_duration = limits.max_connection_duration;
249    let header_read_timeout = limits.header_read_timeout;
250
251    // Separate from any `RateLimitMiddleware` the router itself may apply:
252    // sharing one `Arc<WebSocketRateLimiter>` would let an accept-level IP
253    // flood saturate the middleware's tracked-client map and start
254    // rejecting legitimate new clients at the HTTP layer.
255    //
256    // `None` skips construction entirely (rather than substituting
257    // `usize::MAX`), so a deployment that disables this cap — e.g. behind a
258    // connection-pooling reverse proxy, see the field's doc — pays no cost
259    // for it: no limiter, no cleanup task, no per-connection map entry.
260    let per_ip_limiter = limits.max_connections_per_ip.map(|max_connections_per_ip| {
261        let limiter = Arc::new(WebSocketRateLimiter::new(RateLimitConfig {
262            max_connections_per_ip,
263            ..Default::default()
264        }));
265        limiter.spawn_cleanup_task(Duration::from_secs(60));
266        limiter
267    });
268
269    loop {
270        // Acquired before accept() so a full connection pool applies
271        // backpressure at the accept loop rather than inside hyper. The
272        // semaphore is never closed, so `Err` here is unreachable in
273        // practice; treat it as a benign "stop serving" signal rather than
274        // panicking on an accept-loop hot path.
275        let Ok(permit) = Arc::clone(&semaphore).acquire_owned().await else {
276            return Ok(());
277        };
278        let (stream, peer_addr) = accept_with_retry(&listener).await;
279
280        // Rejects fast, before the stream is ever wrapped or a task
281        // spawned — this mitigates (does not fully close: `permit` above is
282        // still acquired before this check runs, so >=16 distinct source
283        // IPs, or `max_connections_per_ip: None`, still reproduce the
284        // symptom) the accept-level backlog-hang, since a single stalling
285        // source IP's connections are rejected immediately by `continue`
286        // without ever reaching hyper. Only `ConnectionLimitExceeded` (the
287        // per-IP cap itself) rejects; every other error — today only
288        // `CapacityExceeded` (the limiter's own tracked-client map is full),
289        // but `RateLimitError` is not `#[non_exhaustive]` so this stays
290        // exhaustive-by-intent rather than by variant count — fails *open*
291        // instead of reusing `WebSocketRateLimiter`'s fail-closed default:
292        // at accept level, fail-closed would mean total lockout of every
293        // new source IP once the map fills, whereas the global
294        // `max_connections` semaphore already bounds the worst case.
295        let guard = match &per_ip_limiter {
296            Some(limiter) => {
297                let ip_key = accept_rate_limit_key(peer_addr.ip());
298                match RateLimitGuard::new(Arc::clone(limiter), ip_key) {
299                    Ok(guard) => Some(guard),
300                    Err(RateLimitError::ConnectionLimitExceeded { .. }) => {
301                        warn!(%peer_addr, "per-IP connection limit exceeded, dropping connection");
302                        continue;
303                    }
304                    Err(error) => {
305                        debug!(
306                            %peer_addr, %error,
307                            "per-IP rate limiter rejected connection for a reason other \
308                             than the per-IP cap; admitting"
309                        );
310                        None
311                    }
312                }
313            }
314            None => None,
315        };
316
317        let peer_service = Extension(ConnectInfo(peer_addr)).layer(router.clone());
318        let service = TowerToHyperService::new(peer_service);
319        let builder = Arc::clone(&builder);
320
321        tokio::spawn(async move {
322            let _permit = permit;
323            let _guard = guard;
324
325            // Closes the *zero-byte* gap in hyper-util's `auto::Builder`,
326            // whose `ReadVersion` preface-sniffing step runs before any
327            // protocol builder engages and has no timer of its own —
328            // `header_read_timeout` only arms once >=1 byte classifies the
329            // connection as H1, so a client that never sends anything was
330            // otherwise bounded solely by `max_connection_duration` (300s
331            // default). `readable()` is readiness-only and consumes no
332            // bytes, so hyper still sees the full stream afterward; this
333            // check is protocol-agnostic and applies to h1 and h2 alike.
334            //
335            // Residual, not fully closed: a client that sends exactly one
336            // byte matching the H2 preface, then stalls, still passes this
337            // gate (`readable()` only requires *some* data to have arrived)
338            // and rides out the full `max_connection_duration` before
339            // `ReadVersion` itself gives up — this raises the attacker's
340            // cost from 0 bytes to 1 byte, it does not add a timer to
341            // `ReadVersion`. `max_connections_per_ip` and `max_connections`
342            // still bound that window.
343            if let Some(timeout) = header_read_timeout
344                && tokio::time::timeout(timeout, stream.readable())
345                    .await
346                    .is_err()
347            {
348                debug!(%peer_addr, "no bytes received within header_read_timeout, dropping");
349                return;
350            }
351
352            let io = TokioIo::new(stream);
353            let conn = builder.serve_connection_with_upgrades(io, service);
354            let result = match max_connection_duration {
355                Some(deadline) => match tokio::time::timeout(deadline, conn).await {
356                    Ok(result) => result,
357                    Err(_elapsed) => {
358                        debug!("connection exceeded max_connection_duration, dropping");
359                        return;
360                    }
361                },
362                None => conn.await,
363            };
364            if let Err(error) = result {
365                debug!(%error, "connection closed with error");
366            }
367        });
368    }
369}
370
371/// Masks an IPv6 address down to its /64 network prefix for accept-level
372/// per-IP rate-limit keying; IPv4 addresses pass through unchanged.
373///
374/// Without this masking, an attacker holding a routed /64 (trivially
375/// available from many providers) could rotate addresses within that prefix
376/// to both bypass `ConnectionLimits::max_connections_per_ip` and exhaust
377/// `WebSocketRateLimiter`'s tracked-client map. This intentionally diverges
378/// from `RateLimitMiddleware`'s exact-IP keying, which governs a different
379/// (request-level) layer and is left unchanged.
380///
381/// Two cases are normalized *before* the /64 mask is applied, both because
382/// their top 64 bits are all zero and would otherwise collapse onto the
383/// same `::/64` key as every other such address:
384/// - **IPv4-mapped addresses** (`::ffff:a.b.c.d`) are unwrapped to their
385///   plain `IpAddr::V4` form. A dual-stack `[::]:port` listener reports
386///   every IPv4 peer this way by default on Linux (`bindv6only=0`);
387///   without unwrapping, all IPv4 traffic would share one 64-connection
388///   budget — a new DoS the accept-level cap would itself introduce.
389/// - **Loopback** (`::1`) passes through unmasked, since it is not
390///   IPv4-mapped and would otherwise mask to the same `::/64` key as the
391///   unspecified address and other zero-prefix addresses. Reachable only
392///   locally, so this is lower stakes than the IPv4-mapped case, but kept
393///   explicit rather than left as an incidental collision.
394fn accept_rate_limit_key(ip: IpAddr) -> IpAddr {
395    match ip {
396        IpAddr::V4(_) => ip,
397        IpAddr::V6(v6) => {
398            if let Some(v4) = v6.to_ipv4_mapped() {
399                return IpAddr::V4(v4);
400            }
401            if v6.is_loopback() {
402                return ip;
403            }
404            let mut octets = v6.octets();
405            octets[8..].fill(0);
406            IpAddr::V6(Ipv6Addr::from(octets))
407        }
408    }
409}
410
411/// Accept a connection, retrying on error instead of propagating it —
412/// mirrors `axum::serve`'s own [`Listener::accept`](axum::serve::Listener)
413/// behavior (`axum-0.8.9/src/serve/listener.rs`): errors in the
414/// `ConnectionRefused`/`ConnectionAborted`/`ConnectionReset` class (a peer
415/// that reset the connection between the kernel completing the handshake
416/// and userspace calling `accept()`) are retried immediately, and any other
417/// error (e.g. `EMFILE`) is logged and retried after a 1s backoff, so a
418/// single transient accept failure never kills the accept loop.
419async fn accept_with_retry(listener: &TcpListener) -> (tokio::net::TcpStream, SocketAddr) {
420    loop {
421        match listener.accept().await {
422            Ok(accepted) => return accepted,
423            Err(error) if is_connection_error(&error) => continue,
424            Err(error) => {
425                error!(%error, "accept error, retrying after backoff");
426                tokio::time::sleep(Duration::from_secs(1)).await;
427            }
428        }
429    }
430}
431
432fn is_connection_error(error: &io::Error) -> bool {
433    matches!(
434        error.kind(),
435        io::ErrorKind::ConnectionRefused
436            | io::ErrorKind::ConnectionAborted
437            | io::ErrorKind::ConnectionReset
438    )
439}
440
441#[cfg(test)]
442mod tests {
443    use std::net::Ipv4Addr;
444
445    use super::*;
446
447    #[test]
448    fn ipv4_addresses_pass_through_unmasked() {
449        let ip = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7));
450        assert_eq!(accept_rate_limit_key(ip), ip);
451    }
452
453    #[test]
454    fn ipv6_addresses_sharing_a_64_prefix_mask_to_the_same_key() {
455        let a: IpAddr = "2001:db8:1234:5678:aaaa:bbbb:cccc:dddd".parse().unwrap();
456        let b: IpAddr = "2001:db8:1234:5678:1111:2222:3333:4444".parse().unwrap();
457        assert_eq!(accept_rate_limit_key(a), accept_rate_limit_key(b));
458    }
459
460    #[test]
461    fn ipv6_addresses_with_different_64_prefixes_mask_to_different_keys() {
462        let a: IpAddr = "2001:db8:1234:5678::1".parse().unwrap();
463        let b: IpAddr = "2001:db8:1234:5679::1".parse().unwrap();
464        assert_ne!(accept_rate_limit_key(a), accept_rate_limit_key(b));
465    }
466
467    #[test]
468    fn masked_ipv6_key_zeroes_exactly_the_low_64_bits() {
469        let ip: IpAddr = "2001:db8:1234:5678:ffff:ffff:ffff:ffff".parse().unwrap();
470        let expected: IpAddr = "2001:db8:1234:5678::".parse().unwrap();
471        assert_eq!(accept_rate_limit_key(ip), expected);
472    }
473
474    /// Regression for critic finding S2: on a dual-stack `[::]:port`
475    /// listener, distinct IPv4 clients are reported as distinct
476    /// IPv4-mapped IPv6 addresses (`::ffff:a.b.c.d`), which must not
477    /// collapse onto a single shared key -- otherwise 64 connections from
478    /// one attacker would exhaust the per-IP budget for every IPv4 client
479    /// behind the dual-stack listener.
480    #[test]
481    fn ipv4_mapped_ipv6_addresses_are_not_collapsed_into_one_key() {
482        let a: IpAddr = "::ffff:203.0.113.7".parse().unwrap();
483        let b: IpAddr = "::ffff:198.51.100.99".parse().unwrap();
484        assert_ne!(
485            accept_rate_limit_key(a),
486            accept_rate_limit_key(b),
487            "distinct IPv4-mapped addresses must not share a per-IP rate-limit key"
488        );
489    }
490
491    /// Regression for critic finding S2: an IPv4-mapped address must key
492    /// the same way its plain IPv4 form would, not fall back to the
493    /// `::/64` bucket shared by loopback and other zero-prefix addresses.
494    #[test]
495    fn ipv4_mapped_ipv6_address_keys_like_its_ipv4_form() {
496        let mapped: IpAddr = "::ffff:203.0.113.7".parse().unwrap();
497        let plain = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7));
498        assert_ne!(
499            accept_rate_limit_key(mapped),
500            accept_rate_limit_key(IpAddr::V6(Ipv6Addr::UNSPECIFIED)),
501            "an IPv4-mapped address must not key as the unspecified `::/64` bucket"
502        );
503        assert_eq!(
504            accept_rate_limit_key(mapped),
505            accept_rate_limit_key(plain),
506            "an IPv4-mapped address should key identically to its plain IPv4 form"
507        );
508    }
509}