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}