sysknife-daemon 0.26.0

Policy-enforced, audit-logged Linux action daemon for SysKnife
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
//! fail2ban intrusion-prevention actions (Ubuntu / Debian).
//!
//! fail2ban is an intrusion-prevention tool that monitors log files and bans
//! IP addresses that exhibit suspicious behaviour (e.g. repeated failed SSH
//! logins). These actions use the `fail2ban-client` CLI.
//!
//! ## Jail terminology
//!
//! A **jail** is a named set of rules (filter + action) that monitors a
//! specific service. For example, `sshd` watches `/var/log/auth.log` for SSH
//! authentication failures.
//!
//! ## IP validation
//!
//! `Fail2banBanIp` and `Fail2banUnbanIp` validate the supplied IP address with
//! `std::net::IpAddr::from_str` before constructing the `ActionSpec`. An
//! invalid address returns `Err(InvalidIpAddress)` immediately so a bad value
//! cannot reach the daemon.
//!
//! ## Jail/name validation
//!
//! Every constructor that takes a jail name or drop-in name (`fail2ban_status`,
//! `fail2ban_ban_ip`, `fail2ban_unban_ip`, `configure_fail2ban_jail`) validates
//! it in-constructor via `jail_is_valid` before building the `ActionSpec`. This
//! is defense in depth: the executor also validates (`validated_safe_arg` /
//! `validated_sudoers_name`), but a future internal Rust caller that bypassed
//! the executor could not otherwise be blocked from injecting through the
//! interpolated `fail2ban-client`/helper argv.

use std::net::IpAddr;
use std::str::FromStr;

use super::{command_mechanism, ActionSpec};
use sysknife_types::RiskLevel;

// ---------------------------------------------------------------------------
// Error type
// ---------------------------------------------------------------------------

/// Returned when an input fails fail2ban-action validation.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Fail2banError {
    /// Supplied IP string is not a valid IPv4 or IPv6 address. Carries the
    /// offending value so callers can surface an actionable diagnostic.
    InvalidIpAddress(String),
    /// Jail name failed `validated_safe_arg`-style allowlist check (rejects
    /// shell metachars, leading dash, non-ASCII, oversize). Defense in depth
    /// at the constructor — the executor also validates, but a future caller
    /// (internal Rust use, fleet plan/execute path) cannot bypass this.
    InvalidJail(String),
}

impl std::fmt::Display for Fail2banError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::InvalidIpAddress(v) => write!(f, "invalid IP address: '{}'", v),
            Self::InvalidJail(v) => write!(f, "invalid jail name: '{}'", v),
        }
    }
}

impl std::error::Error for Fail2banError {}

/// Allowlist for fail2ban jail names: alphanumeric + `_-` + `.` (no leading
/// dash, no shell metachars, length ≤ 64). Mirrors the `validated_safe_arg`
/// shape used at the executor seam.
fn jail_is_valid(jail: &str) -> bool {
    if jail.is_empty() || jail.len() > 64 {
        return false;
    }
    if jail.starts_with('-') {
        return false;
    }
    jail.chars()
        .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'))
}

// ---------------------------------------------------------------------------
// specs() — for action_consistency tests
// ---------------------------------------------------------------------------

/// Root-owned helper that writes a jail override to `/etc/fail2ban/jail.d/`.
const JAIL_HELPER: &str = "/usr/lib/sysknife/fail2ban-jail-edit";

/// Installed path of the privileged ban/unban helper.
/// See `packaging/sysknife-fail2ban-ban` and the matching NOPASSWD grant
/// in `packaging/sysknife-sudoers`, which replaced a `fail2ban-client set *`
/// wildcard that permitted `set <jail> action <name> actionban <cmd>`.
const BAN_HELPER: &str = "/usr/lib/sysknife/fail2ban-ban";

/// Return one representative `ActionSpec` per fail2ban action name.
pub fn specs() -> Vec<ActionSpec> {
    vec![
        fail2ban_status(None).expect("no jail filter in specs()"),
        fail2ban_ban_ip("sshd", "192.0.2.1").expect("valid IP in specs()"),
        fail2ban_unban_ip("sshd", "192.0.2.1").expect("valid IP in specs()"),
        configure_fail2ban_jail("sshd", &["--maxretry".to_string(), "3".to_string()])
            .expect("sshd is a valid jail name in specs()"),
    ]
}

// ---------------------------------------------------------------------------
// Action constructors
// ---------------------------------------------------------------------------

/// Show fail2ban jail status (`sudo fail2ban-client status [<jail>]`).
///
/// Risk: Low. Read-only; shows active jails, banned IPs, and hit counts.
/// When `jail` is `None` the global status (list of all jails) is returned.
/// When `jail` is `Some(name)` the detailed status for that jail is returned.
///
/// Returns `Err(InvalidJail)` when `jail` is `Some` and fails `jail_is_valid`
/// (in-constructor defense in depth — see the module doc).
pub fn fail2ban_status(jail: Option<&str>) -> Result<ActionSpec, Fail2banError> {
    let args: Vec<&str> = match jail {
        Some(j) => {
            if !jail_is_valid(j) {
                return Err(Fail2banError::InvalidJail(j.to_string()));
            }
            vec!["fail2ban-client", "status", j]
        }
        None => vec!["fail2ban-client", "status"],
    };
    Ok(ActionSpec {
        action_name: "Fail2banStatus",
        mechanism: command_mechanism("sudo", args),
        risk_level: RiskLevel::Low,
        reboot_required: false,
        rollback_available: false,
    })
}

/// Ban an IP address in a fail2ban jail
/// (`sudo fail2ban-client set <jail> banip <ip>`).
///
/// Risk: High. Immediately blocks all traffic from the IP for all services
/// protected by the named jail. Banning a legitimate address can cause an
/// outage (e.g. banning the admin's own IP on the `sshd` jail).
///
/// Returns `Err(InvalidIpAddress)` when `ip` is not a valid IPv4 or IPv6
/// address.
pub fn fail2ban_ban_ip(jail: &str, ip: &str) -> Result<ActionSpec, Fail2banError> {
    if !jail_is_valid(jail) {
        return Err(Fail2banError::InvalidJail(jail.to_string()));
    }
    IpAddr::from_str(ip).map_err(|_| Fail2banError::InvalidIpAddress(ip.to_string()))?;
    Ok(ActionSpec {
        action_name: "Fail2banBanIp",
        mechanism: command_mechanism(
            "sudo",
            [BAN_HELPER, "--op", "ban", "--jail", jail, "--ip", ip],
        ),
        risk_level: RiskLevel::High,
        reboot_required: false,
        rollback_available: false,
    })
}

/// Unban an IP address from a fail2ban jail
/// (`sudo fail2ban-client set <jail> unbanip <ip>`).
///
/// Risk: Medium. Removes a ban, potentially re-admitting a previously blocked
/// address. Reversible — the address can be banned again with `Fail2banBanIp`.
///
/// Returns `Err(InvalidIpAddress)` when `ip` is not a valid IPv4 or IPv6
/// address.
pub fn fail2ban_unban_ip(jail: &str, ip: &str) -> Result<ActionSpec, Fail2banError> {
    if !jail_is_valid(jail) {
        return Err(Fail2banError::InvalidJail(jail.to_string()));
    }
    IpAddr::from_str(ip).map_err(|_| Fail2banError::InvalidIpAddress(ip.to_string()))?;
    Ok(ActionSpec {
        action_name: "Fail2banUnbanIp",
        mechanism: command_mechanism(
            "sudo",
            [BAN_HELPER, "--op", "unban", "--jail", jail, "--ip", ip],
        ),
        risk_level: RiskLevel::Medium,
        reboot_required: false,
        rollback_available: false,
    })
}

/// Write a fail2ban jail override via the helper (`configure_fail2ban_jail`).
///
/// `extra` is a pre-validated list of `--maxretry`/`--bantime`/`--findtime`/
/// `--enabled` flag pairs. The helper writes `/etc/fail2ban/jail.d/sysknife-<name>.local`,
/// tests it with `fail2ban-client -t`, and reloads (rolling back on rejection).
///
/// Risk: High — tuning a jail changes who gets banned (too strict → locks out
/// legitimate users; too loose → weakens brute-force protection).
///
/// Returns `Err(InvalidJail)` when `name` fails `jail_is_valid` (in-constructor
/// defense in depth — see the module doc).
pub fn configure_fail2ban_jail(name: &str, extra: &[String]) -> Result<ActionSpec, Fail2banError> {
    if !jail_is_valid(name) {
        return Err(Fail2banError::InvalidJail(name.to_string()));
    }
    let mut args = vec![
        JAIL_HELPER.to_string(),
        "--name".to_string(),
        name.to_string(),
    ];
    args.extend(extra.iter().cloned());
    Ok(ActionSpec {
        action_name: "ConfigureFail2banJail",
        mechanism: command_mechanism("sudo", args),
        risk_level: RiskLevel::High,
        reboot_required: false,
        rollback_available: false,
    })
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;
    use crate::actions::ActionMechanism;

    fn extract_args(spec: &ActionSpec) -> (&'static str, Vec<String>) {
        match &spec.mechanism {
            ActionMechanism::Command { program, args } => (*program, args.clone()),
            _ => panic!("expected Command mechanism"),
        }
    }

    // ── fail2ban_status (global) ─────────────────────────────────────────────

    #[test]
    fn fail2ban_status_action_name() {
        assert_eq!(fail2ban_status(None).unwrap().action_name, "Fail2banStatus");
    }

    #[test]
    fn fail2ban_status_risk_is_low() {
        assert_eq!(fail2ban_status(None).unwrap().risk_level, RiskLevel::Low);
    }

    #[test]
    fn fail2ban_status_global_argv() {
        let spec = fail2ban_status(None).unwrap();
        let (prog, args) = extract_args(&spec);
        assert_eq!(prog, "sudo");
        let a: Vec<&str> = args.iter().map(String::as_str).collect();
        assert_eq!(a, vec!["fail2ban-client", "status"]);
    }

    // ── fail2ban_status (with jail) ──────────────────────────────────────────

    #[test]
    fn fail2ban_status_jail_argv() {
        let spec = fail2ban_status(Some("sshd")).unwrap();
        let (prog, args) = extract_args(&spec);
        assert_eq!(prog, "sudo");
        let a: Vec<&str> = args.iter().map(String::as_str).collect();
        assert_eq!(a, vec!["fail2ban-client", "status", "sshd"]);
    }

    #[test]
    fn fail2ban_status_rejects_invalid_jail() {
        // In-constructor defense in depth, mirroring fail2ban_ban_ip/unban_ip.
        let err = fail2ban_status(Some("sshd; rm -rf /")).unwrap_err();
        assert!(matches!(err, Fail2banError::InvalidJail(_)));
    }

    // ── fail2ban_ban_ip ──────────────────────────────────────────────────────

    #[test]
    fn fail2ban_ban_ip_action_name() {
        assert_eq!(
            fail2ban_ban_ip("sshd", "10.0.0.1").unwrap().action_name,
            "Fail2banBanIp"
        );
    }

    #[test]
    fn fail2ban_ban_ip_risk_is_high() {
        assert_eq!(
            fail2ban_ban_ip("sshd", "10.0.0.1").unwrap().risk_level,
            RiskLevel::High
        );
    }

    #[test]
    fn fail2ban_ban_ip_argv_ordering() {
        // argv goes through the helper, whose own argv to fail2ban-client is
        // fixed. It used to be `sudo fail2ban-client set <jail> banip <ip>`,
        // authorised by a `fail2ban-client set *` grant that also permitted
        // `set <jail> action <name> actionban <cmd>`, which runs a command as
        // root. sudoers cannot pin the `banip` token, because the variable jail
        // name precedes it, so the subcommand moved behind a helper.
        let spec = fail2ban_ban_ip("sshd", "198.51.100.42").unwrap();
        let (prog, args) = extract_args(&spec);
        assert_eq!(prog, "sudo");
        let a: Vec<&str> = args.iter().map(String::as_str).collect();
        assert_eq!(
            a,
            [
                "/usr/lib/sysknife/fail2ban-ban",
                "--op",
                "ban",
                "--jail",
                "sshd",
                "--ip",
                "198.51.100.42"
            ]
        );
        // The literal the sudoers grant is written against.
        assert_eq!(a[0], BAN_HELPER);
    }

    #[test]
    fn fail2ban_ban_ip_rejects_invalid_ip() {
        let err = fail2ban_ban_ip("sshd", "not-an-ip").unwrap_err();
        assert_eq!(
            err,
            Fail2banError::InvalidIpAddress("not-an-ip".to_string())
        );
    }

    #[test]
    fn fail2ban_ban_ip_rejects_jail_with_shell_metachars() {
        // Defense in depth: the constructor itself rejects malformed jail names,
        // not just the executor. A future internal Rust caller can't bypass.
        let err = fail2ban_ban_ip("sshd; rm -rf /", "10.0.0.1").unwrap_err();
        assert!(matches!(err, Fail2banError::InvalidJail(_)));
    }

    #[test]
    fn fail2ban_ban_ip_rejects_leading_dash_jail() {
        let err = fail2ban_ban_ip("--bypass", "10.0.0.1").unwrap_err();
        assert!(matches!(err, Fail2banError::InvalidJail(_)));
    }

    #[test]
    fn fail2ban_ban_ip_accepts_ipv6() {
        let spec = fail2ban_ban_ip("sshd", "::1").unwrap();
        let (_, args) = extract_args(&spec);
        assert!(args.contains(&"::1".to_string()));
    }

    // ── fail2ban_unban_ip ────────────────────────────────────────────────────

    #[test]
    fn fail2ban_unban_ip_action_name() {
        assert_eq!(
            fail2ban_unban_ip("sshd", "10.0.0.1").unwrap().action_name,
            "Fail2banUnbanIp"
        );
    }

    #[test]
    fn fail2ban_unban_ip_risk_is_medium() {
        assert_eq!(
            fail2ban_unban_ip("sshd", "10.0.0.1").unwrap().risk_level,
            RiskLevel::Medium
        );
    }

    #[test]
    fn fail2ban_unban_ip_argv_ordering() {
        // Same helper, `--op unban`. See the ban test for why.
        let spec = fail2ban_unban_ip("nginx-http-auth", "203.0.113.7").unwrap();
        let (prog, args) = extract_args(&spec);
        assert_eq!(prog, "sudo");
        let a: Vec<&str> = args.iter().map(String::as_str).collect();
        assert_eq!(
            a,
            [
                "/usr/lib/sysknife/fail2ban-ban",
                "--op",
                "unban",
                "--jail",
                "nginx-http-auth",
                "--ip",
                "203.0.113.7"
            ]
        );
        assert_eq!(a[0], BAN_HELPER);
    }

    #[test]
    fn fail2ban_unban_ip_rejects_invalid_ip() {
        let err = fail2ban_unban_ip("sshd", "256.0.0.1").unwrap_err();
        assert_eq!(
            err,
            Fail2banError::InvalidIpAddress("256.0.0.1".to_string())
        );
    }

    #[test]
    fn fail2ban_unban_ip_rejects_invalid_jail() {
        let err = fail2ban_unban_ip("bad jail name", "10.0.0.1").unwrap_err();
        assert!(matches!(err, Fail2banError::InvalidJail(_)));
    }

    #[test]
    fn configure_jail_routes_through_helper() {
        let spec =
            configure_fail2ban_jail("sshd", &["--maxretry".to_string(), "3".to_string()]).unwrap();
        let (program, args) = extract_args(&spec);
        assert_eq!(program, "sudo");
        assert_eq!(args, vec![JAIL_HELPER, "--name", "sshd", "--maxretry", "3"]);
        assert_eq!(spec.risk_level, RiskLevel::High);
    }

    #[test]
    fn configure_fail2ban_jail_rejects_invalid_name() {
        // In-constructor defense in depth, mirroring fail2ban_ban_ip/unban_ip.
        let err =
            configure_fail2ban_jail("sshd; rm -rf /", &["--maxretry".to_string()]).unwrap_err();
        assert!(matches!(err, Fail2banError::InvalidJail(_)));
    }

    // ── specs() completeness ─────────────────────────────────────────────────

    #[test]
    fn specs_covers_all_action_names() {
        let expected = [
            "Fail2banStatus",
            "Fail2banBanIp",
            "Fail2banUnbanIp",
            "ConfigureFail2banJail",
        ];
        let spec_names: Vec<&str> = specs().iter().map(|s| s.action_name).collect();
        for name in &expected {
            assert!(spec_names.contains(name), "specs() missing {name}");
        }
    }
}