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//!
20//! ## Jail/name validation
21//!
22//! Every constructor that takes a jail name or drop-in name (`fail2ban_status`,
23//! `fail2ban_ban_ip`, `fail2ban_unban_ip`, `configure_fail2ban_jail`) validates
24//! it in-constructor via `jail_is_valid` before building the `ActionSpec`. This
25//! is defense in depth: the executor also validates (`validated_safe_arg` /
26//! `validated_sudoers_name`), but a future internal Rust caller that bypassed
27//! the executor could not otherwise be blocked from injecting through the
28//! interpolated `fail2ban-client`/helper argv.
29
30use std::net::IpAddr;
31use std::str::FromStr;
32
33use super::{command_mechanism, ActionSpec};
34use sysknife_types::RiskLevel;
35
36// ---------------------------------------------------------------------------
37// Error type
38// ---------------------------------------------------------------------------
39
40/// Returned when an input fails fail2ban-action validation.
41#[derive(Clone, Debug, PartialEq, Eq)]
42pub enum Fail2banError {
43    /// Supplied IP string is not a valid IPv4 or IPv6 address. Carries the
44    /// offending value so callers can surface an actionable diagnostic.
45    InvalidIpAddress(String),
46    /// Jail name failed `validated_safe_arg`-style allowlist check (rejects
47    /// shell metachars, leading dash, non-ASCII, oversize). Defense in depth
48    /// at the constructor — the executor also validates, but a future caller
49    /// (internal Rust use, fleet plan/execute path) cannot bypass this.
50    InvalidJail(String),
51}
52
53impl std::fmt::Display for Fail2banError {
54    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55        match self {
56            Self::InvalidIpAddress(v) => write!(f, "invalid IP address: '{}'", v),
57            Self::InvalidJail(v) => write!(f, "invalid jail name: '{}'", v),
58        }
59    }
60}
61
62impl std::error::Error for Fail2banError {}
63
64/// Allowlist for fail2ban jail names: alphanumeric + `_-` + `.` (no leading
65/// dash, no shell metachars, length ≤ 64). Mirrors the `validated_safe_arg`
66/// shape used at the executor seam.
67fn jail_is_valid(jail: &str) -> bool {
68    if jail.is_empty() || jail.len() > 64 {
69        return false;
70    }
71    if jail.starts_with('-') {
72        return false;
73    }
74    jail.chars()
75        .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'))
76}
77
78// ---------------------------------------------------------------------------
79// specs() — for action_consistency tests
80// ---------------------------------------------------------------------------
81
82/// Root-owned helper that writes a jail override to `/etc/fail2ban/jail.d/`.
83const JAIL_HELPER: &str = "/usr/lib/sysknife/fail2ban-jail-edit";
84
85/// Installed path of the privileged ban/unban helper.
86/// See `packaging/sysknife-fail2ban-ban` and the matching NOPASSWD grant
87/// in `packaging/sysknife-sudoers`, which replaced a `fail2ban-client set *`
88/// wildcard that permitted `set <jail> action <name> actionban <cmd>`.
89const BAN_HELPER: &str = "/usr/lib/sysknife/fail2ban-ban";
90
91/// Return one representative `ActionSpec` per fail2ban action name.
92pub fn specs() -> Vec<ActionSpec> {
93    vec![
94        fail2ban_status(None).expect("no jail filter in specs()"),
95        fail2ban_ban_ip("sshd", "192.0.2.1").expect("valid IP in specs()"),
96        fail2ban_unban_ip("sshd", "192.0.2.1").expect("valid IP in specs()"),
97        configure_fail2ban_jail("sshd", &["--maxretry".to_string(), "3".to_string()])
98            .expect("sshd is a valid jail name in specs()"),
99    ]
100}
101
102// ---------------------------------------------------------------------------
103// Action constructors
104// ---------------------------------------------------------------------------
105
106/// Show fail2ban jail status (`sudo fail2ban-client status [<jail>]`).
107///
108/// Risk: Low. Read-only; shows active jails, banned IPs, and hit counts.
109/// When `jail` is `None` the global status (list of all jails) is returned.
110/// When `jail` is `Some(name)` the detailed status for that jail is returned.
111///
112/// Returns `Err(InvalidJail)` when `jail` is `Some` and fails `jail_is_valid`
113/// (in-constructor defense in depth — see the module doc).
114pub fn fail2ban_status(jail: Option<&str>) -> Result<ActionSpec, Fail2banError> {
115    let args: Vec<&str> = match jail {
116        Some(j) => {
117            if !jail_is_valid(j) {
118                return Err(Fail2banError::InvalidJail(j.to_string()));
119            }
120            vec!["fail2ban-client", "status", j]
121        }
122        None => vec!["fail2ban-client", "status"],
123    };
124    Ok(ActionSpec {
125        action_name: "Fail2banStatus",
126        mechanism: command_mechanism("sudo", args),
127        risk_level: RiskLevel::Low,
128        reboot_required: false,
129        rollback_available: false,
130    })
131}
132
133/// Ban an IP address in a fail2ban jail
134/// (`sudo fail2ban-client set <jail> banip <ip>`).
135///
136/// Risk: High. Immediately blocks all traffic from the IP for all services
137/// protected by the named jail. Banning a legitimate address can cause an
138/// outage (e.g. banning the admin's own IP on the `sshd` jail).
139///
140/// Returns `Err(InvalidIpAddress)` when `ip` is not a valid IPv4 or IPv6
141/// address.
142pub fn fail2ban_ban_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: "Fail2banBanIp",
149        mechanism: command_mechanism(
150            "sudo",
151            [BAN_HELPER, "--op", "ban", "--jail", jail, "--ip", ip],
152        ),
153        risk_level: RiskLevel::High,
154        reboot_required: false,
155        rollback_available: false,
156    })
157}
158
159/// Unban an IP address from a fail2ban jail
160/// (`sudo fail2ban-client set <jail> unbanip <ip>`).
161///
162/// Risk: Medium. Removes a ban, potentially re-admitting a previously blocked
163/// address. Reversible — the address can be banned again with `Fail2banBanIp`.
164///
165/// Returns `Err(InvalidIpAddress)` when `ip` is not a valid IPv4 or IPv6
166/// address.
167pub fn fail2ban_unban_ip(jail: &str, ip: &str) -> Result<ActionSpec, Fail2banError> {
168    if !jail_is_valid(jail) {
169        return Err(Fail2banError::InvalidJail(jail.to_string()));
170    }
171    IpAddr::from_str(ip).map_err(|_| Fail2banError::InvalidIpAddress(ip.to_string()))?;
172    Ok(ActionSpec {
173        action_name: "Fail2banUnbanIp",
174        mechanism: command_mechanism(
175            "sudo",
176            [BAN_HELPER, "--op", "unban", "--jail", jail, "--ip", ip],
177        ),
178        risk_level: RiskLevel::Medium,
179        reboot_required: false,
180        rollback_available: false,
181    })
182}
183
184/// Write a fail2ban jail override via the helper (`configure_fail2ban_jail`).
185///
186/// `extra` is a pre-validated list of `--maxretry`/`--bantime`/`--findtime`/
187/// `--enabled` flag pairs. The helper writes `/etc/fail2ban/jail.d/sysknife-<name>.local`,
188/// tests it with `fail2ban-client -t`, and reloads (rolling back on rejection).
189///
190/// Risk: High — tuning a jail changes who gets banned (too strict → locks out
191/// legitimate users; too loose → weakens brute-force protection).
192///
193/// Returns `Err(InvalidJail)` when `name` fails `jail_is_valid` (in-constructor
194/// defense in depth — see the module doc).
195pub fn configure_fail2ban_jail(name: &str, extra: &[String]) -> Result<ActionSpec, Fail2banError> {
196    if !jail_is_valid(name) {
197        return Err(Fail2banError::InvalidJail(name.to_string()));
198    }
199    let mut args = vec![
200        JAIL_HELPER.to_string(),
201        "--name".to_string(),
202        name.to_string(),
203    ];
204    args.extend(extra.iter().cloned());
205    Ok(ActionSpec {
206        action_name: "ConfigureFail2banJail",
207        mechanism: command_mechanism("sudo", args),
208        risk_level: RiskLevel::High,
209        reboot_required: false,
210        rollback_available: false,
211    })
212}
213
214// ---------------------------------------------------------------------------
215// Tests
216// ---------------------------------------------------------------------------
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221    use crate::actions::ActionMechanism;
222
223    fn extract_args(spec: &ActionSpec) -> (&'static str, Vec<String>) {
224        match &spec.mechanism {
225            ActionMechanism::Command { program, args } => (*program, args.clone()),
226            _ => panic!("expected Command mechanism"),
227        }
228    }
229
230    // ── fail2ban_status (global) ─────────────────────────────────────────────
231
232    #[test]
233    fn fail2ban_status_action_name() {
234        assert_eq!(fail2ban_status(None).unwrap().action_name, "Fail2banStatus");
235    }
236
237    #[test]
238    fn fail2ban_status_risk_is_low() {
239        assert_eq!(fail2ban_status(None).unwrap().risk_level, RiskLevel::Low);
240    }
241
242    #[test]
243    fn fail2ban_status_global_argv() {
244        let spec = fail2ban_status(None).unwrap();
245        let (prog, args) = extract_args(&spec);
246        assert_eq!(prog, "sudo");
247        let a: Vec<&str> = args.iter().map(String::as_str).collect();
248        assert_eq!(a, vec!["fail2ban-client", "status"]);
249    }
250
251    // ── fail2ban_status (with jail) ──────────────────────────────────────────
252
253    #[test]
254    fn fail2ban_status_jail_argv() {
255        let spec = fail2ban_status(Some("sshd")).unwrap();
256        let (prog, args) = extract_args(&spec);
257        assert_eq!(prog, "sudo");
258        let a: Vec<&str> = args.iter().map(String::as_str).collect();
259        assert_eq!(a, vec!["fail2ban-client", "status", "sshd"]);
260    }
261
262    #[test]
263    fn fail2ban_status_rejects_invalid_jail() {
264        // In-constructor defense in depth, mirroring fail2ban_ban_ip/unban_ip.
265        let err = fail2ban_status(Some("sshd; rm -rf /")).unwrap_err();
266        assert!(matches!(err, Fail2banError::InvalidJail(_)));
267    }
268
269    // ── fail2ban_ban_ip ──────────────────────────────────────────────────────
270
271    #[test]
272    fn fail2ban_ban_ip_action_name() {
273        assert_eq!(
274            fail2ban_ban_ip("sshd", "10.0.0.1").unwrap().action_name,
275            "Fail2banBanIp"
276        );
277    }
278
279    #[test]
280    fn fail2ban_ban_ip_risk_is_high() {
281        assert_eq!(
282            fail2ban_ban_ip("sshd", "10.0.0.1").unwrap().risk_level,
283            RiskLevel::High
284        );
285    }
286
287    #[test]
288    fn fail2ban_ban_ip_argv_ordering() {
289        // argv goes through the helper, whose own argv to fail2ban-client is
290        // fixed. It used to be `sudo fail2ban-client set <jail> banip <ip>`,
291        // authorised by a `fail2ban-client set *` grant that also permitted
292        // `set <jail> action <name> actionban <cmd>`, which runs a command as
293        // root. sudoers cannot pin the `banip` token, because the variable jail
294        // name precedes it, so the subcommand moved behind a helper.
295        let spec = fail2ban_ban_ip("sshd", "198.51.100.42").unwrap();
296        let (prog, args) = extract_args(&spec);
297        assert_eq!(prog, "sudo");
298        let a: Vec<&str> = args.iter().map(String::as_str).collect();
299        assert_eq!(
300            a,
301            [
302                "/usr/lib/sysknife/fail2ban-ban",
303                "--op",
304                "ban",
305                "--jail",
306                "sshd",
307                "--ip",
308                "198.51.100.42"
309            ]
310        );
311        // The literal the sudoers grant is written against.
312        assert_eq!(a[0], BAN_HELPER);
313    }
314
315    #[test]
316    fn fail2ban_ban_ip_rejects_invalid_ip() {
317        let err = fail2ban_ban_ip("sshd", "not-an-ip").unwrap_err();
318        assert_eq!(
319            err,
320            Fail2banError::InvalidIpAddress("not-an-ip".to_string())
321        );
322    }
323
324    #[test]
325    fn fail2ban_ban_ip_rejects_jail_with_shell_metachars() {
326        // Defense in depth: the constructor itself rejects malformed jail names,
327        // not just the executor. A future internal Rust caller can't bypass.
328        let err = fail2ban_ban_ip("sshd; rm -rf /", "10.0.0.1").unwrap_err();
329        assert!(matches!(err, Fail2banError::InvalidJail(_)));
330    }
331
332    #[test]
333    fn fail2ban_ban_ip_rejects_leading_dash_jail() {
334        let err = fail2ban_ban_ip("--bypass", "10.0.0.1").unwrap_err();
335        assert!(matches!(err, Fail2banError::InvalidJail(_)));
336    }
337
338    #[test]
339    fn fail2ban_ban_ip_accepts_ipv6() {
340        let spec = fail2ban_ban_ip("sshd", "::1").unwrap();
341        let (_, args) = extract_args(&spec);
342        assert!(args.contains(&"::1".to_string()));
343    }
344
345    // ── fail2ban_unban_ip ────────────────────────────────────────────────────
346
347    #[test]
348    fn fail2ban_unban_ip_action_name() {
349        assert_eq!(
350            fail2ban_unban_ip("sshd", "10.0.0.1").unwrap().action_name,
351            "Fail2banUnbanIp"
352        );
353    }
354
355    #[test]
356    fn fail2ban_unban_ip_risk_is_medium() {
357        assert_eq!(
358            fail2ban_unban_ip("sshd", "10.0.0.1").unwrap().risk_level,
359            RiskLevel::Medium
360        );
361    }
362
363    #[test]
364    fn fail2ban_unban_ip_argv_ordering() {
365        // Same helper, `--op unban`. See the ban test for why.
366        let spec = fail2ban_unban_ip("nginx-http-auth", "203.0.113.7").unwrap();
367        let (prog, args) = extract_args(&spec);
368        assert_eq!(prog, "sudo");
369        let a: Vec<&str> = args.iter().map(String::as_str).collect();
370        assert_eq!(
371            a,
372            [
373                "/usr/lib/sysknife/fail2ban-ban",
374                "--op",
375                "unban",
376                "--jail",
377                "nginx-http-auth",
378                "--ip",
379                "203.0.113.7"
380            ]
381        );
382        assert_eq!(a[0], BAN_HELPER);
383    }
384
385    #[test]
386    fn fail2ban_unban_ip_rejects_invalid_ip() {
387        let err = fail2ban_unban_ip("sshd", "256.0.0.1").unwrap_err();
388        assert_eq!(
389            err,
390            Fail2banError::InvalidIpAddress("256.0.0.1".to_string())
391        );
392    }
393
394    #[test]
395    fn fail2ban_unban_ip_rejects_invalid_jail() {
396        let err = fail2ban_unban_ip("bad jail name", "10.0.0.1").unwrap_err();
397        assert!(matches!(err, Fail2banError::InvalidJail(_)));
398    }
399
400    #[test]
401    fn configure_jail_routes_through_helper() {
402        let spec =
403            configure_fail2ban_jail("sshd", &["--maxretry".to_string(), "3".to_string()]).unwrap();
404        let (program, args) = extract_args(&spec);
405        assert_eq!(program, "sudo");
406        assert_eq!(args, vec![JAIL_HELPER, "--name", "sshd", "--maxretry", "3"]);
407        assert_eq!(spec.risk_level, RiskLevel::High);
408    }
409
410    #[test]
411    fn configure_fail2ban_jail_rejects_invalid_name() {
412        // In-constructor defense in depth, mirroring fail2ban_ban_ip/unban_ip.
413        let err =
414            configure_fail2ban_jail("sshd; rm -rf /", &["--maxretry".to_string()]).unwrap_err();
415        assert!(matches!(err, Fail2banError::InvalidJail(_)));
416    }
417
418    // ── specs() completeness ─────────────────────────────────────────────────
419
420    #[test]
421    fn specs_covers_all_action_names() {
422        let expected = [
423            "Fail2banStatus",
424            "Fail2banBanIp",
425            "Fail2banUnbanIp",
426            "ConfigureFail2banJail",
427        ];
428        let spec_names: Vec<&str> = specs().iter().map(|s| s.action_name).collect();
429        for name in &expected {
430            assert!(spec_names.contains(name), "specs() missing {name}");
431        }
432    }
433}