use std::collections::HashMap;
use std::net::Ipv4Addr;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, RwLock};
use std::time::{Duration, Instant};
#[derive(Clone, Default)]
pub struct IcmpBackoff {
inner: Arc<RwLock<HashMap<u32, BackoffState>>>,
pub blocked_total: Arc<AtomicU64>,
}
#[derive(Debug, Clone, Copy)]
struct BackoffState {
count_in_window: u32,
window_start: Instant,
blocked_until: Option<Instant>,
}
#[derive(Debug, Clone, Copy)]
pub struct IcmpBackoffConfig {
pub window: Duration,
pub burst_threshold: u32,
pub backoff: Duration,
}
impl Default for IcmpBackoffConfig {
fn default() -> Self {
Self {
window: Duration::from_secs(2),
burst_threshold: 8,
backoff: Duration::from_secs(30),
}
}
}
impl IcmpBackoff {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[inline]
#[must_use]
pub fn slash24_of(ip: Ipv4Addr) -> u32 {
let o = ip.octets();
u32::from_be_bytes([o[0], o[1], o[2], 0])
}
pub fn feed(&self, slash24: u32, count: u32, cfg: IcmpBackoffConfig) -> bool {
self.feed_at(slash24, count, cfg, Instant::now())
}
pub fn feed_at(&self, slash24: u32, count: u32, cfg: IcmpBackoffConfig, now: Instant) -> bool {
let Ok(mut g) = self.inner.write() else {
return false;
};
let entry = g.entry(slash24).or_insert(BackoffState {
count_in_window: 0,
window_start: now,
blocked_until: None,
});
if now.duration_since(entry.window_start) > cfg.window {
entry.count_in_window = 0;
entry.window_start = now;
}
entry.count_in_window = entry.count_in_window.saturating_add(count);
if entry.blocked_until.map_or(false, |u| u > now) {
return true;
}
if entry.count_in_window >= cfg.burst_threshold {
entry.blocked_until = Some(now + cfg.backoff);
self.blocked_total.fetch_add(1, Ordering::Relaxed);
return true;
}
false
}
#[inline]
#[must_use]
pub fn is_blocked(&self, slash24: u32) -> bool {
self.is_blocked_at(slash24, Instant::now())
}
#[must_use]
pub fn is_blocked_at(&self, slash24: u32, now: Instant) -> bool {
let Ok(g) = self.inner.read() else {
return false;
};
g.get(&slash24)
.and_then(|s| s.blocked_until)
.map_or(false, |u| u > now)
}
pub fn prune(&self, cfg: IcmpBackoffConfig) {
let now = Instant::now();
if let Ok(mut g) = self.inner.write() {
g.retain(|_, s| {
s.blocked_until.map_or(false, |u| u > now)
|| now.duration_since(s.window_start) <= cfg.window
});
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn cfg() -> IcmpBackoffConfig {
IcmpBackoffConfig {
window: Duration::from_secs(1),
burst_threshold: 5,
backoff: Duration::from_secs(10),
}
}
#[test]
fn slash24_packs_correctly() {
assert_eq!(
IcmpBackoff::slash24_of(Ipv4Addr::new(10, 1, 2, 3)),
u32::from_be_bytes([10, 1, 2, 0])
);
}
#[test]
fn single_event_does_not_trip() {
let b = IcmpBackoff::new();
let s = IcmpBackoff::slash24_of(Ipv4Addr::new(192, 168, 1, 1));
assert!(!b.feed(s, 1, cfg()));
assert!(!b.is_blocked(s));
}
#[test]
fn burst_threshold_flips_into_backoff() {
let b = IcmpBackoff::new();
let s = IcmpBackoff::slash24_of(Ipv4Addr::new(192, 168, 1, 1));
let now = Instant::now();
for _ in 0..4 {
assert!(!b.feed_at(s, 1, cfg(), now));
}
assert!(b.feed_at(s, 1, cfg(), now));
assert!(b.is_blocked_at(s, now));
assert_eq!(b.blocked_total.load(Ordering::Relaxed), 1);
}
#[test]
fn backoff_expires_after_window() {
let b = IcmpBackoff::new();
let c = cfg();
let s = IcmpBackoff::slash24_of(Ipv4Addr::new(10, 0, 0, 1));
let now = Instant::now();
b.feed_at(s, 5, c, now);
assert!(b.is_blocked_at(s, now));
let later = now + c.backoff + Duration::from_millis(1);
assert!(!b.is_blocked_at(s, later));
}
#[test]
fn rolling_window_decays_count() {
let b = IcmpBackoff::new();
let c = cfg();
let s = IcmpBackoff::slash24_of(Ipv4Addr::new(10, 0, 0, 2));
let t0 = Instant::now();
b.feed_at(s, 3, c, t0);
let after_window = t0 + c.window + Duration::from_millis(1);
let tripped = b.feed_at(s, 3, c, after_window);
assert!(!tripped, "old window must not contribute to threshold");
}
#[test]
fn unrelated_slash24_unaffected() {
let b = IcmpBackoff::new();
let a = IcmpBackoff::slash24_of(Ipv4Addr::new(10, 0, 0, 1));
let b_ip = IcmpBackoff::slash24_of(Ipv4Addr::new(10, 1, 0, 1));
let now = Instant::now();
for _ in 0..6 {
b.feed_at(a, 1, cfg(), now);
}
assert!(b.is_blocked_at(a, now));
assert!(!b.is_blocked_at(b_ip, now));
}
#[test]
fn prune_drops_stale_unblocked_entries() {
let b = IcmpBackoff::new();
let c = cfg();
let s = IcmpBackoff::slash24_of(Ipv4Addr::new(10, 0, 0, 5));
b.feed_at(s, 1, c, Instant::now());
std::thread::sleep(c.window + Duration::from_millis(10));
b.prune(c);
let g = b.inner.read().unwrap();
assert!(!g.contains_key(&s));
}
#[test]
fn burst_count_can_be_supplied_in_one_call() {
let b = IcmpBackoff::new();
let s = IcmpBackoff::slash24_of(Ipv4Addr::new(10, 0, 0, 1));
assert!(b.feed(s, 100, cfg()));
assert!(b.is_blocked(s));
}
}