netget/server/accept_bounded.rs
1//! A connection cap for accept loops, and the refusal that says why.
2//!
3//! # The problem this closes
4//!
5//! Every TCP accept loop in this tree used to accept without limit. A peer that connects and
6//! says nothing holds a socket, a task and an [`AppState`](crate::state::app_state::AppState)
7//! connection entry for as long as it likes; a thousand of them is a free denial of service,
8//! pre-authentication, on a server that will happily accept a thousand more. Per-connection
9//! read timeouts bound how long *one* peer can stall, and are the other half of this — but a
10//! timeout alone still lets an attacker hold `timeout × rate` connections at once. Only a cap
11//! turns "bounded per connection" into "bounded".
12//!
13//! # Why it refuses out loud
14//!
15//! A cap that silently drops the socket is worse than no cap at all: the client cannot tell a
16//! full server from a crashed one, so it retries immediately and forever, and the operator
17//! sees an outage with no cause. So [`accept_bounded`] writes the caller's own bytes before
18//! closing, and each protocol supplies the phrasing its *own* clients already understand —
19//! MySQL error 1040, RESP `-ERR max number of clients reached`, HTTP `503`, and so on. The
20//! bytes live in each protocol's `mod.rs`, not here: a table of per-protocol literals in a
21//! shared module would be exactly the centralised per-protocol logic this codebase forbids.
22//! This module knows only "some bytes, or none".
23//!
24//! Where a protocol has no way to say it — the peer speaks first and every server message is a
25//! positive assertion — the right answer is an empty slice and a plain close, for the same
26//! reason twenty protocols are deliberately silent on an LLM failure: a fabricated reply is
27//! worse than silence. The refusal is still logged, and the log line is the diagnosis.
28//!
29//! # Usage
30//!
31//! ```ignore
32//! const MAX_CONNECTIONS: usize = accept_bounded::DEFAULT_MAX_CONNECTIONS;
33//! const BUSY: &[u8] = b"-ERR max number of clients reached\r\n";
34//!
35//! let limiter = ConnectionLimiter::new(MAX_CONNECTIONS);
36//! loop {
37//! let (socket, peer, permit) =
38//! match accept_bounded(&listener, &limiter, BUSY, "REDIS", Some(&status_tx)).await {
39//! Ok(v) => v,
40//! Err(e) => { /* the listener itself failed; break */ }
41//! };
42//! tokio::spawn(async move {
43//! let _permit = permit; // held for the life of the connection
44//! handle(socket).await;
45//! });
46//! }
47//! ```
48//!
49//! [`accept_bounded`] never returns a refused connection: it loops internally, so the caller's
50//! loop only ever sees peers that were admitted. The [`ConnectionPermit`] it returns must be
51//! moved into the connection task — dropping it early releases the slot while the connection
52//! is still live, which silently un-caps the server.
53
54use crate::logging::emit::Log;
55use std::net::SocketAddr;
56use std::sync::Arc;
57use tokio::io::AsyncWriteExt;
58use tokio::net::{TcpListener, TcpStream};
59use tokio::sync::{mpsc, OwnedSemaphorePermit, Semaphore};
60
61/// Concurrent connections a server admits before it starts refusing.
62///
63/// 256, which is what `src/server/nfs/guard.rs` chose on its own reasoning and the only number
64/// in this tree that was ever argued for: each admitted connection costs a bounded amount of
65/// memory, so the cap is what multiplies that bound into a total. It is deliberately generous
66/// — far above anything a human operator or a test harness produces, and far below what an
67/// attacker needs for the socket exhaustion to matter. A protocol whose per-connection cost is
68/// unusually high (a large read buffer, a long-lived session) declares its own smaller value
69/// and says why in the comment; one that multiplexes everything over few connections may
70/// declare a smaller one still.
71///
72/// This is not a tuning knob exposed to the model or the operator: a bound decided by
73/// configuration is a bound an attacker can ask you to raise.
74pub const DEFAULT_MAX_CONNECTIONS: usize = 256;
75
76/// How long a refusal is given to reach the peer before the socket is dropped anyway.
77///
78/// A peer that has filled our send buffer and stopped reading must not be able to hold the
79/// *refusal* open either — that would turn the cap into the same resource hole it closes.
80const REFUSAL_WRITE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
81
82/// Tracks how many connections a server currently has admitted.
83///
84/// Cheap to clone; every clone refers to the same count.
85#[derive(Clone, Debug)]
86pub struct ConnectionLimiter {
87 slots: Arc<Semaphore>,
88 max: usize,
89}
90
91impl ConnectionLimiter {
92 /// A limiter admitting at most `max` connections at once.
93 ///
94 /// `max` of zero would refuse everything, which is never what a caller means, so it is
95 /// clamped to one.
96 pub fn new(max: usize) -> Self {
97 let max = max.max(1);
98 Self {
99 slots: Arc::new(Semaphore::new(max)),
100 max,
101 }
102 }
103
104 /// The cap this limiter was built with, for log lines and tests.
105 pub fn max(&self) -> usize {
106 self.max
107 }
108
109 /// Connections currently admitted.
110 pub fn in_use(&self) -> usize {
111 self.max - self.slots.available_permits()
112 }
113
114 /// Take a slot, or `None` if the server is at its cap.
115 ///
116 /// Never waits: queueing a peer that is over the cap is just a slower way of holding the
117 /// resource it was refused.
118 pub fn try_acquire(&self) -> Option<ConnectionPermit> {
119 Arc::clone(&self.slots)
120 .try_acquire_owned()
121 .ok()
122 .map(|permit| ConnectionPermit { _permit: permit })
123 }
124}
125
126/// One admitted connection's slot. Released when dropped, so it must live as long as the
127/// connection task does.
128#[derive(Debug)]
129pub struct ConnectionPermit {
130 _permit: OwnedSemaphorePermit,
131}
132
133/// Accept the next connection the server has room for, refusing the ones it does not.
134///
135/// Returns only admitted peers. A peer over the cap is answered with `refusal` (skipped when
136/// empty), logged at WARN with `decision=fail_closed_connection_cap`, closed, and never seen
137/// by the caller.
138///
139/// `Err` means the *listener* failed, which is the caller's signal to break its accept loop —
140/// exactly the errors `TcpListener::accept` already returns.
141pub async fn accept_bounded(
142 listener: &TcpListener,
143 limiter: &ConnectionLimiter,
144 refusal: &[u8],
145 protocol: &str,
146 status_tx: Option<&mpsc::UnboundedSender<String>>,
147) -> std::io::Result<(TcpStream, SocketAddr, ConnectionPermit)> {
148 loop {
149 let (socket, peer_addr) = listener.accept().await?;
150
151 match limiter.try_acquire() {
152 Some(permit) => return Ok((socket, peer_addr, permit)),
153 None => {
154 Log::new(status_tx).warn(format!(
155 "{} connection from {} refused decision=fail_closed_connection_cap \
156 (limit {} concurrent)",
157 protocol,
158 peer_addr,
159 limiter.max()
160 ));
161 refuse(socket, refusal).await;
162 }
163 }
164 }
165}
166
167/// [`accept_bounded`] for a Unix domain socket listener (`socket_file`, `ssh_agent`).
168///
169/// A filesystem socket is exposed to every local process that can write the node, and a peer
170/// that connects and says nothing holds a task and an `AppState` entry exactly as a TCP peer
171/// does, so it needs the same cap. There is no peer address to log, so the refusal line names
172/// the listener's own path instead.
173#[cfg(unix)]
174pub async fn accept_bounded_unix(
175 listener: &tokio::net::UnixListener,
176 limiter: &ConnectionLimiter,
177 refusal: &[u8],
178 protocol: &str,
179 status_tx: Option<&mpsc::UnboundedSender<String>>,
180) -> std::io::Result<(tokio::net::UnixStream, ConnectionPermit)> {
181 loop {
182 let (socket, _) = listener.accept().await?;
183
184 match limiter.try_acquire() {
185 Some(permit) => return Ok((socket, permit)),
186 None => {
187 let path = listener
188 .local_addr()
189 .ok()
190 .and_then(|a| a.as_pathname().map(|p| p.display().to_string()))
191 .unwrap_or_else(|| "<unnamed>".to_string());
192 Log::new(status_tx).warn(format!(
193 "{} connection on {} refused decision=fail_closed_connection_cap \
194 (limit {} concurrent)",
195 protocol,
196 path,
197 limiter.max()
198 ));
199 refuse(socket, refusal).await;
200 }
201 }
202 }
203}
204
205/// Write the protocol's "busy" bytes and close.
206///
207/// Both halves are best-effort and time-bounded: the peer is already being refused, so nothing
208/// here may block the accept loop. Bytes first, then an explicit shutdown so the peer reads the
209/// refusal followed by a clean EOF rather than a reset that discards it.
210async fn refuse<S: tokio::io::AsyncWrite + Unpin>(mut socket: S, refusal: &[u8]) {
211 let _ = tokio::time::timeout(REFUSAL_WRITE_TIMEOUT, async {
212 if !refusal.is_empty() {
213 let _ = socket.write_all(refusal).await;
214 let _ = socket.flush().await;
215 }
216 let _ = socket.shutdown().await;
217 })
218 .await;
219}
220
221/// Bound the upload tail discarded after an HTTP response has been flushed and the write
222/// side half-closed. Two MiB covers the one-MiB overrun exercised by real HTTP clients,
223/// including framing; two seconds also bounds a silent peer. Nothing is buffered or modelled.
224const RESPONSE_DRAIN_BYTES: usize = 2 * 1024 * 1024;
225const RESPONSE_DRAIN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
226
227/// Discard an in-flight upload tail after flushing the response and half-closing writes.
228///
229/// Dropping a TCP socket while unread upload bytes remain can send RST and erase a 413
230/// before the client reads it. The absolute time and byte budgets let an ordinary upload
231/// finish without allowing an endless or silent peer to keep the connection task alive.
232/// This runs only after the HTTP connection completes, outside live handler work.
233pub(crate) async fn drain_after_response(reader: &mut (impl AsyncRead + Unpin)) {
234 use tokio::io::AsyncReadExt;
235
236 let deadline = tokio::time::Instant::now() + RESPONSE_DRAIN_TIMEOUT;
237 let mut remaining = RESPONSE_DRAIN_BYTES;
238 let mut scratch = [0u8; 8192];
239 while remaining > 0 {
240 let read_size = remaining.min(scratch.len());
241 match tokio::time::timeout_at(deadline, reader.read(&mut scratch[..read_size])).await {
242 Ok(Ok(n)) if n > 0 => remaining -= n,
243 _ => break,
244 }
245 }
246}
247
248// ---------------------------------------------------------------------------------------
249// Read deadlines where the protocol's loop is not ours
250// ---------------------------------------------------------------------------------------
251//
252// Most servers here own their read loop, and the right shape there is the one
253// `src/server/whois/mod.rs` uses: wrap the `read()` — and *only* the `read()` — in
254// `tokio::time::timeout`, so the model round-trip and a `manual` rule parking an event for a
255// human sit outside the deadline by construction. Two protocols hand their socket to a crate
256// that owns the loop instead (`msql-srv`, `pgwire`), and the two types below are how they get
257// the same guarantee.
258
259use std::future::Future;
260use std::pin::Pin;
261use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
262use std::task::{Context, Poll};
263use std::time::Duration;
264use tokio::io::{AsyncRead, ReadBuf};
265
266/// An [`AsyncRead`] that fails with [`std::io::ErrorKind::TimedOut`] when the peer sends
267/// nothing for `idle`.
268///
269/// This exists for a crate that takes a generic reader and runs the protocol loop itself, where
270/// there is no `read()` call of ours to wrap.
271///
272/// **Why it cannot evict a live-but-slow operation.** The deadline is armed lazily, when a read
273/// is polled and finds nothing, and disarmed the moment bytes arrive. While the crate is off
274/// doing something else with what it read — an LLM round-trip, a `manual` event parked for a
275/// human at the dashboard — this reader is not being polled at all, so no clock is running. The
276/// next poll after that work finishes arms a *fresh* deadline rather than inheriting a stale
277/// one. That lazy arming is the whole correctness argument: a version that reset the deadline
278/// only on a successful read would fire immediately after any pause longer than `idle`, which
279/// is exactly the live-transfer eviction this project already learned about from TFTP.
280pub struct IdleTimeoutReader<R> {
281 inner: R,
282 /// Bound until the peer's first byte. Kept separate because "has said nothing at all" and
283 /// "has gone quiet mid-session" deserve different answers — the whois pair, expressed for a
284 /// loop that is not ours.
285 first: Duration,
286 idle: Duration,
287 seen_bytes: bool,
288 /// Armed only while a read is outstanding with no bytes yet.
289 deadline: Option<Pin<Box<tokio::time::Sleep>>>,
290}
291
292impl<R> IdleTimeoutReader<R> {
293 /// One bound for every read.
294 pub fn new(inner: R, idle: Duration) -> Self {
295 Self::with_first(inner, idle, idle)
296 }
297
298 /// `first` until the peer's first byte arrives, `idle` for every read after that.
299 pub fn with_first(inner: R, first: Duration, idle: Duration) -> Self {
300 Self {
301 inner,
302 first,
303 idle,
304 seen_bytes: false,
305 deadline: None,
306 }
307 }
308}
309
310impl<R: AsyncRead + Unpin> AsyncRead for IdleTimeoutReader<R> {
311 fn poll_read(
312 self: Pin<&mut Self>,
313 cx: &mut Context<'_>,
314 buf: &mut ReadBuf<'_>,
315 ) -> Poll<std::io::Result<()>> {
316 let this = self.get_mut();
317 let before = buf.filled().len();
318 match Pin::new(&mut this.inner).poll_read(cx, buf) {
319 Poll::Ready(result) => {
320 // Bytes (or EOF, or an error): whatever happens next is not this reader's
321 // business, so disarm.
322 this.deadline = None;
323 if result.is_ok() && buf.filled().len() > before {
324 this.seen_bytes = true;
325 }
326 Poll::Ready(result)
327 }
328 Poll::Pending => {
329 let bound = if this.seen_bytes {
330 this.idle
331 } else {
332 this.first
333 };
334 let deadline = this
335 .deadline
336 .get_or_insert_with(|| Box::pin(tokio::time::sleep(bound)));
337 match deadline.as_mut().poll(cx) {
338 Poll::Ready(()) => {
339 this.deadline = None;
340 Poll::Ready(Err(std::io::Error::new(
341 std::io::ErrorKind::TimedOut,
342 "peer sent nothing before the idle deadline",
343 )))
344 }
345 Poll::Pending => Poll::Pending,
346 }
347 }
348 }
349 }
350}
351
352/// Whether a connection whose loop belongs to a crate is idle, and since when.
353///
354/// [`IdleTimeoutReader`] is the better answer wherever the crate takes a generic reader.
355/// `pgwire` does not — `process_socket` takes a concrete `TcpStream` — so PostgreSQL bounds its
356/// idle time from the outside instead, with a watchdog reading this.
357///
358/// The `in_flight` counter is what keeps that watchdog honest. A connection parked on an LLM
359/// call, or on a `manual` rule waiting for a human, is *working*, not idle, and must never be
360/// closed from under itself; the handler raises the counter for the whole of that work, and
361/// [`watch_idle`] refuses to fire while it is non-zero. Wall-clock alone would have made the
362/// bound "longer than the longest park anyone configures", which is a guess rather than a rule.
363#[derive(Debug)]
364pub struct ConnectionActivity {
365 /// Milliseconds since this process's clock epoch at the last sign of life.
366 last: AtomicU64,
367 /// Operations currently being answered on this connection.
368 in_flight: AtomicUsize,
369}
370
371impl Default for ConnectionActivity {
372 fn default() -> Self {
373 Self::new()
374 }
375}
376
377impl ConnectionActivity {
378 pub fn new() -> Self {
379 Self {
380 last: AtomicU64::new(now_millis()),
381 in_flight: AtomicUsize::new(0),
382 }
383 }
384
385 /// Note that something crossed the connection.
386 pub fn touch(&self) {
387 self.last.store(now_millis(), Ordering::Relaxed);
388 }
389
390 /// Mark the start of work the peer is waiting on. Pair with [`Self::end_work`].
391 pub fn begin_work(&self) {
392 self.touch();
393 self.in_flight.fetch_add(1, Ordering::SeqCst);
394 }
395
396 /// Mark the end of that work, and treat its completion as activity.
397 pub fn end_work(&self) {
398 self.in_flight.fetch_sub(1, Ordering::SeqCst);
399 self.touch();
400 }
401
402 /// Mark the connection busy until the returned guard is dropped.
403 ///
404 /// Preferred over a bare [`Self::begin_work`]/[`Self::end_work`] pair anywhere the work has
405 /// more than one exit — a `?`, an early `return`, an unwinding panic. Leaking the mark
406 /// pins the connection "busy" forever and silently disables the idle bound, which is the
407 /// worst possible failure for a guard nobody is looking at.
408 pub fn busy(self: &Arc<Self>) -> BusyGuard {
409 self.begin_work();
410 BusyGuard(Arc::clone(self))
411 }
412
413 /// How long the connection has been doing nothing at all. `None` while work is in flight,
414 /// which is the whole point: busy is not idle.
415 pub fn idle_for(&self) -> Option<Duration> {
416 if self.in_flight.load(Ordering::SeqCst) > 0 {
417 return None;
418 }
419 let last = self.last.load(Ordering::Relaxed);
420 Some(Duration::from_millis(now_millis().saturating_sub(last)))
421 }
422}
423
424/// Holds a connection "busy" for its lifetime. See [`ConnectionActivity::busy`].
425pub struct BusyGuard(Arc<ConnectionActivity>);
426
427impl Drop for BusyGuard {
428 fn drop(&mut self) {
429 self.0.end_work();
430 }
431}
432
433/// Watch a [`ConnectionActivity`] and resolve once it has been idle for `idle`.
434///
435/// Returns as soon as the bound is exceeded; the caller decides what to do about it (PostgreSQL
436/// aborts the connection task). This polls rather than waking on an event because the thing
437/// being watched is the *absence* of events, and a coarse tick costs nothing when the bound is
438/// minutes.
439pub async fn watch_idle(activity: Arc<ConnectionActivity>, idle: Duration) {
440 let tick = (idle / 20).clamp(Duration::from_millis(250), Duration::from_secs(15));
441 loop {
442 tokio::time::sleep(tick).await;
443 if let Some(elapsed) = activity.idle_for() {
444 if elapsed >= idle {
445 return;
446 }
447 }
448 }
449}
450
451/// [`watch_idle`] for a session protocol whose client may legitimately say nothing for a long
452/// time: resolve once `activity` has been idle for `idle`, having called `probe` once at half
453/// of it.
454///
455/// This is the shape for WebSocket-carried servers (`websocket`, `webrtc`, `webrtc_signaling`):
456/// the probe queues a Ping, every RFC 6455 endpoint answers a Ping with a Pong by itself, and the
457/// caller touches `activity` on every inbound frame — so a live-but-silent client is never
458/// closed, and what reaches the bound is a peer that has stopped reading or vanished without a
459/// FIN. The bound is on liveness, not on conversation.
460///
461/// The probe fires once per silent stretch, not per tick: any activity starts a new stretch, and
462/// a busy connection (work in flight) is neither probed nor reported idle.
463pub async fn watch_idle_with_probe<F: FnMut()>(
464 activity: Arc<ConnectionActivity>,
465 idle: Duration,
466 mut probe: F,
467) {
468 let tick = (idle / 20).clamp(Duration::from_millis(100), Duration::from_secs(15));
469 let mut probed = false;
470 loop {
471 tokio::time::sleep(tick).await;
472 match activity.idle_for() {
473 Some(elapsed) if elapsed >= idle => return,
474 Some(elapsed) if elapsed >= idle / 2 => {
475 if !probed {
476 probe();
477 probed = true;
478 }
479 }
480 // Busy, or heard from recently: the next silent stretch gets its own probe.
481 _ => probed = false,
482 }
483 }
484}
485
486/// The payload of the keepalive Ping the WebSocket-carried servers send through
487/// [`watch_idle_with_probe`]. A Pong echoes it (RFC 6455 §5.5.3); it is not checked, because any
488/// frame is proof of life, but it makes the Ping identifiable in a capture.
489pub const KEEPALIVE_PING_PAYLOAD: &[u8] = b"netget-keepalive";
490
491/// Milliseconds on a monotonic clock. `utils::clock` rather than `std::time::Instant`, which
492/// panics on wasm32 (see the project CLAUDE.md).
493fn now_millis() -> u64 {
494 crate::utils::clock::Instant::now()
495 .duration_since(*PROCESS_START)
496 .as_millis() as u64
497}
498
499static PROCESS_START: std::sync::LazyLock<crate::utils::clock::Instant> =
500 std::sync::LazyLock::new(crate::utils::clock::Instant::now);
501
502#[cfg(test)]
503mod response_drain_tests {
504 use super::*;
505
506 #[tokio::test]
507 async fn response_drain_stops_at_the_byte_budget() {
508 let bytes = vec![0; RESPONSE_DRAIN_BYTES + 1];
509 let mut reader = bytes.as_slice();
510 drain_after_response(&mut reader).await;
511 assert_eq!(
512 reader.len(),
513 1,
514 "an endless uploader cannot keep the task alive"
515 );
516 }
517
518 #[tokio::test]
519 async fn response_drain_stops_even_when_the_peer_keeps_its_write_half_open() {
520 let (mut reader, _silent_peer) = tokio::io::duplex(1);
521 let started = tokio::time::Instant::now();
522 tokio::time::timeout(
523 RESPONSE_DRAIN_TIMEOUT + std::time::Duration::from_secs(5),
524 drain_after_response(&mut reader),
525 )
526 .await
527 .expect("a silent peer cannot keep the task alive");
528 assert!(started.elapsed() >= RESPONSE_DRAIN_TIMEOUT);
529 }
530}