1use std::net::IpAddr;
31use std::str::FromStr;
32
33use super::{command_mechanism, ActionSpec};
34use sysknife_types::RiskLevel;
35
36#[derive(Clone, Debug, PartialEq, Eq)]
42pub enum Fail2banError {
43 InvalidIpAddress(String),
46 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
64fn 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
78const JAIL_HELPER: &str = "/usr/lib/sysknife/fail2ban-jail-edit";
84
85const BAN_HELPER: &str = "/usr/lib/sysknife/fail2ban-ban";
90
91pub 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
102pub 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
133pub 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
159pub 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
184pub 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#[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 #[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 #[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 let err = fail2ban_status(Some("sshd; rm -rf /")).unwrap_err();
266 assert!(matches!(err, Fail2banError::InvalidJail(_)));
267 }
268
269 #[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 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 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 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 #[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 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 let err =
414 configure_fail2ban_jail("sshd; rm -rf /", &["--maxretry".to_string()]).unwrap_err();
415 assert!(matches!(err, Fail2banError::InvalidJail(_)));
416 }
417
418 #[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}