Skip to main content

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