fix: sliding-window rate limits and TCP connection cap
This commit is contained in:
parent
0c7311b068
commit
fd847d035f
7 changed files with 198 additions and 48 deletions
|
|
@ -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
2
RNS.md
|
|
@ -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`.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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+=(
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
124
src/ratelimit.rs
124
src/ratelimit.rs
|
|
@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue