fix: sliding-window rate limits and TCP connection cap

This commit is contained in:
randogoth 2026-10-06 22:13:59 +03:00
parent 0c7311b068
commit fd847d035f
7 changed files with 198 additions and 48 deletions

View file

@ -34,7 +34,7 @@ Add bunshin as a flake input and import the module:
} }
``` ```
`services.bunshin` also takes `host`, `port`, `dataDir`, `maxEnvelope`, `quota`, `requestsQuota`, `retentionDays`, `requestsRetentionDays`, `maxTokens`, `rateConnections`, `rateSends`, `rateTokens` and `domains`; see `flake.nix` for defaults. The module renders a `systemd` unit that runs `bunshin serve` under `DynamicUser`; it does not generate a key. `services.bunshin` also takes `host`, `port`, `dataDir`, `maxEnvelope`, `quota`, `requestsQuota`, `retentionDays`, `requestsRetentionDays`, `maxTokens`, `rateConnections`, `rateSends`, `rateTokens`, `maxConnections` and `domains`; see `flake.nix` for defaults. The module renders a `systemd` unit that runs `bunshin serve` under `DynamicUser`; it does not generate a key.
For the RNS carrier, build `packages.rns`, set `services.bunshin.package` to it, and enable `services.bunshin.rns` with its `keyFile`; see [RNS.md](RNS.md) for the protocol and the remaining options. For the RNS carrier, build `packages.rns`, set `services.bunshin.package` to it, and enable `services.bunshin.rns` with its `keyFile`; see [RNS.md](RNS.md) for the protocol and the remaining options.
@ -115,7 +115,7 @@ bunshin keygen --key server.key
bunshin serve --key server.key --db mail.db --host 0.0.0.0 --port 1961 bunshin serve --key server.key --db mail.db --host 0.0.0.0 --port 1961
``` ```
`serve` accepts `--max-envelope`, `--quota`, `--requests-quota`, `--retention-days`, `--requests-retention-days`, `--max-tokens`, `--invite-token`, `--rate-connections`, `--rate-sends` and `--rate-tokens` to control size limits, the mailbox's two quota tiers, their retention, the accept-token cap, registration gating and abuse control. Run `bunshin serve --help` for defaults. `serve` accepts `--max-envelope`, `--quota`, `--requests-quota`, `--retention-days`, `--requests-retention-days`, `--max-tokens`, `--invite-token`, `--rate-connections`, `--rate-sends`, `--rate-tokens` and `--max-connections` to control size limits, the mailbox's two quota tiers, their retention, the accept-token cap, registration gating, abuse control and the concurrent-connection cap. Rate limits are sliding-window: hits age out 60 s after they happen, so a burst straddling a window boundary cannot exceed the configured rate. Connections past `--max-connections` (default 100, 0 = unlimited) are refused, mirroring the RNS carrier's link cap. Run `bunshin serve --help` for defaults.
`--verbose` (or `services.bunshin.verbose` in the NixOS module) raises logging to debug level, which adds server-side metrics on both carriers: per-operation timing, request and response sizes, handshake duration, per-session summaries, and RNS link events. The default info level stays quiet on success. `--verbose` (or `services.bunshin.verbose` in the NixOS module) raises logging to debug level, which adds server-side metrics on both carriers: per-operation timing, request and response sizes, handshake duration, per-session summaries, and RNS link events. The default info level stays quiet on success.

2
RNS.md
View file

@ -16,7 +16,7 @@ Implemented behind the `rns` cargo feature (protocol 1.2; the default build stil
| Ops, status codes, envelope | §6, §12 | identical | | Ops, status codes, envelope | §6, §12 | identical |
| AUTH / REGISTER binding | Noise handshake hash, server static key | derived, see §2 | | AUTH / REGISTER binding | Noise handshake hash, server static key | derived, see §2 |
| Announce | — | at startup and every 2 h; interfaces rate-limit to ≈1/hour, which is the ceiling | | Announce | — | at startup and every 2 h; interfaces rate-limit to ≈1/hour, which is the ceiling |
| Abuse control | per-IP rate limits | per-link request + byte limits, concurrent-link cap | | Abuse control | per-IP sliding-window rate limits, concurrent-connection cap | per-link request + byte limits, concurrent-link cap |
One wire-format detail the upstream spec leaves implicit: microReticulum splices the request payload and the response into their msgpack envelopes verbatim, so both directions carry the smolmail payload as a msgpack binary. The shim unpacks on the way in and packs on the way out; the Rust side only ever sees `op u8 || body` and `status u8 || payload`. One wire-format detail the upstream spec leaves implicit: microReticulum splices the request payload and the response into their msgpack envelopes verbatim, so both directions carry the smolmail payload as a msgpack binary. The shim unpacks on the way in and packs on the way out; the Rust side only ever sees `op u8 || body` and `status u8 || payload`.

View file

@ -323,6 +323,12 @@
description = "Max SEND operations per minute, per accept token."; description = "Max SEND operations per minute, per accept token.";
}; };
maxConnections = mkOption {
type = types.ints.unsigned;
default = 100;
description = "Max concurrently served TCP connections; past the cap connections are refused. 0 means unlimited.";
};
inviteToken = mkOption { inviteToken = mkOption {
type = types.nullOr types.str; type = types.nullOr types.str;
default = null; default = null;
@ -476,6 +482,7 @@
rate_connections = cfg.rateConnections; rate_connections = cfg.rateConnections;
rate_sends = cfg.rateSends; rate_sends = cfg.rateSends;
rate_tokens = cfg.rateTokens; rate_tokens = cfg.rateTokens;
max_connections = cfg.maxConnections;
} }
// lib.optionalAttrs (cfg.inviteToken != null) { invite_token = cfg.inviteToken; } // lib.optionalAttrs (cfg.inviteToken != null) { invite_token = cfg.inviteToken; }
// lib.optionalAttrs (cfg.inviteTokenFile != null) { // lib.optionalAttrs (cfg.inviteTokenFile != null) {
@ -523,6 +530,7 @@
--rate-connections ${toString cfg.rateConnections} --rate-connections ${toString cfg.rateConnections}
--rate-sends ${toString cfg.rateSends} --rate-sends ${toString cfg.rateSends}
--rate-tokens ${toString cfg.rateTokens} --rate-tokens ${toString cfg.rateTokens}
--max-connections ${toString cfg.maxConnections}
) )
${lib.optionalString cfg.rns.enable '' ${lib.optionalString cfg.rns.enable ''
args+=( args+=(

View file

@ -25,6 +25,7 @@ const DEFAULT_MAX_TOKENS: u16 = 1024;
const DEFAULT_RATE_CONNECTIONS: u32 = 120; const DEFAULT_RATE_CONNECTIONS: u32 = 120;
const DEFAULT_RATE_SENDS: u32 = 60; const DEFAULT_RATE_SENDS: u32 = 60;
const DEFAULT_RATE_TOKENS: u32 = 30; const DEFAULT_RATE_TOKENS: u32 = 30;
const DEFAULT_MAX_CONNECTIONS: usize = 100;
/// One resolved domain, ready to serve. /// One resolved domain, ready to serve.
pub struct DomainConfig { pub struct DomainConfig {
@ -43,6 +44,7 @@ pub struct DomainConfig {
pub rate_connections: u32, pub rate_connections: u32,
pub rate_sends: u32, pub rate_sends: u32,
pub rate_tokens: u32, pub rate_tokens: u32,
pub max_connections: usize,
} }
/// The union of every setting either table accepts; `deny_unknown_fields` /// The union of every setting either table accepts; `deny_unknown_fields`
@ -67,6 +69,7 @@ struct Table {
rate_connections: Option<u32>, rate_connections: Option<u32>,
rate_sends: Option<u32>, rate_sends: Option<u32>,
rate_tokens: Option<u32>, rate_tokens: Option<u32>,
max_connections: Option<usize>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@ -187,6 +190,10 @@ pub fn load(path: &str) -> anyhow::Result<Vec<DomainConfig>> {
.rate_tokens .rate_tokens
.or(file.defaults.rate_tokens) .or(file.defaults.rate_tokens)
.unwrap_or(DEFAULT_RATE_TOKENS), .unwrap_or(DEFAULT_RATE_TOKENS),
max_connections: table
.max_connections
.or(file.defaults.max_connections)
.unwrap_or(DEFAULT_MAX_CONNECTIONS),
}); });
} }
Ok(domains) Ok(domains)
@ -218,6 +225,7 @@ mod tests {
assert_eq!(d.max_envelope, 768 << 10); assert_eq!(d.max_envelope, 768 << 10);
assert_eq!(d.quota, 64 << 20); assert_eq!(d.quota, 64 << 20);
assert_eq!(d.rate_tokens, 30); assert_eq!(d.rate_tokens, 30);
assert_eq!(d.max_connections, 100);
assert!(d.invite_token.is_none()); assert!(d.invite_token.is_none());
} }

View file

@ -45,7 +45,7 @@ enum Command {
Serve { Serve {
/// Serve one domain per [domains.<name>] table; every domain has its /// Serve one domain per [domains.<name>] table; every domain has its
/// own key, port and mailbox database /// own key, port and mailbox database
#[arg(long, conflicts_with_all = ["key", "db", "host", "port", "max_envelope", "quota", "requests_quota", "retention_days", "requests_retention_days", "max_tokens", "invite_token", "rate_connections", "rate_sends", "rate_tokens"])] #[arg(long, conflicts_with_all = ["key", "db", "host", "port", "max_envelope", "quota", "requests_quota", "retention_days", "requests_retention_days", "max_tokens", "invite_token", "rate_connections", "rate_sends", "rate_tokens", "max_connections"])]
#[cfg_attr(feature = "rns", arg(conflicts_with_all = ["enabled", "rns_key", "rns_max_envelope", "rns_fetch_budget", "rns_max_links", "rns_rate_link_requests", "rns_rate_link_bytes", "rns_link_idle", "rns_udp", "rns_udp_forward"]))] #[cfg_attr(feature = "rns", arg(conflicts_with_all = ["enabled", "rns_key", "rns_max_envelope", "rns_fetch_budget", "rns_max_links", "rns_rate_link_requests", "rns_rate_link_bytes", "rns_link_idle", "rns_udp", "rns_udp_forward"]))]
config: Option<String>, config: Option<String>,
#[arg(long, default_value = "server.key")] #[arg(long, default_value = "server.key")]
@ -76,6 +76,9 @@ enum Command {
rate_sends: u32, rate_sends: u32,
#[arg(long = "rate-tokens", default_value_t = 30)] #[arg(long = "rate-tokens", default_value_t = 30)]
rate_tokens: u32, rate_tokens: u32,
/// Refuse connections past this many concurrent ones (0 = unlimited)
#[arg(long = "max-connections", default_value_t = 100)]
max_connections: usize,
#[cfg(feature = "rns")] #[cfg(feature = "rns")]
#[command(flatten)] #[command(flatten)]
rns: RnsServeArgs, rns: RnsServeArgs,
@ -153,6 +156,7 @@ fn main() -> anyhow::Result<()> {
rate_connections, rate_connections,
rate_sends, rate_sends,
rate_tokens, rate_tokens,
max_connections,
} => { } => {
if let Some(path) = config { if let Some(path) = config {
return server::serve_domains(config::load(&path)?); return server::serve_domains(config::load(&path)?);
@ -172,6 +176,7 @@ fn main() -> anyhow::Result<()> {
rate_connections, rate_connections,
rate_sends, rate_sends,
rate_tokens, rate_tokens,
max_connections,
}) })
} }
#[cfg(feature = "rns")] #[cfg(feature = "rns")]
@ -191,6 +196,7 @@ fn main() -> anyhow::Result<()> {
rate_connections, rate_connections,
rate_sends, rate_sends,
rate_tokens, rate_tokens,
max_connections,
rns, rns,
} => { } => {
if let Some(path) = config { if let Some(path) = config {
@ -248,6 +254,7 @@ fn main() -> anyhow::Result<()> {
rate_connections, rate_connections,
rate_sends, rate_sends,
rate_tokens, rate_tokens,
max_connections,
}) })
} }
} }

View file

@ -1,20 +1,18 @@
//! Fixed-window per-IP counter, the whole of the server's abuse control. //! Sliding-window per-IP counters, the whole of the server's abuse control.
//! //!
//! A server cannot see senders, so quotas, size caps and this are all it has. //! A server cannot see senders, so quotas, size caps and this are all it has.
//! Hits are logged per key and pruned once they age out of the window, so a
//! burst straddling a window boundary cannot exceed the configured rate the
//! way a fixed-window counter would.
use std::collections::HashMap; use std::collections::{HashMap, VecDeque};
use std::sync::Mutex; use std::sync::Mutex;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
struct Window {
start: Instant,
count: u32,
}
pub struct RateLimiter { pub struct RateLimiter {
limit: u32, limit: u32,
window: Duration, window: Duration,
hits: Mutex<HashMap<String, Window>>, hits: Mutex<HashMap<String, VecDeque<Instant>>>,
} }
impl RateLimiter { impl RateLimiter {
@ -27,44 +25,44 @@ impl RateLimiter {
} }
pub fn allow(&self, ip: &str) -> bool { pub fn allow(&self, ip: &str) -> bool {
self.allow_at(ip, Instant::now())
}
fn allow_at(&self, key: &str, now: Instant) -> bool {
if self.limit == 0 { if self.limit == 0 {
return true; return true;
} }
let now = Instant::now();
let mut hits = self.hits.lock().unwrap(); let mut hits = self.hits.lock().unwrap();
let entry = hits.entry(ip.to_string()).or_insert(Window { let entry = hits.entry(key.to_string()).or_default();
start: now, prune(entry, now, self.window);
count: 0, if entry.len() >= self.limit as usize {
});
if now.duration_since(entry.start) >= self.window {
entry.start = now;
entry.count = 0;
}
if entry.count >= self.limit {
return false; return false;
} }
entry.count += 1; entry.push_back(now);
if hits.len() > 4096 { if hits.len() > 4096 {
let window = self.window; let window = self.window;
hits.retain(|_, w| now.duration_since(w.start) < window); hits.retain(|_, e| !e.is_empty() && now.duration_since(*e.back().unwrap()) <= window);
} }
true true
} }
} }
/// Fixed-window byte counter for per-link transfer budgets: `RateLimiter` fn prune(entry: &mut VecDeque<Instant>, now: Instant, window: Duration) {
while entry
.front()
.is_some_and(|t| now.duration_since(*t) > window)
{
entry.pop_front();
}
}
/// Sliding-window byte counter for per-link transfer budgets: `RateLimiter`
/// counts events, this counts bytes, so it gets its own small type. /// counts events, this counts bytes, so it gets its own small type.
#[cfg(feature = "rns")] #[cfg(feature = "rns")]
pub struct ByteRateLimiter { pub struct ByteRateLimiter {
limit: u64, limit: u64,
window: Duration, window: Duration,
hits: Mutex<HashMap<String, ByteWindow>>, hits: Mutex<HashMap<String, VecDeque<(Instant, u64)>>>,
}
#[cfg(feature = "rns")]
struct ByteWindow {
start: Instant,
bytes: u64,
} }
#[cfg(feature = "rns")] #[cfg(feature = "rns")]
@ -79,26 +77,29 @@ impl ByteRateLimiter {
/// Records `bytes` against `key` if the window still has room for them. /// Records `bytes` against `key` if the window still has room for them.
pub fn allow(&self, key: &str, bytes: usize) -> bool { pub fn allow(&self, key: &str, bytes: usize) -> bool {
self.allow_at(key, bytes, Instant::now())
}
fn allow_at(&self, key: &str, bytes: usize, now: Instant) -> bool {
if self.limit == 0 { if self.limit == 0 {
return true; return true;
} }
let now = Instant::now();
let mut hits = self.hits.lock().unwrap(); let mut hits = self.hits.lock().unwrap();
let entry = hits.entry(key.to_string()).or_insert(ByteWindow { let entry = hits.entry(key.to_string()).or_default();
start: now, while entry
bytes: 0, .front()
}); .is_some_and(|(t, _)| now.duration_since(*t) > self.window)
if now.duration_since(entry.start) >= self.window { {
entry.start = now; entry.pop_front();
entry.bytes = 0;
} }
if entry.bytes + bytes as u64 > self.limit { let used: u64 = entry.iter().map(|(_, n)| *n).sum();
if used + bytes as u64 > self.limit {
return false; return false;
} }
entry.bytes += bytes as u64; entry.push_back((now, bytes as u64));
if hits.len() > 4096 { if hits.len() > 4096 {
let window = self.window; let window = self.window;
hits.retain(|_, w| now.duration_since(w.start) < window); hits.retain(|_, e| !e.is_empty() && now.duration_since(e.back().unwrap().0) <= window);
} }
true true
} }
@ -111,9 +112,10 @@ mod tests {
#[test] #[test]
fn allows_up_to_limit_then_blocks() { fn allows_up_to_limit_then_blocks() {
let rl = RateLimiter::new(2); let rl = RateLimiter::new(2);
assert!(rl.allow("1.2.3.4")); let now = Instant::now();
assert!(rl.allow("1.2.3.4")); assert!(rl.allow_at("1.2.3.4", now));
assert!(!rl.allow("1.2.3.4")); assert!(rl.allow_at("1.2.3.4", now));
assert!(!rl.allow_at("1.2.3.4", now));
} }
#[test] #[test]
@ -131,4 +133,42 @@ mod tests {
assert!(rl.allow("2.2.2.2")); assert!(rl.allow("2.2.2.2"));
assert!(!rl.allow("1.1.1.1")); assert!(!rl.allow("1.1.1.1"));
} }
/// The regression the sliding window exists for: a burst straddling a
/// window boundary must not be handed a fresh window, only the slots
/// its own hits have aged out. A hit counts for the whole window,
/// including the instant it is exactly window old.
#[test]
fn boundary_burst_cannot_double_the_rate() {
let rl = RateLimiter::new(2);
let t0 = Instant::now();
let window = Duration::from_secs(60);
assert!(rl.allow_at("1.2.3.4", t0));
assert!(rl.allow_at("1.2.3.4", t0 + Duration::from_secs(1)));
// At the boundary a fixed-window counter would reset and admit
// two more; both hits are still window-old or fresher.
assert!(!rl.allow_at("1.2.3.4", t0 + window));
// Half a second later the t0 hit has aged out but the +1s hit
// has not, so exactly one slot is free.
let later = t0 + window + Duration::from_millis(500);
assert!(rl.allow_at("1.2.3.4", later));
assert!(!rl.allow_at("1.2.3.4", later));
}
#[cfg(feature = "rns")]
#[test]
fn byte_boundary_burst_cannot_double_the_budget() {
let bl = ByteRateLimiter::new(100);
let t0 = Instant::now();
let window = Duration::from_secs(60);
assert!(bl.allow_at("link", 60, t0));
assert!(bl.allow_at("link", 40, t0 + Duration::from_secs(1)));
// At the boundary both hits still count: 100 bytes used, no room.
assert!(!bl.allow_at("link", 1, t0 + window));
// Half a second later only the 60-byte hit has aged out, freeing
// exactly its 60 bytes.
let later = t0 + window + Duration::from_millis(500);
assert!(bl.allow_at("link", 60, later));
assert!(!bl.allow_at("link", 1, later));
}
} }

View file

@ -1,7 +1,7 @@
//! TCP accept loop, per-connection handling, and the background purge loop. //! TCP accept loop, per-connection handling, and the background purge loop.
use std::net::{TcpListener, TcpStream}; use std::net::{TcpListener, TcpStream};
use std::sync::Arc; use std::sync::{Arc, Mutex};
use std::time::Duration; use std::time::Duration;
use crate::bind::TransportBindValues; use crate::bind::TransportBindValues;
@ -28,6 +28,7 @@ pub struct ServeArgs {
pub rate_connections: u32, pub rate_connections: u32,
pub rate_sends: u32, pub rate_sends: u32,
pub rate_tokens: u32, pub rate_tokens: u32,
pub max_connections: usize,
} }
/// The single-domain CLI path: one anonymous domain. /// The single-domain CLI path: one anonymous domain.
@ -48,6 +49,7 @@ pub fn run(args: ServeArgs) -> anyhow::Result<()> {
rate_connections: args.rate_connections, rate_connections: args.rate_connections,
rate_sends: args.rate_sends, rate_sends: args.rate_sends,
rate_tokens: args.rate_tokens, rate_tokens: args.rate_tokens,
max_connections: args.max_connections,
}]) }])
} }
@ -94,8 +96,9 @@ pub fn serve_domains(domains: Vec<DomainConfig>) -> anyhow::Result<()> {
purge_loop(purge_db_path, main_retention_secs, requests_retention_secs) purge_loop(purge_db_path, main_retention_secs, requests_retention_secs)
}); });
let gate = Arc::new(ConnGate::new(domain.max_connections));
let db_path = domain.db_path.clone(); let db_path = domain.db_path.clone();
std::thread::spawn(move || accept_loop(listener, config, static_key, db_path)); std::thread::spawn(move || accept_loop(listener, config, static_key, db_path, gate));
} }
// Each domain's accept loop runs in its own thread; nothing fails here. // Each domain's accept loop runs in its own thread; nothing fails here.
@ -120,6 +123,7 @@ fn accept_loop(
config: Arc<ServerConfig>, config: Arc<ServerConfig>,
static_key: [u8; KEY_LEN], static_key: [u8; KEY_LEN],
db_path: String, db_path: String,
gate: Arc<ConnGate>,
) { ) {
for incoming in listener.incoming() { for incoming in listener.incoming() {
let stream = match incoming { let stream = match incoming {
@ -139,9 +143,19 @@ fn accept_loop(
continue; continue;
} }
let permit = match gate.try_acquire() {
Some(p) => p,
None => {
log::warn!("connection limit reached, refusing {peer_ip}");
continue;
}
};
let config = Arc::clone(&config); let config = Arc::clone(&config);
let db_path = db_path.clone(); let db_path = db_path.clone();
std::thread::spawn(move || { std::thread::spawn(move || {
// The permit is dropped with the connection, freeing its slot.
let _permit = permit;
if let Err(e) = handle_connection(stream, &config, &static_key, &db_path, &peer_ip) { if let Err(e) = handle_connection(stream, &config, &static_key, &db_path, &peer_ip) {
log::info!("connection error from {peer_ip}: {e}"); log::info!("connection error from {peer_ip}: {e}");
} }
@ -149,6 +163,55 @@ fn accept_loop(
} }
} }
/// Cap on concurrently served TCP connections, the mirror of the RNS
/// carrier's link cap (SPEC.md sec 13.8): without one, a connection flood
/// would exhaust threads one unbounded spawn at a time. `max == 0` disables
/// the cap; past it, connections are refused, never queued.
struct ConnGate {
max: usize,
active: Mutex<usize>,
}
impl ConnGate {
fn new(max: usize) -> Self {
ConnGate {
max,
active: Mutex::new(0),
}
}
fn try_acquire(self: &Arc<Self>) -> Option<ConnPermit> {
if self.max == 0 {
return Some(ConnPermit {
gate: Arc::clone(self),
});
}
let mut active = self.active.lock().unwrap();
if *active >= self.max {
return None;
}
*active += 1;
Some(ConnPermit {
gate: Arc::clone(self),
})
}
}
/// Dropping releases the slot, so the count stays accurate whatever return
/// path or panic closes the connection.
struct ConnPermit {
gate: Arc<ConnGate>,
}
impl Drop for ConnPermit {
fn drop(&mut self) {
if self.gate.max == 0 {
return;
}
*self.gate.active.lock().unwrap() -= 1;
}
}
/// Also the hostile harness's entry point: it drives real connections /// Also the hostile harness's entry point: it drives real connections
/// through the same accept/handshake/session path the TCP carrier serves. /// through the same accept/handshake/session path the TCP carrier serves.
pub(crate) fn handle_connection( pub(crate) fn handle_connection(
@ -265,3 +328,27 @@ fn purge_loop(db_path: String, main_retention_secs: i64, requests_retention_secs
} }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn gate_refuses_past_cap_and_frees_on_drop() {
let gate = Arc::new(ConnGate::new(2));
let a = gate.try_acquire().unwrap();
let b = gate.try_acquire().unwrap();
assert!(gate.try_acquire().is_none());
drop(b);
assert!(gate.try_acquire().is_some());
drop(a);
}
#[test]
fn zero_cap_means_unlimited() {
let gate = Arc::new(ConnGate::new(0));
for _ in 0..100 {
assert!(gate.try_acquire().is_some());
}
}
}