1use std::collections::HashMap;
24use std::net::Ipv4Addr;
25use std::sync::atomic::{AtomicU64, Ordering};
26use std::sync::{Arc, RwLock};
27use std::time::{Duration, Instant};
28
29#[derive(Clone, Default)]
32pub struct IcmpBackoff {
33 inner: Arc<RwLock<HashMap<u32, BackoffState>>>,
34 pub blocked_total: Arc<AtomicU64>,
37}
38
39#[derive(Debug, Clone, Copy)]
40struct BackoffState {
41 count_in_window: u32,
43 window_start: Instant,
45 blocked_until: Option<Instant>,
47}
48
49#[derive(Debug, Clone, Copy)]
52pub struct IcmpBackoffConfig {
53 pub window: Duration,
55 pub burst_threshold: u32,
57 pub backoff: Duration,
59}
60
61impl Default for IcmpBackoffConfig {
62 fn default() -> Self {
63 Self {
64 window: Duration::from_secs(2),
65 burst_threshold: 8,
66 backoff: Duration::from_secs(30),
67 }
68 }
69}
70
71impl IcmpBackoff {
72 #[must_use]
74 pub fn new() -> Self {
75 Self::default()
76 }
77
78 #[inline]
80 #[must_use]
81 pub fn slash24_of(ip: Ipv4Addr) -> u32 {
82 let o = ip.octets();
83 u32::from_be_bytes([o[0], o[1], o[2], 0])
84 }
85
86 pub fn feed(&self, slash24: u32, count: u32, cfg: IcmpBackoffConfig) -> bool {
89 self.feed_at(slash24, count, cfg, Instant::now())
90 }
91
92 pub fn feed_at(&self, slash24: u32, count: u32, cfg: IcmpBackoffConfig, now: Instant) -> bool {
94 let Ok(mut g) = self.inner.write() else {
95 return false;
96 };
97 let entry = g.entry(slash24).or_insert(BackoffState {
98 count_in_window: 0,
99 window_start: now,
100 blocked_until: None,
101 });
102
103 if now.duration_since(entry.window_start) > cfg.window {
105 entry.count_in_window = 0;
106 entry.window_start = now;
107 }
108 entry.count_in_window = entry.count_in_window.saturating_add(count);
109
110 if entry.blocked_until.map_or(false, |u| u > now) {
112 return true;
113 }
114
115 if entry.count_in_window >= cfg.burst_threshold {
116 entry.blocked_until = Some(now + cfg.backoff);
117 self.blocked_total.fetch_add(1, Ordering::Relaxed);
118 return true;
119 }
120 false
121 }
122
123 #[inline]
126 #[must_use]
127 pub fn is_blocked(&self, slash24: u32) -> bool {
128 self.is_blocked_at(slash24, Instant::now())
129 }
130
131 #[must_use]
133 pub fn is_blocked_at(&self, slash24: u32, now: Instant) -> bool {
134 let Ok(g) = self.inner.read() else {
135 return false;
136 };
137 g.get(&slash24)
138 .and_then(|s| s.blocked_until)
139 .map_or(false, |u| u > now)
140 }
141
142 pub fn prune(&self, cfg: IcmpBackoffConfig) {
145 let now = Instant::now();
146 if let Ok(mut g) = self.inner.write() {
147 g.retain(|_, s| {
148 s.blocked_until.map_or(false, |u| u > now)
149 || now.duration_since(s.window_start) <= cfg.window
150 });
151 }
152 }
153}
154
155#[cfg(test)]
156mod tests {
157 use super::*;
158
159 fn cfg() -> IcmpBackoffConfig {
160 IcmpBackoffConfig {
161 window: Duration::from_secs(1),
162 burst_threshold: 5,
163 backoff: Duration::from_secs(10),
164 }
165 }
166
167 #[test]
168 fn slash24_packs_correctly() {
169 assert_eq!(
170 IcmpBackoff::slash24_of(Ipv4Addr::new(10, 1, 2, 3)),
171 u32::from_be_bytes([10, 1, 2, 0])
172 );
173 }
174
175 #[test]
176 fn single_event_does_not_trip() {
177 let b = IcmpBackoff::new();
178 let s = IcmpBackoff::slash24_of(Ipv4Addr::new(192, 168, 1, 1));
179 assert!(!b.feed(s, 1, cfg()));
180 assert!(!b.is_blocked(s));
181 }
182
183 #[test]
184 fn burst_threshold_flips_into_backoff() {
185 let b = IcmpBackoff::new();
186 let s = IcmpBackoff::slash24_of(Ipv4Addr::new(192, 168, 1, 1));
187 let now = Instant::now();
188 for _ in 0..4 {
189 assert!(!b.feed_at(s, 1, cfg(), now));
190 }
191 assert!(b.feed_at(s, 1, cfg(), now));
193 assert!(b.is_blocked_at(s, now));
194 assert_eq!(b.blocked_total.load(Ordering::Relaxed), 1);
195 }
196
197 #[test]
198 fn backoff_expires_after_window() {
199 let b = IcmpBackoff::new();
200 let c = cfg();
201 let s = IcmpBackoff::slash24_of(Ipv4Addr::new(10, 0, 0, 1));
202 let now = Instant::now();
203 b.feed_at(s, 5, c, now);
204 assert!(b.is_blocked_at(s, now));
205 let later = now + c.backoff + Duration::from_millis(1);
206 assert!(!b.is_blocked_at(s, later));
207 }
208
209 #[test]
210 fn rolling_window_decays_count() {
211 let b = IcmpBackoff::new();
212 let c = cfg();
213 let s = IcmpBackoff::slash24_of(Ipv4Addr::new(10, 0, 0, 2));
214 let t0 = Instant::now();
215 b.feed_at(s, 3, c, t0);
217 let after_window = t0 + c.window + Duration::from_millis(1);
218 let tripped = b.feed_at(s, 3, c, after_window);
219 assert!(!tripped, "old window must not contribute to threshold");
220 }
221
222 #[test]
223 fn unrelated_slash24_unaffected() {
224 let b = IcmpBackoff::new();
225 let a = IcmpBackoff::slash24_of(Ipv4Addr::new(10, 0, 0, 1));
226 let b_ip = IcmpBackoff::slash24_of(Ipv4Addr::new(10, 1, 0, 1));
227 let now = Instant::now();
228 for _ in 0..6 {
229 b.feed_at(a, 1, cfg(), now);
230 }
231 assert!(b.is_blocked_at(a, now));
232 assert!(!b.is_blocked_at(b_ip, now));
233 }
234
235 #[test]
236 fn prune_drops_stale_unblocked_entries() {
237 let b = IcmpBackoff::new();
238 let c = cfg();
239 let s = IcmpBackoff::slash24_of(Ipv4Addr::new(10, 0, 0, 5));
240 b.feed_at(s, 1, c, Instant::now());
241 std::thread::sleep(c.window + Duration::from_millis(10));
243 b.prune(c);
244 let g = b.inner.read().unwrap();
245 assert!(!g.contains_key(&s));
246 }
247
248 #[test]
249 fn burst_count_can_be_supplied_in_one_call() {
250 let b = IcmpBackoff::new();
251 let s = IcmpBackoff::slash24_of(Ipv4Addr::new(10, 0, 0, 1));
252 assert!(b.feed(s, 100, cfg()));
254 assert!(b.is_blocked(s));
255 }
256}