use std::collections::HashMap;
use std::net::IpAddr;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, RwLock};
use std::time::Instant;
use crate::config::{RateLimit, RateLimits};
use crate::throttle::TokenBucket;
const PRUNE_EVERY_N_ADMISSIONS: u64 = 64;
const MAX_TRACKED_CLIENTS: usize = 65_536;
#[derive(Debug)]
pub struct AbuseControls {
config: RwLock<RateLimits>,
clients: Mutex<HashMap<IpAddr, Arc<Mutex<ClientState>>>>,
admissions: AtomicU64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DenialReason {
ConnectionRate,
AuthFailureRate,
ConcurrentConnections,
}
impl DenialReason {
pub fn as_str(self) -> &'static str {
match self {
DenialReason::ConnectionRate => "connection rate limit exceeded",
DenialReason::AuthFailureRate => "auth failure rate limit exceeded",
DenialReason::ConcurrentConnections => "concurrent connection limit exceeded",
}
}
}
#[derive(Debug)]
pub struct ClientPermit {
controls: Arc<AbuseControls>,
state: Arc<Mutex<ClientState>>,
bandwidth: Option<Arc<Mutex<TokenBucket>>>,
}
impl AbuseControls {
pub fn new(config: RateLimits) -> Arc<Self> {
Arc::new(Self {
config: RwLock::new(config),
clients: Mutex::new(HashMap::new()),
admissions: AtomicU64::new(0),
})
}
pub fn update_config(&self, config: RateLimits) {
*self.config.write().unwrap_or_else(|e| e.into_inner()) = config.clone();
let now = Instant::now();
let states: Vec<Arc<Mutex<ClientState>>> = {
let clients = self.clients.lock().unwrap_or_else(|e| e.into_inner());
clients.values().cloned().collect()
};
for state in states {
let mut state = state.lock().unwrap_or_else(|e| e.into_inner());
reconcile_bandwidth(&mut state, &config, now);
}
}
pub fn admit(self: &Arc<Self>, ip: IpAddr) -> Result<ClientPermit, DenialReason> {
let mut clients = self.clients.lock().unwrap_or_else(|e| e.into_inner());
let config = self
.config
.read()
.unwrap_or_else(|e| e.into_inner())
.clone();
let now = Instant::now();
let state = checkout_state(&self.admissions, &mut clients, ip, &config, now);
let mut state_guard = state.lock().unwrap_or_else(|e| e.into_inner());
if is_limit_exceeded(
&mut state_guard.auth_failures,
config.auth_failure_rate.as_ref(),
now,
) {
return Err(DenialReason::AuthFailureRate);
}
if let Some(limit) = config.concurrent_connections {
if state_guard.active_connections >= limit {
return Err(DenialReason::ConcurrentConnections);
}
}
if increment_window(
&mut state_guard.connections,
config.connection_rate.as_ref(),
now,
) {
return Err(DenialReason::ConnectionRate);
}
state_guard.active_connections += 1;
reconcile_bandwidth(&mut state_guard, &config, now);
let bandwidth = state_guard.bandwidth.clone();
drop(state_guard);
Ok(ClientPermit {
controls: self.clone(),
state,
bandwidth,
})
}
pub fn record_auth_failure(&self, ip: IpAddr) {
let config = self
.config
.read()
.unwrap_or_else(|e| e.into_inner())
.clone();
let mut clients = self.clients.lock().unwrap_or_else(|e| e.into_inner());
let now = Instant::now();
let state = checkout_state(&self.admissions, &mut clients, ip, &config, now);
let mut state = state.lock().unwrap_or_else(|e| e.into_inner());
let _ = increment_window(
&mut state.auth_failures,
config.auth_failure_rate.as_ref(),
now,
);
}
fn release(&self, state: &Mutex<ClientState>) {
let mut state = state.lock().unwrap_or_else(|e| e.into_inner());
state.active_connections = state.active_connections.saturating_sub(1);
}
}
impl ClientPermit {
pub fn throttle_bucket(&self) -> Option<Arc<Mutex<TokenBucket>>> {
self.bandwidth.clone()
}
}
impl Drop for ClientPermit {
fn drop(&mut self) {
self.controls.release(&self.state);
}
}
#[derive(Debug)]
struct ClientState {
connections: Window,
auth_failures: Window,
bandwidth: Option<Arc<Mutex<TokenBucket>>>,
active_connections: usize,
}
impl ClientState {
fn new(now: Instant) -> Self {
Self {
connections: Window::new(now),
auth_failures: Window::new(now),
bandwidth: None,
active_connections: 0,
}
}
}
#[derive(Debug)]
struct Window {
started: Instant,
count: u64,
}
impl Window {
fn new(now: Instant) -> Self {
Self {
started: now,
count: 0,
}
}
}
fn reset_if_expired(window: &mut Window, limit: &RateLimit, now: Instant) {
if now.duration_since(window.started) >= limit.window {
window.started = now;
window.count = 0;
}
}
fn is_limit_exceeded(window: &mut Window, limit: Option<&RateLimit>, now: Instant) -> bool {
let Some(limit) = limit else {
return false;
};
reset_if_expired(window, limit, now);
window.count >= limit.limit
}
fn increment_window(window: &mut Window, limit: Option<&RateLimit>, now: Instant) -> bool {
let Some(limit) = limit else {
return false;
};
reset_if_expired(window, limit, now);
window.count = window.count.saturating_add(1);
window.count > limit.limit
}
fn reconcile_bandwidth(state: &mut ClientState, config: &RateLimits, now: Instant) {
match (&config.byte_rate, &state.bandwidth) {
(Some(limit), None) => {
state.bandwidth = TokenBucket::from_rate_window(limit.limit, limit.window, now)
.map(|b| Arc::new(Mutex::new(b)));
}
(Some(limit), Some(bucket)) => {
bucket
.lock()
.unwrap_or_else(|e| e.into_inner())
.update_rate_window(limit.limit, limit.window, now);
}
(None, Some(_)) => state.bandwidth = None,
(None, None) => {}
}
}
fn checkout_state(
admissions: &AtomicU64,
clients: &mut HashMap<IpAddr, Arc<Mutex<ClientState>>>,
ip: IpAddr,
config: &RateLimits,
now: Instant,
) -> Arc<Mutex<ClientState>> {
let due = admissions
.fetch_add(1, Ordering::Relaxed)
.is_multiple_of(PRUNE_EVERY_N_ADMISSIONS);
if due {
prune_expired_clients(clients, config, now);
}
if clients.len() >= MAX_TRACKED_CLIENTS && !clients.contains_key(&ip) {
evict_idle_clients(clients, MAX_TRACKED_CLIENTS - 1);
}
clients
.entry(ip)
.or_insert_with(|| Arc::new(Mutex::new(ClientState::new(now))))
.clone()
}
fn prune_expired_clients(
clients: &mut HashMap<IpAddr, Arc<Mutex<ClientState>>>,
config: &RateLimits,
now: Instant,
) {
clients.retain(|_, state| match state.try_lock() {
Ok(state) => !state.is_expired(config, now),
Err(_) => true,
});
}
fn evict_idle_clients(clients: &mut HashMap<IpAddr, Arc<Mutex<ClientState>>>, target: usize) {
if clients.len() <= target {
return;
}
let excess = clients.len() - target;
let victims: Vec<IpAddr> = clients
.iter()
.filter(|(_, state)| state.try_lock().is_ok_and(|g| g.active_connections == 0))
.take(excess)
.map(|(ip, _)| *ip)
.collect();
for ip in victims {
clients.remove(&ip);
}
}
impl ClientState {
fn is_expired(&self, config: &RateLimits, now: Instant) -> bool {
self.active_connections == 0
&& window_is_prunable(&self.connections, config.connection_rate.as_ref(), now)
&& window_is_prunable(&self.auth_failures, config.auth_failure_rate.as_ref(), now)
&& self.bandwidth.as_ref().is_none_or(|b| {
b.try_lock().is_ok_and(|mut g| g.is_full(now))
})
}
}
fn window_is_prunable(window: &Window, limit: Option<&RateLimit>, now: Instant) -> bool {
let Some(limit) = limit else {
return true;
};
window.count == 0 || now.duration_since(window.started) >= limit.window
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
fn ip() -> IpAddr {
"127.0.0.1".parse().unwrap()
}
#[test]
fn enforces_connection_rate() {
let controls = AbuseControls::new(RateLimits {
connection_rate: Some(RateLimit {
limit: 1,
window: Duration::from_secs(60),
}),
..RateLimits::default()
});
let _permit = controls.admit(ip()).unwrap();
let err = controls.admit(ip()).unwrap_err();
assert_eq!(err, DenialReason::ConnectionRate);
}
#[test]
fn releases_concurrent_connection_permit_on_drop() {
let controls = AbuseControls::new(RateLimits {
concurrent_connections: Some(1),
..RateLimits::default()
});
let permit = controls.admit(ip()).unwrap();
assert_eq!(
controls.admit(ip()).unwrap_err(),
DenialReason::ConcurrentConnections
);
drop(permit);
assert!(controls.admit(ip()).is_ok());
}
#[test]
fn auth_failures_block_later_connections() {
let controls = AbuseControls::new(RateLimits {
auth_failure_rate: Some(RateLimit {
limit: 1,
window: Duration::from_secs(60),
}),
..RateLimits::default()
});
controls.record_auth_failure(ip());
assert_eq!(
controls.admit(ip()).unwrap_err(),
DenialReason::AuthFailureRate
);
}
#[test]
fn byte_rate_yields_a_shared_per_client_bucket() {
let controls = AbuseControls::new(RateLimits {
byte_rate: Some(RateLimit {
limit: 4,
window: Duration::from_secs(60),
}),
..RateLimits::default()
});
let a = controls.admit(ip()).unwrap();
let b = controls.admit(ip()).unwrap();
let bucket_a = a.throttle_bucket().expect("byterate yields a bucket");
let bucket_b = b.throttle_bucket().expect("byterate yields a bucket");
assert!(Arc::ptr_eq(&bucket_a, &bucket_b));
}
#[test]
fn no_byte_rate_yields_no_bucket() {
let controls = AbuseControls::new(RateLimits::default());
let permit = controls.admit(ip()).unwrap();
assert!(permit.throttle_bucket().is_none());
}
#[test]
fn reload_retunes_existing_bucket_in_place() {
let controls = AbuseControls::new(RateLimits {
byte_rate: Some(RateLimit {
limit: 1000,
window: Duration::from_secs(1),
}),
..RateLimits::default()
});
let first = controls.admit(ip()).unwrap();
let bucket = first.throttle_bucket().unwrap();
controls.update_config(RateLimits {
byte_rate: Some(RateLimit {
limit: 5000,
window: Duration::from_secs(1),
}),
..RateLimits::default()
});
let second = controls.admit(ip()).unwrap();
assert!(Arc::ptr_eq(&bucket, &second.throttle_bucket().unwrap()));
}
#[test]
fn reload_retunes_live_bucket_without_readmission() {
let controls = AbuseControls::new(RateLimits {
byte_rate: Some(RateLimit {
limit: 100,
window: Duration::from_secs(60),
}),
..RateLimits::default()
});
let permit = controls.admit(ip()).unwrap();
let bucket = permit.throttle_bucket().unwrap();
assert!(bucket.lock().unwrap().is_full(Instant::now()));
controls.update_config(RateLimits {
byte_rate: Some(RateLimit {
limit: 1_000_000,
window: Duration::from_secs(60),
}),
..RateLimits::default()
});
assert!(!bucket.lock().unwrap().is_full(Instant::now()));
}
#[test]
fn drained_bandwidth_state_survives_prune_until_refilled() {
use crate::throttle::Throttle;
let controls = AbuseControls::new(RateLimits {
byte_rate: Some(RateLimit {
limit: 1000,
window: Duration::from_secs(60),
}),
..RateLimits::default()
});
let drained = "127.0.0.9".parse().unwrap();
let permit = controls.admit(drained).unwrap();
let bucket = permit.throttle_bucket().unwrap();
assert!(Throttle::new().with_bucket(bucket).police(1000));
drop(permit);
let other = ip();
for _ in 0..PRUNE_EVERY_N_ADMISSIONS {
drop(controls.admit(other).unwrap());
}
assert!(controls.clients.lock().unwrap().contains_key(&drained));
}
#[test]
fn prunes_expired_idle_client_states_on_amortized_sweep() {
let controls = AbuseControls::new(RateLimits {
connection_rate: Some(RateLimit {
limit: u64::MAX,
window: Duration::ZERO,
}),
..RateLimits::default()
});
let first = "127.0.0.1".parse().unwrap();
let second = "127.0.0.2".parse().unwrap();
drop(controls.admit(first).unwrap());
assert_eq!(controls.clients.lock().unwrap().len(), 1);
for _ in 0..PRUNE_EVERY_N_ADMISSIONS {
drop(controls.admit(second).unwrap());
}
let clients = controls.clients.lock().unwrap();
assert_eq!(clients.len(), 1);
assert!(clients.contains_key(&second));
}
#[test]
fn evict_idle_clients_drops_idle_but_keeps_active() {
let now = Instant::now();
let mut clients: HashMap<IpAddr, Arc<Mutex<ClientState>>> = HashMap::new();
for i in 0..5u8 {
clients.insert(
IpAddr::from([10, 0, 0, i]),
Arc::new(Mutex::new(ClientState::new(now))),
);
}
let active_ip = IpAddr::from([10, 0, 0, 100]);
let active = Arc::new(Mutex::new(ClientState::new(now)));
active.lock().unwrap().active_connections = 1;
clients.insert(active_ip, active);
assert_eq!(clients.len(), 6);
evict_idle_clients(&mut clients, 2);
assert_eq!(clients.len(), 2);
assert!(
clients.contains_key(&active_ip),
"an active state must never be evicted"
);
}
#[test]
fn evict_idle_clients_never_drops_active_even_over_target() {
let now = Instant::now();
let mut clients: HashMap<IpAddr, Arc<Mutex<ClientState>>> = HashMap::new();
for i in 0..3u8 {
let state = Arc::new(Mutex::new(ClientState::new(now)));
state.lock().unwrap().active_connections = 1;
clients.insert(IpAddr::from([10, 0, 0, i]), state);
}
evict_idle_clients(&mut clients, 1);
assert_eq!(clients.len(), 3);
}
}