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