use std::collections::HashMap;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use std::sync::Mutex;
use std::time::{Duration, Instant};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Policy {
pub free_failures: u32,
pub window: Duration,
pub first_lockout: Duration,
pub max_lockout: Duration,
}
impl Policy {
pub const ENROLL: Policy = Policy {
free_failures: 5,
window: Duration::from_secs(10 * 60),
first_lockout: Duration::from_secs(60),
max_lockout: Duration::from_secs(60 * 60),
};
pub const LOGIN: Policy = Policy {
free_failures: 5,
window: Duration::from_secs(60),
first_lockout: Duration::from_secs(60),
max_lockout: Duration::from_secs(60 * 60),
};
pub const PASSKEY_LOGIN: Policy = Policy {
free_failures: 30,
window: Duration::from_secs(60),
first_lockout: Duration::from_secs(60),
max_lockout: Duration::from_secs(60 * 60),
};
}
pub const DEFAULT_CAPACITY: usize = 4096;
#[derive(Debug, Clone, Copy)]
struct Entry {
failures: u32,
last_failure: Instant,
locked_until: Option<Instant>,
}
impl Entry {
fn locked(&self, now: Instant) -> bool {
self.locked_until.is_some_and(|until| now < until)
}
fn forgotten(&self, policy: &Policy, now: Instant) -> bool {
let since = self
.locked_until
.map_or(self.last_failure, |u| u.max(self.last_failure));
!self.locked(now) && now.saturating_duration_since(since) >= policy.window
}
}
pub fn key(addr: IpAddr) -> IpAddr {
match addr.to_canonical() {
IpAddr::V4(v4) if v4.is_loopback() => IpAddr::V4(Ipv4Addr::LOCALHOST),
IpAddr::V4(v4) => IpAddr::V4(v4),
IpAddr::V6(v6) if v6.is_loopback() => IpAddr::V6(v6),
IpAddr::V6(v6) => {
let s = v6.segments();
IpAddr::V6(Ipv6Addr::new(s[0], s[1], s[2], s[3], 0, 0, 0, 0))
}
}
}
struct State {
entries: HashMap<IpAddr, Entry>,
overflow: Option<Entry>,
}
pub struct Limiter {
policy: Policy,
capacity: usize,
state: Mutex<State>,
}
impl Limiter {
pub fn new(policy: Policy) -> Self {
Self::with_capacity(policy, DEFAULT_CAPACITY)
}
pub fn with_capacity(policy: Policy, capacity: usize) -> Self {
Self {
policy,
capacity: capacity.max(1),
state: Mutex::new(State {
entries: HashMap::new(),
overflow: None,
}),
}
}
pub fn attempt(&self, addr: IpAddr, now: Instant) -> Result<(), Duration> {
let policy = self.policy;
let addr = key(addr);
let mut state = self.state.lock().expect("limiter lock");
if let Some(entry) = state.entries.get_mut(&addr) {
return record_attempt(entry, &policy, now);
}
state.entries.retain(|_, e| !e.forgotten(&policy, now));
if state.entries.len() < self.capacity || addr.is_loopback() {
let entry = state.entries.entry(addr).or_insert(Entry {
failures: 0,
last_failure: now,
locked_until: None,
});
return record_attempt(entry, &policy, now);
}
let overflow = state.overflow.get_or_insert(Entry {
failures: 0,
last_failure: now,
locked_until: None,
});
record_attempt(overflow, &policy, now)
}
pub fn succeeded(&self, addr: IpAddr) {
self.state.lock().expect("limiter lock").entries.remove(&key(addr));
}
pub fn clear(&self) {
let mut state = self.state.lock().expect("limiter lock");
state.entries.clear();
state.overflow = None;
}
pub fn tracked(&self) -> usize {
self.state.lock().expect("limiter lock").entries.len()
}
}
fn record_attempt(entry: &mut Entry, policy: &Policy, now: Instant) -> Result<(), Duration> {
if let Some(until) = entry.locked_until
&& now < until
{
return Err(until - now);
}
if entry.forgotten(policy, now) {
entry.failures = 0;
entry.locked_until = None;
}
entry.failures += 1;
entry.last_failure = now;
if entry.failures >= policy.free_failures {
let doublings = (entry.failures - policy.free_failures).min(20);
let lockout = policy
.first_lockout
.saturating_mul(1 << doublings)
.min(policy.max_lockout);
entry.locked_until = Some(now + lockout);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
const A: IpAddr = IpAddr::V4(std::net::Ipv4Addr::new(192, 0, 2, 1));
const B: IpAddr = IpAddr::V4(std::net::Ipv4Addr::new(192, 0, 2, 2));
fn secs(s: u64) -> Duration {
Duration::from_secs(s)
}
#[test]
fn the_fifth_attempt_locks_out_and_each_further_one_doubles_the_lockout() {
let limiter = Limiter::new(Policy::ENROLL);
let t0 = Instant::now();
for _ in 0..5 {
assert_eq!(limiter.attempt(A, t0), Ok(()));
}
assert_eq!(limiter.attempt(A, t0), Err(secs(60)));
assert_eq!(limiter.attempt(A, t0 + secs(59)), Err(secs(1)));
assert_eq!(limiter.attempt(A, t0 + secs(60)), Ok(()));
assert_eq!(limiter.attempt(A, t0 + secs(60)), Err(secs(120)));
assert_eq!(limiter.attempt(A, t0 + secs(180)), Ok(()));
assert_eq!(limiter.attempt(A, t0 + secs(180)), Err(secs(240)));
assert_eq!(limiter.attempt(B, t0), Ok(()));
}
#[test]
fn a_parallel_burst_gets_no_more_than_the_free_attempts() {
let limiter = Arc::new(Limiter::new(Policy::ENROLL));
let now = Instant::now();
let barrier = Arc::new(std::sync::Barrier::new(32));
let threads: Vec<_> = (0..32)
.map(|_| {
let limiter = limiter.clone();
let barrier = barrier.clone();
std::thread::spawn(move || {
barrier.wait();
limiter.attempt(A, now).is_ok()
})
})
.collect();
let allowed = threads.into_iter().map(|t| t.join().unwrap()).filter(|ok| *ok).count();
assert_eq!(allowed, 5);
}
#[test]
fn failures_older_than_the_window_are_forgotten() {
let limiter = Limiter::new(Policy::ENROLL);
let t0 = Instant::now();
for _ in 0..4 {
limiter.attempt(A, t0).unwrap();
}
let later = t0 + secs(10 * 60);
for _ in 0..4 {
assert_eq!(limiter.attempt(A, later), Ok(()));
}
assert_eq!(limiter.attempt(A, later), Ok(()));
assert!(limiter.attempt(A, later).is_err());
}
#[test]
fn a_success_clears_the_count_and_the_lockout_is_capped() {
let limiter = Limiter::new(Policy::ENROLL);
let t0 = Instant::now();
for _ in 0..4 {
limiter.attempt(A, t0).unwrap();
}
limiter.succeeded(A);
for _ in 0..4 {
assert_eq!(limiter.attempt(A, t0), Ok(()));
}
let mut t = t0;
let mut longest = Duration::ZERO;
for _ in 0..40 {
if let Err(wait) = limiter.attempt(A, t) {
longest = longest.max(wait);
t += wait;
}
}
assert_eq!(longest, secs(60 * 60));
}
#[test]
fn an_ipv6_64_is_one_address_and_a_mapped_ipv4_is_its_ipv4() {
let limiter = Limiter::new(Policy::ENROLL);
let t0 = Instant::now();
let v6 = |last: u16| IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 1, 2, 0, 0, 0, last));
for last in 1..=5 {
limiter.attempt(v6(last), t0).unwrap();
}
assert!(limiter.attempt(v6(99), t0).is_err(), "same /64");
let other_64 = IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 1, 3, 0, 0, 0, 1));
assert_eq!(limiter.attempt(other_64, t0), Ok(()));
for _ in 0..5 {
limiter.attempt(A, t0).unwrap();
}
let mapped = IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0xffff, 0xc000, 0x0201));
assert_eq!(key(mapped), A);
assert!(limiter.attempt(mapped, t0).is_err());
}
#[test]
fn past_its_capacity_forgotten_entries_are_pruned_before_anything_else() {
let limiter = Limiter::with_capacity(Policy::ENROLL, 3);
let t0 = Instant::now();
let addr = |n: u8| IpAddr::V4(std::net::Ipv4Addr::new(198, 51, 100, n));
for n in 1..=3 {
limiter.attempt(addr(n), t0).unwrap();
}
assert_eq!(limiter.tracked(), 3);
let later = t0 + secs(10 * 60);
assert_eq!(limiter.attempt(addr(4), later), Ok(()));
assert_eq!(limiter.tracked(), 1);
}
#[test]
fn once_the_table_is_full_of_live_entries_new_addresses_share_one_overflow_budget() {
let limiter = Limiter::with_capacity(Policy::ENROLL, 3);
let t0 = Instant::now();
let live = |n: u8| IpAddr::V4(std::net::Ipv4Addr::new(203, 0, 113, n));
for n in 1..=3 {
assert_eq!(limiter.attempt(live(n), t0), Ok(()));
}
assert_eq!(limiter.tracked(), 3);
let flood = |n: u8| IpAddr::V4(std::net::Ipv4Addr::new(198, 51, 100, n));
for n in 1..=5 {
assert_eq!(limiter.attempt(flood(n), t0), Ok(()), "overflow attempt {n}");
}
assert_eq!(limiter.tracked(), 3);
assert_eq!(limiter.attempt(flood(6), t0), Err(secs(60)));
for n in 1..=3 {
for _ in 0..4 {
assert_eq!(
limiter.attempt(live(n), t0),
Ok(()),
"address {n} was reset by the flood"
);
}
assert!(
limiter.attempt(live(n), t0).is_err(),
"address {n} was reset by the flood (should be locked after 5 total failures)"
);
}
assert_eq!(limiter.tracked(), 3);
}
#[test]
fn loopback_keeps_one_entry_of_its_own_past_capacity() {
let limiter = Limiter::with_capacity(Policy::ENROLL, 1);
let t0 = Instant::now();
limiter.attempt(A, t0).unwrap();
for _ in 0..5 {
limiter.attempt(B, t0).unwrap();
}
assert!(limiter.attempt(B, t0).is_err(), "the overflow budget is used up");
let lo = |d: u8| IpAddr::V4(std::net::Ipv4Addr::new(127, d, 0, 1));
assert_eq!(key(lo(9)), IpAddr::V4(std::net::Ipv4Addr::LOCALHOST));
for d in 0..5 {
assert_eq!(
limiter.attempt(lo(d), t0),
Ok(()),
"loopback shared the overflow budget"
);
}
assert!(limiter.attempt(lo(200), t0).is_err(), "127/8 is one address");
assert_eq!(limiter.tracked(), 2);
assert_eq!(limiter.attempt(IpAddr::V6(Ipv6Addr::LOCALHOST), t0), Ok(()));
}
#[test]
fn locked_entries_are_never_evicted_even_by_a_large_flood() {
let limiter = Limiter::with_capacity(Policy::ENROLL, 2);
let t0 = Instant::now();
let locked = |n: u8| IpAddr::V4(std::net::Ipv4Addr::new(203, 0, 113, n));
for n in 1..=2 {
for _ in 0..5 {
limiter.attempt(locked(n), t0).unwrap();
}
assert!(limiter.attempt(locked(n), t0).is_err());
}
assert_eq!(limiter.tracked(), 2);
let flood = |n: u16| IpAddr::V4(std::net::Ipv4Addr::new(198, 51, 100, (n % 256) as u8));
for n in 1..=20 {
let _ = limiter.attempt(flood(n), t0);
assert_eq!(limiter.tracked(), 2, "a locked entry was evicted at flood attempt {n}");
}
for n in 1..=2 {
assert!(limiter.attempt(locked(n), t0).is_err(), "address {n} was reset");
}
}
}