Skip to main content

gossan_engine/
icmp_backoff.rs

1//! ICMP-unreachable per-/24 backoff consumer.
2//!
3//! The pre-existing `Slash24Backoff` in `scan.rs` reacts to RST bursts.
4//! This module is the parallel consumer for ICMP "destination
5//! unreachable" packets — a strong signal that a router or border
6//! firewall is shedding load and we should slow down on the entire
7//! `/24` rather than burn TX budget per port.
8//!
9//! ## Status: consumer wired, source pending
10//!
11//! `netforge::EngineStats` does not yet expose an
12//! `icmp_unreachable_per_sec` counter, and `RxPacket` does not surface
13//! ICMP packets. So this module:
14//!
15//! 1. Implements the full backoff state machine + tests, and
16//! 2. Exposes a `feed(slash24, count)` entry point that any source
17//!    can call (a future netforge ICMP RX path, or a separate raw
18//!    socket reader running alongside the TCP RX).
19//!
20//! When `netforge` lands ICMP surfacing this consumer plugs in
21//! without further changes — that is open work, not deferred.
22
23use 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/// ICMP-unreachable backoff table. Same shape as `Slash24Backoff` so
30/// the TX hot path can consult both with identical cost.
31#[derive(Clone, Default)]
32pub struct IcmpBackoff {
33    inner: Arc<RwLock<HashMap<u32, BackoffState>>>,
34    /// Total /24s currently in active backoff at any tick. Useful as a
35    /// scan-level health metric.
36    pub blocked_total: Arc<AtomicU64>,
37}
38
39#[derive(Debug, Clone, Copy)]
40struct BackoffState {
41    /// ICMP unreachables observed in the current rolling window.
42    count_in_window: u32,
43    /// Window start.
44    window_start: Instant,
45    /// When the /24 leaves backoff. None = not currently blocked.
46    blocked_until: Option<Instant>,
47}
48
49/// Tunable parameters. Sized for typical commercial-network behavior;
50/// tightening can be done at construction.
51#[derive(Debug, Clone, Copy)]
52pub struct IcmpBackoffConfig {
53    /// Rolling window length over which ICMP counts accumulate.
54    pub window: Duration,
55    /// Threshold inside `window` that flips a /24 into backoff.
56    pub burst_threshold: u32,
57    /// How long a /24 stays in backoff after being tripped.
58    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    /// Empty table.
73    #[must_use]
74    pub fn new() -> Self {
75        Self::default()
76    }
77
78    /// Convert an IPv4 address to the /24 key used by this table.
79    #[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    /// Feed `count` ICMP-unreachable observations for `slash24`.
87    /// Returns `true` if the /24 is now (or was already) in backoff.
88    pub fn feed(&self, slash24: u32, count: u32, cfg: IcmpBackoffConfig) -> bool {
89        self.feed_at(slash24, count, cfg, Instant::now())
90    }
91
92    /// Test-friendly variant — explicit `now` so we can simulate time.
93    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        // Decay window.
104        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        // Already in active backoff?
111        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    /// Read-only check for the TX hot path. Lock-light: returns false
124    /// on any contention rather than blocking.
125    #[inline]
126    #[must_use]
127    pub fn is_blocked(&self, slash24: u32) -> bool {
128        self.is_blocked_at(slash24, Instant::now())
129    }
130
131    /// Test-friendly check — explicit `now`.
132    #[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    /// Drop entries that are out of window AND not currently blocked.
143    /// Call from a 1Hz prune thread; cheap when the map is small.
144    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        // 5th observation hits threshold.
192        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        // 3 events at t0, then 3 events well after window — should NOT trip.
216        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        // Sleep past the window so the entry is stale.
242        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        // Single feed of 100 unreachables — must trip on the spot.
253        assert!(b.feed(s, 100, cfg()));
254        assert!(b.is_blocked(s));
255    }
256}