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