Skip to content

Commit 6f03f66

Browse files
MagicalTuxclaude
andcommitted
rt: thread the per-IP limiter through the TCP runtimes (F6)
Add Server::max_conns_per_ip (default 0 = unlimited) and wire a PeerLimiter into the threadpool, tokio, and mio runtimes plus the HTTP redirect listener. Each runtime admits on accept and holds the PeerGuard for the connection's whole lifetime (threadpool: around catch_unwind in the worker; tokio: inside the spawned task; mio: in the Conn struct so it releases on sweep/done/error). HTTP/3 is untouched (out of scope). With no cap set, behavior is unchanged. Addresses audit finding F6 (per-IP connection limits). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ef3f1b4 commit 6f03f66

4 files changed

Lines changed: 94 additions & 14 deletions

File tree

src/rt/mio.rs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,15 @@
1010
use std::collections::HashMap;
1111
use std::io::{ErrorKind, Read, Write};
1212
use std::net::SocketAddr;
13+
use std::sync::Arc;
1314
use std::time::{Duration, Instant};
1415

1516
use mio::event::Event;
1617
use mio::net::{TcpListener, TcpStream};
1718
use mio::{Events, Interest, Poll, Token};
1819

1920
use crate::error::{Error, Result};
20-
use crate::rt::common::{IO_TIMEOUT, MIN_PROGRESS, READ_BUF};
21+
use crate::rt::common::{self, IO_TIMEOUT, MIN_PROGRESS, READ_BUF};
2122
use crate::session::{Session, SessionConfig};
2223

2324
#[cfg(feature = "tls")]
@@ -50,6 +51,9 @@ struct Conn {
5051
progress_since: Instant,
5152
/// Bytes received in the current window (reset once it reaches the floor).
5253
progress_bytes: usize,
54+
/// Holds this connection's per-IP slot; releases it when the `Conn` is
55+
/// dropped (idle sweep, done, or error).
56+
_peer_guard: common::PeerGuard,
5357
}
5458

5559
impl Conn {
@@ -125,6 +129,7 @@ impl Conn {
125129
pub(crate) fn run(
126130
addrs: Vec<SocketAddr>,
127131
cfg: SessionConfig,
132+
limiter: Arc<common::PeerLimiter>,
128133
#[cfg(feature = "tls")] tls: Option<TlsAcceptor>,
129134
) -> Result<()> {
130135
let mut listener = bind_first(&addrs)?;
@@ -185,6 +190,7 @@ pub(crate) fn run(
185190
&listener,
186191
&poll,
187192
&cfg,
193+
&limiter,
188194
#[cfg(feature = "tls")]
189195
&tls,
190196
&mut conns,
@@ -241,6 +247,7 @@ fn accept_ready(
241247
listener: &TcpListener,
242248
poll: &Poll,
243249
cfg: &SessionConfig,
250+
limiter: &Arc<common::PeerLimiter>,
244251
#[cfg(feature = "tls")] tls: &Option<TlsAcceptor>,
245252
conns: &mut HashMap<Token, Conn>,
246253
next_token: &mut usize,
@@ -255,6 +262,14 @@ fn accept_ready(
255262
drop(stream);
256263
continue;
257264
}
265+
// Enforce the per-IP cap; dropping `stream` closes the connection.
266+
let guard = match limiter.admit(stream.peer_addr().ok().map(|a| a.ip())) {
267+
Some(g) => g,
268+
None => {
269+
drop(stream);
270+
continue;
271+
}
272+
};
258273
let session = match build_session(
259274
cfg,
260275
#[cfg(feature = "tls")]
@@ -285,6 +300,7 @@ fn accept_ready(
285300
read_closed: false,
286301
progress_since: Instant::now(),
287302
progress_bytes: 0,
303+
_peer_guard: guard,
288304
},
289305
);
290306
}

src/rt/mod.rs

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ pub struct Server {
6767
handler: Arc<dyn Handler>,
6868
server_name: Option<String>,
6969
workers: usize,
70+
/// Cap on concurrent connections from a single client IP (0 = unlimited).
71+
max_conns_per_ip: u32,
7072
#[cfg(feature = "tls")]
7173
tls: Option<TlsAcceptor>,
7274
#[cfg(feature = "compress")]
@@ -101,6 +103,7 @@ impl Server {
101103
handler: Arc::new(not_found),
102104
server_name: Some(concat!("httpsd/", env!("CARGO_PKG_VERSION")).to_owned()),
103105
workers: default_workers(),
106+
max_conns_per_ip: 0,
104107
#[cfg(feature = "tls")]
105108
tls: None,
106109
#[cfg(feature = "compress")]
@@ -139,6 +142,15 @@ impl Server {
139142
self
140143
}
141144

145+
/// Cap the number of concurrent connections from a single client IP (0 =
146+
/// unlimited, the default). Applies to the TCP runtimes and the HTTP redirect
147+
/// listener; a single source that exceeds the cap has further connections
148+
/// dropped so it cannot exhaust the global connection budget.
149+
pub fn max_conns_per_ip(mut self, max: u32) -> Server {
150+
self.max_conns_per_ip = max;
151+
self
152+
}
153+
142154
/// Set the `Server` response header value (`None` to omit it).
143155
pub fn server_name(mut self, name: Option<String>) -> Server {
144156
self.server_name = name;
@@ -258,27 +270,38 @@ impl Server {
258270
let listener = std::net::TcpListener::bind(self.addrs.as_slice())?;
259271
let cfg = self.session_config();
260272
let tls_mode = self.tls_mode();
273+
let limiter = common::PeerLimiter::new(self.max_conns_per_ip);
261274

262275
if !self.http_addrs.is_empty() {
263276
let http = std::net::TcpListener::bind(self.http_addrs.as_slice())?;
264277
let ctx = self.http_ctx();
265-
std::thread::spawn(move || threadpool::run_http_redirect(http, ctx));
278+
let redirect_limiter = Arc::clone(&limiter);
279+
std::thread::spawn(move || threadpool::run_http_redirect(http, ctx, redirect_limiter));
266280
}
267281

268282
// Both the main listener and (if any) the redirect listener are now
269283
// bound; the readiness signal is emitted inside `threadpool::run` right
270284
// before the accept loop.
271-
threadpool::run(listener, cfg, tls_mode, self.workers, self.ready_tx)
285+
threadpool::run(
286+
listener,
287+
cfg,
288+
tls_mode,
289+
self.workers,
290+
limiter,
291+
self.ready_tx,
292+
)
272293
}
273294

274295
/// Run on a tokio runtime. Requires being called from within a tokio
275296
/// runtime context (e.g. under `#[tokio::main]`).
276297
#[cfg(feature = "rt-tokio")]
277298
pub async fn run_tokio(self) -> Result<()> {
278299
let cfg = self.session_config();
300+
let limiter = common::PeerLimiter::new(self.max_conns_per_ip);
279301
tokio::run(
280302
self.addrs.clone(),
281303
cfg,
304+
limiter,
282305
#[cfg(feature = "tls")]
283306
self.tls,
284307
)
@@ -290,9 +313,11 @@ impl Server {
290313
#[cfg(feature = "rt-mio")]
291314
pub fn run_mio(self) -> Result<()> {
292315
let cfg = self.session_config();
316+
let limiter = common::PeerLimiter::new(self.max_conns_per_ip);
293317
mio::run(
294318
self.addrs.clone(),
295319
cfg,
320+
limiter,
296321
#[cfg(feature = "tls")]
297322
self.tls,
298323
)

src/rt/threadpool.rs

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,17 @@ struct Shared {
2626
tls: TlsMode,
2727
}
2828

29+
/// An accepted connection queued to a worker, paired with the per-IP slot guard
30+
/// that must live for the connection's whole lifetime.
31+
type Job = (TcpStream, common::PeerGuard);
32+
2933
/// Run a blocking accept loop, dispatching connections to `workers` threads.
3034
pub(crate) fn run(
3135
listener: TcpListener,
3236
cfg: SessionConfig,
3337
tls: TlsMode,
3438
workers: usize,
39+
limiter: Arc<common::PeerLimiter>,
3540
ready: Option<Sender<()>>,
3641
) -> Result<()> {
3742
// On-demand ACME (TLS-ALPN-01) can self-deadlock with a single worker: the
@@ -55,8 +60,7 @@ pub(crate) fn run(
5560
// the queue is full `send` blocks the accept loop; further connections wait
5661
// in the kernel backlog and are shed there if it too fills.
5762
let backlog = workers.saturating_mul(64).clamp(256, 4096);
58-
let (tx, rx): (SyncSender<TcpStream>, Receiver<TcpStream>) =
59-
std::sync::mpsc::sync_channel(backlog);
63+
let (tx, rx): (SyncSender<Job>, Receiver<Job>) = std::sync::mpsc::sync_channel(backlog);
6064
let rx = Arc::new(Mutex::new(rx));
6165

6266
for _ in 0..workers {
@@ -75,7 +79,14 @@ pub(crate) fn run(
7579
for incoming in listener.incoming() {
7680
match incoming {
7781
Ok(stream) => {
78-
if tx.send(stream).is_err() {
82+
// Enforce the per-IP cap before queueing. `continue` drops
83+
// `stream`, closing the over-limit connection (shed silently,
84+
// like the global caps).
85+
let guard = match limiter.admit(stream.peer_addr().ok().map(|a| a.ip())) {
86+
Some(g) => g,
87+
None => continue,
88+
};
89+
if tx.send((stream, guard)).is_err() {
7990
break;
8091
}
8192
}
@@ -89,18 +100,22 @@ pub(crate) fn run(
89100
Ok(())
90101
}
91102

92-
fn worker_loop(rx: Arc<Mutex<Receiver<TcpStream>>>, shared: Arc<Shared>) {
103+
fn worker_loop(rx: Arc<Mutex<Receiver<Job>>>, shared: Arc<Shared>) {
93104
loop {
94-
let stream = {
95-
let guard = match rx.lock() {
105+
let received = {
106+
let lock = match rx.lock() {
96107
Ok(g) => g,
97108
Err(_) => return,
98109
};
99-
guard.recv()
110+
lock.recv()
100111
};
101-
let Ok(stream) = stream else {
112+
let Ok((stream, guard)) = received else {
102113
return;
103114
};
115+
// Hold the per-IP slot for the whole connection: keep `guard` alive
116+
// across `catch_unwind` and drop it (releasing the slot) only after the
117+
// handler returns — even if it panics.
118+
let _guard = guard;
104119
// Isolate each connection: a panic while serving one client must not
105120
// kill the worker thread, which would permanently shrink the pool and,
106121
// once every worker died, wedge the whole server.
@@ -167,7 +182,11 @@ fn handle_acme(
167182
/// Accept loop for the plain-HTTP listener (redirects + ACME HTTP-01). It spawns
168183
/// a thread per connection — these are short-lived (read a request, reply, close)
169184
/// — but caps how many run at once so a flood can't spawn unbounded threads.
170-
pub(crate) fn run_http_redirect(listener: TcpListener, ctx: HttpCtx) {
185+
pub(crate) fn run_http_redirect(
186+
listener: TcpListener,
187+
ctx: HttpCtx,
188+
limiter: Arc<common::PeerLimiter>,
189+
) {
171190
/// Maximum redirect connections served concurrently; excess is shed.
172191
const MAX_INFLIGHT: usize = 256;
173192

@@ -178,6 +197,11 @@ pub(crate) fn run_http_redirect(listener: TcpListener, ctx: HttpCtx) {
178197
Ok(mut stream) => {
179198
common::apply_timeouts(&stream);
180199
stream.set_nodelay(true).ok();
200+
// Enforce the per-IP cap; `continue` drops the connection.
201+
let guard = match limiter.admit(stream.peer_addr().ok().map(|a| a.ip())) {
202+
Some(g) => g,
203+
None => continue,
204+
};
181205
// Shed load past the cap by closing the connection (drop).
182206
if inflight.fetch_add(1, Ordering::Relaxed) >= MAX_INFLIGHT {
183207
inflight.fetch_sub(1, Ordering::Relaxed);
@@ -186,6 +210,8 @@ pub(crate) fn run_http_redirect(listener: TcpListener, ctx: HttpCtx) {
186210
let ctx = Arc::clone(&ctx);
187211
let inflight = Arc::clone(&inflight);
188212
thread::spawn(move || {
213+
// Hold the per-IP slot until the redirect finishes.
214+
let _guard = guard;
189215
if let Err(e) = redirect::serve(&mut stream, &ctx)
190216
&& cfg!(debug_assertions)
191217
{

src/rt/tokio.rs

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt};
1414
use tokio::net::{TcpListener, TcpStream};
1515

1616
use crate::error::{Error, Result};
17-
use crate::rt::common::{IO_TIMEOUT, MIN_PROGRESS, READ_BUF};
17+
use crate::rt::common::{self, IO_TIMEOUT, MIN_PROGRESS, READ_BUF};
1818
use crate::session::{Session, SessionConfig};
1919

2020
#[cfg(feature = "tls")]
@@ -39,6 +39,7 @@ struct Shared {
3939
pub(crate) async fn run(
4040
addrs: Vec<SocketAddr>,
4141
cfg: SessionConfig,
42+
limiter: Arc<common::PeerLimiter>,
4243
#[cfg(feature = "tls")] tls: Option<TlsAcceptor>,
4344
) -> Result<()> {
4445
let listener = bind_first(&addrs).await?;
@@ -51,15 +52,27 @@ pub(crate) async fn run(
5152

5253
loop {
5354
match listener.accept().await {
54-
Ok((stream, _peer)) => {
55+
Ok((stream, peer)) => {
5556
// Shed load past the global cap by dropping the connection.
5657
if shared.inflight.fetch_add(1, Ordering::Relaxed) >= MAX_INFLIGHT {
5758
shared.inflight.fetch_sub(1, Ordering::Relaxed);
5859
drop(stream);
5960
continue;
6061
}
62+
// Enforce the per-IP cap. On rejection, release the inflight
63+
// slot we just claimed and drop the connection.
64+
let guard = match limiter.admit(Some(peer.ip())) {
65+
Some(g) => g,
66+
None => {
67+
shared.inflight.fetch_sub(1, Ordering::Relaxed);
68+
drop(stream);
69+
continue;
70+
}
71+
};
6172
let shared = Arc::clone(&shared);
6273
tokio::spawn(async move {
74+
// Hold the per-IP slot for the whole connection.
75+
let _guard = guard;
6376
let outcome = serve(stream, &shared).await;
6477
shared.inflight.fetch_sub(1, Ordering::Relaxed);
6578
if cfg!(debug_assertions)

0 commit comments

Comments
 (0)