Skip to main content

rust_web_server/blocklist/
mod.rs

1//! Runtime IP blocklist middleware.
2//!
3//! Unlike [`crate::ip_filter::IpFilter`] (configured at startup), `Blocklist`
4//! is mutable at runtime — add and remove IPs while the server is running.
5//! The global singleton is accessible from MCP tools, admin handlers, and
6//! middleware without passing explicit references.
7//!
8//! # Example
9//!
10//! ```rust,no_run
11//! use rust_web_server::app::App;
12//! use rust_web_server::core::New;
13//! use rust_web_server::blocklist::{self, BlocklistLayer};
14//!
15//! let app = App::new().wrap(BlocklistLayer);
16//!
17//! // Block an IP at runtime.
18//! blocklist::global().block("1.2.3.4");
19//!
20//! // Unblock later.
21//! blocklist::global().unblock("1.2.3.4");
22//! ```
23
24#[cfg(test)]
25mod tests;
26
27use std::sync::atomic::{AtomicUsize, Ordering};
28use std::sync::{Mutex, OnceLock};
29
30use crate::application::Application;
31use crate::error::{AppError, IntoResponse};
32use crate::middleware::Middleware;
33use crate::request::Request;
34use crate::response::Response;
35use crate::server::ConnectionInfo;
36
37/// A thread-safe list of blocked IPv4 addresses.
38pub struct Blocklist {
39    denied: Mutex<Vec<String>>,
40    /// `denied.len()`, kept under its lock: with nothing blocked (the usual
41    /// case) `is_blocked` takes no lock — it was a process-wide mutex taken
42    /// on every request by `BlocklistLayer`.
43    count: AtomicUsize,
44}
45
46impl Blocklist {
47    fn new() -> Self {
48        Blocklist { denied: Mutex::new(Vec::new()), count: AtomicUsize::new(0) }
49    }
50
51    /// Add `ip` to the blocklist. No-op if already present.
52    pub fn block(&self, ip: &str) {
53        let mut guard = self.denied.lock().unwrap();
54        if !guard.iter().any(|e| e == ip) {
55            guard.push(ip.to_string());
56        }
57        self.count.store(guard.len(), Ordering::Relaxed);
58    }
59
60    /// Remove `ip` from the blocklist. No-op if not present.
61    pub fn unblock(&self, ip: &str) {
62        let mut guard = self.denied.lock().unwrap();
63        guard.retain(|e| e != ip);
64        self.count.store(guard.len(), Ordering::Relaxed);
65    }
66
67    /// `true` if `ip` is currently blocked.
68    pub fn is_blocked(&self, ip: &str) -> bool {
69        if self.count.load(Ordering::Relaxed) == 0 {
70            return false;
71        }
72        self.denied.lock().unwrap().iter().any(|e| e == ip)
73    }
74
75    /// Snapshot of all blocked IPs in insertion order.
76    pub fn list(&self) -> Vec<String> {
77        self.denied.lock().unwrap().clone()
78    }
79
80    /// Remove all entries.
81    pub fn clear(&self) {
82        let mut guard = self.denied.lock().unwrap();
83        guard.clear();
84        self.count.store(0, Ordering::Relaxed);
85    }
86}
87
88static INSTANCE: OnceLock<Blocklist> = OnceLock::new();
89
90/// Return the process-wide `Blocklist` singleton.
91pub fn global() -> &'static Blocklist {
92    INSTANCE.get_or_init(Blocklist::new)
93}
94
95/// Middleware that checks each request's client IP against [`global()`].
96///
97/// Blocked IPs receive `403 Forbidden`. All other requests pass through
98/// to the next layer.
99pub struct BlocklistLayer;
100
101impl Middleware for BlocklistLayer {
102    /// Headers only: a streamed body passes through untouched.
103    fn accepts_streamed_body(&self, _request: &Request, _connection: &ConnectionInfo) -> bool {
104        true
105    }
106
107
108    fn handle(
109        &self,
110        request: &Request,
111        connection: &ConnectionInfo,
112        next: &dyn Application,
113    ) -> Result<Response, String> {
114        if global().is_blocked(&connection.client.ip) {
115            return Ok(AppError::Forbidden.into_response());
116        }
117        next.execute(request, connection)
118    }
119
120    /// In-memory only: safe on the event loop.
121    fn runs_inline(&self, _request: &Request, _connection: &ConnectionInfo) -> bool {
122        true
123    }
124}