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}