Skip to main content

sysknife_daemon/actions/
fail2ban.rs

1//! fail2ban intrusion-prevention actions (Ubuntu / Debian).
2//!
3//! fail2ban is an intrusion-prevention tool that monitors log files and bans
4//! IP addresses that exhibit suspicious behaviour (e.g. repeated failed SSH
5//! logins). These actions use the `fail2ban-client` CLI.
6//!
7//! ## Jail terminology
8//!
9//! A **jail** is a named set of rules (filter + action) that monitors a
10//! specific service. For example, `sshd` watches `/var/log/auth.log` for SSH
11//! authentication failures.
12//!
13//! ## IP validation
14//!
15//! `Fail2banBanIp` and `Fail2banUnbanIp` validate the supplied IP address with
16//! `std::net::IpAddr::from_str` before constructing the `ActionSpec`. An
17//! invalid address returns `Err(InvalidIpAddress)` immediately so a bad value
18//! cannot reach the daemon.
19
20use std::net::IpAddr;
21use std::str::FromStr;
22
23use super::{command_mechanism, ActionSpec};
24use sysknife_types::RiskLevel;
25
26// ---------------------------------------------------------------------------
27// Error type
28// ---------------------------------------------------------------------------
29
30/// Returned when an input fails fail2ban-action validation.
31#[derive(Clone, Debug, PartialEq, Eq)]
32pub enum Fail2banError {
33    /// Supplied IP string is not a valid IPv4 or IPv6 address. Carries the
34    /// offending value so callers can surface an actionable diagnostic.
35    InvalidIpAddress(String),
36    /// Jail name failed `validated_safe_arg`-style allowlist check (rejects
37    /// shell metachars, leading dash, non-ASCII, oversize). Defense in depth
38    /// at the constructor — the executor also validates, but a future caller
39    /// (internal Rust use, fleet plan/execute path) cannot bypass this.
40    InvalidJail(String),
41}
42
43impl std::fmt::Display for Fail2banError {
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        match self {
46            Self::InvalidIpAddress(v) => write!(f, "invalid IP address: '{}'", v),
47            Self::InvalidJail(v) => write!(f, "invalid jail name: '{}'", v),
48        }
49    }
50}
51
52impl std::error::Error for Fail2banError {}
53
54/// Backwards-compat alias so existing call sites keep compiling.
55pub type InvalidIpAddress = Fail2banError;
56
57/// Allowlist for fail2ban jail names: alphanumeric + `_-` + `.` (no leading
58/// dash, no shell metachars, length ≤ 64). Mirrors the `validated_safe_arg`
59/// shape used at the executor seam.
60fn jail_is_valid(jail: &str) -> bool {
61    if jail.is_empty() || jail.len() > 64 {
62        return false;
63    }
64    if jail.starts_with('-') {
65        return false;
66    }
67    jail.chars()
68        .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'))
69}
70
71// ---------------------------------------------------------------------------
72// specs() — for action_consistency tests
73// ---------------------------------------------------------------------------
74
75/// Root-owned helper that writes a jail override to `/etc/fail2ban/jail.d/`.
76const JAIL_HELPER: &str = "/usr/lib/sysknife/fail2ban-jail-edit";
77
78/// Return one representative `ActionSpec` per fail2ban action name.
79pub fn specs() -> Vec<ActionSpec> {
80    vec![
81        fail2ban_status(None),
82        fail2ban_ban_ip("sshd", "192.0.2.1").expect("valid IP in specs()"),
83        fail2ban_unban_ip("sshd", "192.0.2.1").expect("valid IP in specs()"),
84        configure_fail2ban_jail("sshd", &["--maxretry".to_string(), "3".to_string()]),
85    ]
86}
87
88// ---------------------------------------------------------------------------
89// Action constructors
90// ---------------------------------------------------------------------------
91
92/// Show fail2ban jail status (`sudo fail2ban-client status [<jail>]`).
93///
94/// Risk: Low. Read-only; shows active jails, banned IPs, and hit counts.
95/// When `jail` is `None` the global status (list of all jails) is returned.
96/// When `jail` is `Some(name)` the detailed status for that jail is returned.
97pub fn fail2ban_status(jail: Option<&str>) -> ActionSpec {
98    let args: Vec<&str> = match jail {
99        Some(j) => vec!["fail2ban-client", "status", j],
100        None => vec!["fail2ban-client", "status"],
101    };
102    ActionSpec {
103        action_name: "Fail2banStatus",
104        mechanism: command_mechanism("sudo", args),
105        risk_level: RiskLevel::Low,
106        reboot_required: false,
107        rollback_available: false,
108    }
109}
110
111/// Ban an IP address in a fail2ban jail
112/// (`sudo fail2ban-client set <jail> banip <ip>`).
113///
114/// Risk: High. Immediately blocks all traffic from the IP for all services
115/// protected by the named jail. Banning a legitimate address can cause an
116/// outage (e.g. banning the admin's own IP on the `sshd` jail).
117///
118/// Returns `Err(InvalidIpAddress)` when `ip` is not a valid IPv4 or IPv6
119/// address.
120pub fn fail2ban_ban_ip(jail: &str, ip: &str) -> Result<ActionSpec, Fail2banError> {
121    if !jail_is_valid(jail) {
122        return Err(Fail2banError::InvalidJail(jail.to_string()));
123    }
124    IpAddr::from_str(ip).map_err(|_| Fail2banError::InvalidIpAddress(ip.to_string()))?;
125    Ok(ActionSpec {
126        action_name: "Fail2banBanIp",
127        mechanism: command_mechanism("sudo", ["fail2ban-client", "set", jail, "banip", ip]),
128        risk_level: RiskLevel::High,
129        reboot_required: false,
130        rollback_available: false,
131    })
132}
133
134/// Unban an IP address from a fail2ban jail
135/// (`sudo fail2ban-client set <jail> unbanip <ip>`).
136///
137/// Risk: Medium. Removes a ban, potentially re-admitting a previously blocked
138/// address. Reversible — the address can be banned again with `Fail2banBanIp`.
139///
140/// Returns `Err(InvalidIpAddress)` when `ip` is not a valid IPv4 or IPv6
141/// address.
142pub fn fail2ban_unban_ip(jail: &str, ip: &str) -> Result<ActionSpec, Fail2banError> {
143    if !jail_is_valid(jail) {
144        return Err(Fail2banError::InvalidJail(jail.to_string()));
145    }
146    IpAddr::from_str(ip).map_err(|_| Fail2banError::InvalidIpAddress(ip.to_string()))?;
147    Ok(ActionSpec {
148        action_name: "Fail2banUnbanIp",
149        mechanism: command_mechanism("sudo", ["fail2ban-client", "set", jail, "unbanip", ip]),
150        risk_level: RiskLevel::Medium,
151        reboot_required: false,
152        rollback_available: false,
153    })
154}
155
156/// Write a fail2ban jail override via the helper (`configure_fail2ban_jail`).
157///
158/// `extra` is a pre-validated list of `--maxretry`/`--bantime`/`--findtime`/
159/// `--enabled` flag pairs. The helper writes `/etc/fail2ban/jail.d/sysknife-<name>.local`,
160/// tests it with `fail2ban-client -t`, and reloads (rolling back on rejection).
161///
162/// Risk: High — tuning a jail changes who gets banned (too strict → locks out
163/// legitimate users; too loose → weakens brute-force protection).
164pub fn configure_fail2ban_jail(name: &str, extra: &[String]) -> ActionSpec {
165    let mut args = vec![
166        JAIL_HELPER.to_string(),
167        "--name".to_string(),
168        name.to_string(),
169    ];
170    args.extend(extra.iter().cloned());
171    ActionSpec {
172        action_name: "ConfigureFail2banJail",
173        mechanism: command_mechanism("sudo", args),
174        risk_level: RiskLevel::High,
175        reboot_required: false,
176        rollback_available: false,
177    }
178}
179
180// ---------------------------------------------------------------------------
181// Tests
182// ---------------------------------------------------------------------------
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187    use crate::actions::ActionMechanism;
188
189    fn extract_args(spec: &ActionSpec) -> (&'static str, Vec<String>) {
190        match &spec.mechanism {
191            ActionMechanism::Command { program, args } => (*program, args.clone()),
192            _ => panic!("expected Command mechanism"),
193        }
194    }
195
196    // ── fail2ban_status (global) ─────────────────────────────────────────────
197
198    #[test]
199    fn fail2ban_status_action_name() {
200        assert_eq!(fail2ban_status(None).action_name, "Fail2banStatus");
201    }
202
203    #[test]
204    fn fail2ban_status_risk_is_low() {
205        assert_eq!(fail2ban_status(None).risk_level, RiskLevel::Low);
206    }
207
208    #[test]
209    fn fail2ban_status_global_argv() {
210        let spec = fail2ban_status(None);
211        let (prog, args) = extract_args(&spec);
212        assert_eq!(prog, "sudo");
213        let a: Vec<&str> = args.iter().map(String::as_str).collect();
214        assert_eq!(a, vec!["fail2ban-client", "status"]);
215    }
216
217    // ── fail2ban_status (with jail) ──────────────────────────────────────────
218
219    #[test]
220    fn fail2ban_status_jail_argv() {
221        let spec = fail2ban_status(Some("sshd"));
222        let (prog, args) = extract_args(&spec);
223        assert_eq!(prog, "sudo");
224        let a: Vec<&str> = args.iter().map(String::as_str).collect();
225        assert_eq!(a, vec!["fail2ban-client", "status", "sshd"]);
226    }
227
228    // ── fail2ban_ban_ip ──────────────────────────────────────────────────────
229
230    #[test]
231    fn fail2ban_ban_ip_action_name() {
232        assert_eq!(
233            fail2ban_ban_ip("sshd", "10.0.0.1").unwrap().action_name,
234            "Fail2banBanIp"
235        );
236    }
237
238    #[test]
239    fn fail2ban_ban_ip_risk_is_high() {
240        assert_eq!(
241            fail2ban_ban_ip("sshd", "10.0.0.1").unwrap().risk_level,
242            RiskLevel::High
243        );
244    }
245
246    #[test]
247    fn fail2ban_ban_ip_argv_ordering() {
248        // argv must be: sudo fail2ban-client set <jail> banip <ip>
249        let spec = fail2ban_ban_ip("sshd", "198.51.100.42").unwrap();
250        let (prog, args) = extract_args(&spec);
251        assert_eq!(prog, "sudo");
252        let a: Vec<&str> = args.iter().map(String::as_str).collect();
253        assert_eq!(a[0], "fail2ban-client");
254        assert_eq!(a[1], "set");
255        assert_eq!(a[2], "sshd");
256        assert_eq!(a[3], "banip");
257        assert_eq!(a[4], "198.51.100.42");
258    }
259
260    #[test]
261    fn fail2ban_ban_ip_rejects_invalid_ip() {
262        let err = fail2ban_ban_ip("sshd", "not-an-ip").unwrap_err();
263        assert_eq!(
264            err,
265            Fail2banError::InvalidIpAddress("not-an-ip".to_string())
266        );
267    }
268
269    #[test]
270    fn fail2ban_ban_ip_rejects_jail_with_shell_metachars() {
271        // Defense in depth: the constructor itself rejects malformed jail names,
272        // not just the executor. A future internal Rust caller can't bypass.
273        let err = fail2ban_ban_ip("sshd; rm -rf /", "10.0.0.1").unwrap_err();
274        assert!(matches!(err, Fail2banError::InvalidJail(_)));
275    }
276
277    #[test]
278    fn fail2ban_ban_ip_rejects_leading_dash_jail() {
279        let err = fail2ban_ban_ip("--bypass", "10.0.0.1").unwrap_err();
280        assert!(matches!(err, Fail2banError::InvalidJail(_)));
281    }
282
283    #[test]
284    fn fail2ban_ban_ip_accepts_ipv6() {
285        let spec = fail2ban_ban_ip("sshd", "::1").unwrap();
286        let (_, args) = extract_args(&spec);
287        assert!(args.contains(&"::1".to_string()));
288    }
289
290    // ── fail2ban_unban_ip ────────────────────────────────────────────────────
291
292    #[test]
293    fn fail2ban_unban_ip_action_name() {
294        assert_eq!(
295            fail2ban_unban_ip("sshd", "10.0.0.1").unwrap().action_name,
296            "Fail2banUnbanIp"
297        );
298    }
299
300    #[test]
301    fn fail2ban_unban_ip_risk_is_medium() {
302        assert_eq!(
303            fail2ban_unban_ip("sshd", "10.0.0.1").unwrap().risk_level,
304            RiskLevel::Medium
305        );
306    }
307
308    #[test]
309    fn fail2ban_unban_ip_argv_ordering() {
310        // argv must be: sudo fail2ban-client set <jail> unbanip <ip>
311        let spec = fail2ban_unban_ip("nginx-http-auth", "203.0.113.7").unwrap();
312        let (prog, args) = extract_args(&spec);
313        assert_eq!(prog, "sudo");
314        let a: Vec<&str> = args.iter().map(String::as_str).collect();
315        assert_eq!(a[0], "fail2ban-client");
316        assert_eq!(a[1], "set");
317        assert_eq!(a[2], "nginx-http-auth");
318        assert_eq!(a[3], "unbanip");
319        assert_eq!(a[4], "203.0.113.7");
320    }
321
322    #[test]
323    fn fail2ban_unban_ip_rejects_invalid_ip() {
324        let err = fail2ban_unban_ip("sshd", "256.0.0.1").unwrap_err();
325        assert_eq!(
326            err,
327            Fail2banError::InvalidIpAddress("256.0.0.1".to_string())
328        );
329    }
330
331    #[test]
332    fn fail2ban_unban_ip_rejects_invalid_jail() {
333        let err = fail2ban_unban_ip("bad jail name", "10.0.0.1").unwrap_err();
334        assert!(matches!(err, Fail2banError::InvalidJail(_)));
335    }
336
337    #[test]
338    fn configure_jail_routes_through_helper() {
339        let spec = configure_fail2ban_jail("sshd", &["--maxretry".to_string(), "3".to_string()]);
340        let (program, args) = extract_args(&spec);
341        assert_eq!(program, "sudo");
342        assert_eq!(args, vec![JAIL_HELPER, "--name", "sshd", "--maxretry", "3"]);
343        assert_eq!(spec.risk_level, RiskLevel::High);
344    }
345
346    // ── specs() completeness ─────────────────────────────────────────────────
347
348    #[test]
349    fn specs_covers_all_action_names() {
350        let expected = [
351            "Fail2banStatus",
352            "Fail2banBanIp",
353            "Fail2banUnbanIp",
354            "ConfigureFail2banJail",
355        ];
356        let spec_names: Vec<&str> = specs().iter().map(|s| s.action_name).collect();
357        for name in &expected {
358            assert!(spec_names.contains(name), "specs() missing {name}");
359        }
360    }
361}