Skip to main content

hashiverse_lib/transport/ddos/
ddos.rs

1//! # Per-IP DDoS scoring and connection-slot accounting
2//!
3//! Defines three collaborating pieces:
4//!
5//! - [`DdosScore`] — per-IP score with linear time-decay (drains
6//!   [`crate::tools::config::SERVER_DDOS_DECAY_PER_SECOND`] points per second). Bad
7//!   requests add points, good requests don't, and once the score crosses
8//!   [`crate::tools::config::SERVER_DDOS_SCORE_THRESHOLD`] the IP is banned. Decay is
9//!   lazy so there's no background timer — every `increment` or `current` call
10//!   recomputes based on elapsed real time.
11//! - [`DdosConnectionGuard`] — an RAII guard returned to the transport for every open
12//!   connection slot. Its `Drop` decrements the per-IP connection count. Request
13//!   processing code threads this guard through `IncomingRequest`, so the moment the
14//!   request is finished the slot is freed, automatically.
15//! - [`DdosProtection`] — the overall trait the transport calls into:
16//!   `try_accept_connection` (returns `None` if over limit), `observe_bad_request`,
17//!   `ban` (pokes `ipset`/`iptables` via the sibling server crate on native).
18//!
19//! Two implementations exist:
20//! [`crate::transport::ddos::mem_ddos`] for accounting-only deployments and tests, and
21//! the native ipset-backed production implementation lives in `hashiverse-server-lib`.
22
23use std::sync::Arc;
24use crate::tools::time::TimeMillis;
25
26/// Per-IP score with linear time decay.
27///
28/// Score drains at `decay_per_second` points per second. The decay is applied
29/// lazily on each `increment` or `current` call — no background timer needed —
30/// against a caller-supplied `now` (drawn from the pluggable `TimeProvider`), so
31/// it stays consistent with the rest of the system under a scaled test clock.
32pub struct DdosScore {
33    score: f64,
34    /// `None` until the first `increment`, so a freshly-created score applies no decay.
35    last_updated: Option<TimeMillis>,
36}
37
38impl Default for DdosScore {
39    fn default() -> Self {
40        Self::new()
41    }
42}
43
44impl DdosScore {
45    pub fn new() -> Self {
46        Self { score: 0.0, last_updated: None }
47    }
48
49    fn elapsed_secs(&self, now: TimeMillis) -> f64 {
50        match self.last_updated {
51            Some(prev) => (now.0.saturating_sub(prev.0).max(0) as f64) / 1000.0,
52            None => 0.0,
53        }
54    }
55
56    /// Decay the score based on elapsed time, then add `points`. Returns the new score.
57    pub fn increment(&mut self, points: f64, decay_per_second: f64, now: TimeMillis) -> f64 {
58        self.score = (self.score - decay_per_second * self.elapsed_secs(now)).max(0.0) + points;
59        self.last_updated = Some(now);
60        self.score
61    }
62
63    /// Read the decayed score without modifying it.
64    pub fn current(&self, decay_per_second: f64, now: TimeMillis) -> f64 {
65        (self.score - decay_per_second * self.elapsed_secs(now)).max(0.0)
66    }
67}
68
69/// RAII guard for a single IP's connection slot.
70///
71/// Created by `DdosConnectionGuard::try_new`; dropped when the connection ends.
72/// While alive it holds a slot in the per-IP connection counter.
73/// Exposes `allow_request` and `report_bad_request` so callers never need to
74/// pass a raw `Arc<dyn DdosProtection>` through request handling code.
75pub struct DdosConnectionGuard {
76    ip: String,
77    ddos: Arc<dyn DdosProtection>,
78}
79
80impl DdosConnectionGuard {
81    /// Try to acquire a connection slot for `ip`.
82    ///
83    /// Returns `None` if the IP is over the per-IP connection cap or is already
84    /// rate-limited.  Returns `Some(guard)` on success; the slot is released
85    /// automatically when the guard is dropped.
86    pub fn try_new(ddos: Arc<dyn DdosProtection>, ip: impl Into<String>) -> Option<Self> {
87        let ip = ip.into();
88        if ddos.try_acquire_connection(&ip) {
89            Some(Self { ip, ddos })
90        } else {
91            None
92        }
93    }
94
95    pub fn ip(&self) -> &str {
96        &self.ip
97    }
98
99    /// Returns `true` if the next request from this connection should be processed.
100    pub fn allow_request(&self) -> bool {
101        self.ddos.allow_request(&self.ip)
102    }
103
104    /// Report that a request from this connection was malformed or malicious.
105    pub fn report_bad_request(&self) {
106        self.ddos.report_bad_request(&self.ip)
107    }
108}
109
110impl Drop for DdosConnectionGuard {
111    fn drop(&mut self) {
112        self.ddos.release_connection(&self.ip);
113    }
114}
115
116/// The server-side throttle and abuse-mitigation policy for inbound connections.
117///
118/// Every inbound request is gated through a `DdosProtection` implementation: the server asks
119/// `try_acquire_connection` when a new connection lands, `allow_request` before processing
120/// each request, and calls `report_bad_request` when a packet fails validation (bad PoW,
121/// malformed RPC framing, signature mismatch, …). Implementations accumulate a per-IP score
122/// from reported bad requests and refuse further connections above a threshold.
123///
124/// Pairing with [`DdosConnectionGuard`] means call sites never have to pass a raw
125/// `Arc<dyn DdosProtection>` through every layer of the request handler — the guard carries
126/// a handle to this trait through `IncomingRequest` and releases its slot automatically
127/// on drop. A `NoopDdosProtection` exists for tests that do not want to exercise rate limiting.
128pub trait DdosProtection: Send + Sync {
129    /// Returns `true` if the request from `ip` should be processed, `false` if it should be
130    /// dropped immediately.
131    fn allow_request(&self, ip: &str) -> bool;
132
133    /// Notify the implementation that a request from `ip` was rejected.  Implementations
134    /// should use this to accumulate evidence and eventually ban repeat offenders.
135    fn report_bad_request(&self, ip: &str);
136
137    /// Try to acquire a connection slot for `ip`, checking both the ban score and the
138    /// per-IP connection cap.  Returns `true` and increments the connection count on
139    /// success.  Returns `false` if the IP is blocked or over the per-IP cap.
140    ///
141    /// Must be paired with `release_connection` — this is handled automatically by
142    /// `DdosConnectionGuard::drop`.
143    fn try_acquire_connection(&self, ip: &str) -> bool;
144
145    /// Release a connection slot previously acquired by `try_acquire_connection`.
146    /// Called automatically by `DdosConnectionGuard::drop`.
147    fn release_connection(&self, ip: &str);
148}
149
150#[cfg(test)]
151mod tests {
152    use super::DdosScore;
153    use crate::tools::time::TimeMillis;
154
155    #[test]
156    fn fresh_score_has_no_decay_on_first_increment() {
157        // last_updated is None initially, so the first increment applies no decay
158        // regardless of decay_per_second or the supplied `now`.
159        let mut score = DdosScore::new();
160        assert_eq!(score.increment(5.0, 1000.0, TimeMillis(1_000_000)), 5.0);
161    }
162
163    #[test]
164    fn score_decays_with_elapsed_time() {
165        let decay_per_second = 2.0;
166        let mut score = DdosScore::new();
167
168        // t=0: +10 -> 10.0 (no decay on first increment)
169        assert_eq!(score.increment(10.0, decay_per_second, TimeMillis(0)), 10.0);
170
171        // 3s later: read decays 2.0 * 3 = 6.0 -> 4.0, without mutating.
172        assert_eq!(score.current(decay_per_second, TimeMillis(3_000)), 4.0);
173        // current() must not have mutated the stored score.
174        assert_eq!(score.current(decay_per_second, TimeMillis(0)), 10.0);
175
176        // 10s later: 2.0 * 10 = 20 fully drains it (floored at 0), then +1 -> 1.0
177        assert_eq!(score.increment(1.0, decay_per_second, TimeMillis(10_000)), 1.0);
178    }
179
180    #[test]
181    fn zero_decay_never_drains() {
182        let mut score = DdosScore::new();
183        score.increment(3.0, 0.0, TimeMillis(0));
184        assert_eq!(score.current(0.0, TimeMillis(1_000_000_000)), 3.0);
185    }
186}