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
85pub 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
96pub 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
127pub 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
150pub 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
172pub 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#[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 #[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 #[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 let err = fail2ban_status(Some("sshd; rm -rf /")).unwrap_err();
254 assert!(matches!(err, Fail2banError::InvalidJail(_)));
255 }
256
257 #[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 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 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 #[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 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 let err =
380 configure_fail2ban_jail("sshd; rm -rf /", &["--maxretry".to_string()]).unwrap_err();
381 assert!(matches!(err, Fail2banError::InvalidJail(_)));
382 }
383
384 #[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}