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}