Skip to main content

hashiverse_server_lib/transport/ddos/
ipset_ddos.rs

1//! # Kernel-level DDoS protection backed by Linux `ipset` + `iptables`
2//!
3//! The production implementation of
4//! [`hashiverse_lib::transport::ddos::ddos::DdosProtection`] used by the real server.
5//! Layered on top of the in-RAM scoring logic from `hashiverse-lib`:
6//!
7//! 1. Per-IP `DdosScore` accumulates penalties for bad requests (e.g. invalid PoW,
8//!    malformed packets) with linear time decay from
9//!    [`hashiverse_lib::tools::config::SERVER_DDOS_DECAY_PER_SECOND`].
10//! 2. When a score crosses
11//!    [`hashiverse_lib::tools::config::SERVER_DDOS_SCORE_THRESHOLD`], the IP is
12//!    shelled out to `ipset add` against the set named by
13//!    [`hashiverse_lib::tools::config::SERVER_DDOS_IPSET_SET_NAME`], which an
14//!    operator-configured `iptables` rule then drops at the kernel.
15//! 3. A short (≥10 s) throttle around the `ipset` call prevents hammering the
16//!    subprocess in edge cases.
17//!
18//! Per-IP concurrent-connection caps are enforced via a `HashMap<String, usize>`
19//! guarded by a `parking_lot::Mutex`, cutting off a single IP from monopolising all
20//! [`hashiverse_lib::tools::config::SERVER_DDOS_MAX_CONNECTIONS_PER_IP`] slots. The
21//! `NET_ADMIN` capability is required on the container — see the operator docs.
22
23use hashiverse_lib::transport::ddos::ddos::{DdosProtection, DdosScore};
24use parking_lot::Mutex;
25use log::{info, warn};
26use moka::sync::Cache;
27use std::collections::HashMap;
28use std::process::Command;
29use std::sync::Arc;
30use std::time::Duration;
31
32/// Production DDoS protection backed by Linux `ipset`.
33///
34/// Per-IP scores use linear time decay: each `allow_request` adds 1.0 point,
35/// each `report_bad_request` adds `bad_request_penalty` points, and the score
36/// drains at `decay_per_second` points/second.  This means sustained low-rate
37/// traffic stabilises below the threshold while bursts trigger quickly.
38///
39/// When a score first crosses `score_threshold`, the IP is added to the named
40/// ipset via `ipset add <set_name> <ip> --exist`.  A second 10-second moka cache
41/// (`ipset_throttle`) prevents hammering the ipset command.
42///
43/// `try_acquire_connection` additionally enforces a per-IP connection cap
44/// (`max_connections_per_ip`).
45pub struct IpsetDdosProtection {
46    set_name: String,
47    score_threshold: f64,
48    decay_per_second: f64,
49    bad_request_penalty: f64,
50    max_connections_per_ip: usize,
51    scores: Cache<String, Arc<Mutex<DdosScore>>>,
52    ipset_throttle: Cache<String, ()>,
53    connections: Mutex<HashMap<String, usize>>,
54}
55
56impl IpsetDdosProtection {
57    pub fn new(set_name: impl Into<String>, score_threshold: f64, decay_per_second: f64, bad_request_penalty: f64, max_connections_per_ip: usize) -> Self {
58        let set_name = set_name.into();
59
60        // Idle expiry: time for a maxed-out score to fully decay, with 2x margin
61        let idle_secs = if decay_per_second > 0.0 {
62            (score_threshold / decay_per_second * 2.0).ceil() as u64
63        } else {
64            3600
65        };
66
67        let result = Command::new("ipset").args(["create", &set_name, "hash:ip", "timeout", &idle_secs.to_string(), "--exist"]).status();
68        match result {
69            Ok(status) if status.success() => info!("DDoS: ipset set '{}' ready", set_name),
70            Ok(status) => warn!("DDoS: ipset create '{}' failed with status {}", set_name, status),
71            Err(e) => warn!("DDoS: failed to run ipset create '{}': {}", set_name, e),
72        }
73
74        // Set up our iptables rules for both docker (FORWARD) and metal (INPUT) servers.
75        for chain in ["INPUT", "FORWARD"] {
76            // -D silently fails if the rule doesn't exist, so delete then re-insert is safe
77            let _ = Command::new("iptables").args(["-D", chain, "-m", "set", "--match-set", &set_name, "src", "-j", "DROP"]).status();
78            let result = Command::new("iptables").args(["-I", chain, "-m", "set", "--match-set", &set_name, "src", "-j", "DROP"]).status();
79            match result {
80                Ok(status) if status.success() => info!("DDoS: iptables {} rule for '{}' installed", chain, set_name),
81                Ok(status) => warn!("DDoS: iptables -I {} for '{}' failed with status {}", chain, set_name, status),
82                Err(e) => warn!("DDoS: failed to run iptables {} for '{}': {}", chain, set_name, e),
83            }
84        }
85
86        Self {
87            set_name,
88            score_threshold,
89            decay_per_second,
90            bad_request_penalty,
91            max_connections_per_ip,
92            scores: Cache::builder().time_to_idle(Duration::from_secs(idle_secs)).build(),
93            ipset_throttle: Cache::builder().time_to_live(Duration::from_secs(10)).build(),
94            connections: Mutex::new(HashMap::new()),
95        }
96    }
97
98    fn increment_score(&self, ip: &str, points: f64) -> f64 {
99        let entry = self.scores.get_with(ip.to_string(), || Arc::new(Mutex::new(DdosScore::new())));
100        entry.lock().increment(points, self.decay_per_second)
101    }
102
103    fn is_score_banned(&self, ip: &str) -> bool {
104        self.scores
105            .get(ip)
106            .map(|entry| entry.lock().current(self.decay_per_second) >= self.score_threshold)
107            .unwrap_or(false)
108    }
109
110    fn maybe_call_ipset(&self, ip: &str) {
111        // Have we already spawned this IP address?
112        {
113            if self.ipset_throttle.contains_key(ip) {
114                return;
115            }
116            self.ipset_throttle.insert(ip.to_string(), ());
117        }
118
119        // Add to ipset asynchronously
120        {
121            let set_name = self.set_name.clone();
122            let ip = ip.to_string();
123
124            tokio::spawn(async move {
125                info!("Banning DDoS ip: {}", ip);
126                match tokio::process::Command::new("ipset").args(["add", &set_name, &ip, "--exist"]).status().await {
127                    Ok(status) if status.success() => info!("DDoS: banned {} via ipset set '{}'", ip, set_name),
128                    Ok(status) => warn!("DDoS: ipset add {} failed with status {}", ip, status),
129                    Err(e) => warn!("DDoS: failed to run ipset for {}: {}", ip, e),
130                }
131            });
132        }
133    }
134}
135
136impl DdosProtection for IpsetDdosProtection {
137    fn allow_request(&self, ip: &str) -> bool {
138        let score = self.increment_score(ip, 1.0);
139        if score >= self.score_threshold {
140            self.maybe_call_ipset(ip);
141            false
142        }
143        else {
144            true
145        }
146    }
147
148    fn report_bad_request(&self, ip: &str) {
149        let score = self.increment_score(ip, self.bad_request_penalty);
150        if score >= self.score_threshold {
151            self.maybe_call_ipset(ip);
152        }
153    }
154
155    fn try_acquire_connection(&self, ip: &str) -> bool {
156        if self.is_score_banned(ip) {
157            return false;
158        }
159        let mut connections = self.connections.lock();
160        let count = connections.entry(ip.to_string()).or_insert(0);
161        if *count >= self.max_connections_per_ip {
162            return false;
163        }
164        *count += 1;
165        true
166    }
167
168    fn release_connection(&self, ip: &str) {
169        let mut connections = self.connections.lock();
170        if let Some(count) = connections.get_mut(ip) {
171            *count = count.saturating_sub(1);
172            if *count == 0 {
173                connections.remove(ip);
174            }
175        }
176    }
177}