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 std::time::Instant;
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.
30pub struct DdosScore {
31    score: f64,
32    last_updated: Instant,
33}
34
35impl DdosScore {
36    pub fn new() -> Self {
37        Self { score: 0.0, last_updated: Instant::now() }
38    }
39
40    /// Decay the score based on elapsed time, then add `points`. Returns the new score.
41    pub fn increment(&mut self, points: f64, decay_per_second: f64) -> f64 {
42        let now = Instant::now();
43        let elapsed_secs = now.duration_since(self.last_updated).as_secs_f64();
44        self.score = (self.score - decay_per_second * elapsed_secs).max(0.0) + points;
45        self.last_updated = now;
46        self.score
47    }
48
49    /// Read the decayed score without modifying it.
50    pub fn current(&self, decay_per_second: f64) -> f64 {
51        let elapsed_secs = self.last_updated.elapsed().as_secs_f64();
52        (self.score - decay_per_second * elapsed_secs).max(0.0)
53    }
54}
55
56/// RAII guard for a single IP's connection slot.
57///
58/// Created by `DdosConnectionGuard::try_new`; dropped when the connection ends.
59/// While alive it holds a slot in the per-IP connection counter.
60/// Exposes `allow_request` and `report_bad_request` so callers never need to
61/// pass a raw `Arc<dyn DdosProtection>` through request handling code.
62pub struct DdosConnectionGuard {
63    ip: String,
64    ddos: Arc<dyn DdosProtection>,
65}
66
67impl DdosConnectionGuard {
68    /// Try to acquire a connection slot for `ip`.
69    ///
70    /// Returns `None` if the IP is over the per-IP connection cap or is already
71    /// rate-limited.  Returns `Some(guard)` on success; the slot is released
72    /// automatically when the guard is dropped.
73    pub fn try_new(ddos: Arc<dyn DdosProtection>, ip: impl Into<String>) -> Option<Self> {
74        let ip = ip.into();
75        if ddos.try_acquire_connection(&ip) {
76            Some(Self { ip, ddos })
77        } else {
78            None
79        }
80    }
81
82    pub fn ip(&self) -> &str {
83        &self.ip
84    }
85
86    /// Returns `true` if the next request from this connection should be processed.
87    pub fn allow_request(&self) -> bool {
88        self.ddos.allow_request(&self.ip)
89    }
90
91    /// Report that a request from this connection was malformed or malicious.
92    pub fn report_bad_request(&self) {
93        self.ddos.report_bad_request(&self.ip)
94    }
95}
96
97impl Drop for DdosConnectionGuard {
98    fn drop(&mut self) {
99        self.ddos.release_connection(&self.ip);
100    }
101}
102
103/// The server-side throttle and abuse-mitigation policy for inbound connections.
104///
105/// Every inbound request is gated through a `DdosProtection` implementation: the server asks
106/// `try_acquire_connection` when a new connection lands, `allow_request` before processing
107/// each request, and calls `report_bad_request` when a packet fails validation (bad PoW,
108/// malformed RPC framing, signature mismatch, …). Implementations accumulate a per-IP score
109/// from reported bad requests and refuse further connections above a threshold.
110///
111/// Pairing with [`DdosConnectionGuard`] means call sites never have to pass a raw
112/// `Arc<dyn DdosProtection>` through every layer of the request handler — the guard carries
113/// a handle to this trait through `IncomingRequest` and releases its slot automatically
114/// on drop. A `NoopDdosProtection` exists for tests that do not want to exercise rate limiting.
115pub trait DdosProtection: Send + Sync {
116    /// Returns `true` if the request from `ip` should be processed, `false` if it should be
117    /// dropped immediately.
118    fn allow_request(&self, ip: &str) -> bool;
119
120    /// Notify the implementation that a request from `ip` was rejected.  Implementations
121    /// should use this to accumulate evidence and eventually ban repeat offenders.
122    fn report_bad_request(&self, ip: &str);
123
124    /// Try to acquire a connection slot for `ip`, checking both the ban score and the
125    /// per-IP connection cap.  Returns `true` and increments the connection count on
126    /// success.  Returns `false` if the IP is blocked or over the per-IP cap.
127    ///
128    /// Must be paired with `release_connection` — this is handled automatically by
129    /// `DdosConnectionGuard::drop`.
130    fn try_acquire_connection(&self, ip: &str) -> bool;
131
132    /// Release a connection slot previously acquired by `try_acquire_connection`.
133    /// Called automatically by `DdosConnectionGuard::drop`.
134    fn release_connection(&self, ip: &str);
135}