1use std::net::IpAddr;
21use std::str::FromStr;
22
23use super::{command_mechanism, ActionSpec};
24use sysknife_types::RiskLevel;
25
26#[derive(Clone, Debug, PartialEq, Eq)]
32pub enum Fail2banError {
33 InvalidIpAddress(String),
36 InvalidJail(String),
41}
42
43impl std::fmt::Display for Fail2banError {
44 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45 match self {
46 Self::InvalidIpAddress(v) => write!(f, "invalid IP address: '{}'", v),
47 Self::InvalidJail(v) => write!(f, "invalid jail name: '{}'", v),
48 }
49 }
50}
51
52impl std::error::Error for Fail2banError {}
53
54pub type InvalidIpAddress = Fail2banError;
56
57fn jail_is_valid(jail: &str) -> bool {
61 if jail.is_empty() || jail.len() > 64 {
62 return false;
63 }
64 if jail.starts_with('-') {
65 return false;
66 }
67 jail.chars()
68 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'))
69}
70
71const JAIL_HELPER: &str = "/usr/lib/sysknife/fail2ban-jail-edit";
77
78pub fn specs() -> Vec<ActionSpec> {
80 vec![
81 fail2ban_status(None),
82 fail2ban_ban_ip("sshd", "192.0.2.1").expect("valid IP in specs()"),
83 fail2ban_unban_ip("sshd", "192.0.2.1").expect("valid IP in specs()"),
84 configure_fail2ban_jail("sshd", &["--maxretry".to_string(), "3".to_string()]),
85 ]
86}
87
88pub fn fail2ban_status(jail: Option<&str>) -> ActionSpec {
98 let args: Vec<&str> = match jail {
99 Some(j) => vec!["fail2ban-client", "status", j],
100 None => vec!["fail2ban-client", "status"],
101 };
102 ActionSpec {
103 action_name: "Fail2banStatus",
104 mechanism: command_mechanism("sudo", args),
105 risk_level: RiskLevel::Low,
106 reboot_required: false,
107 rollback_available: false,
108 }
109}
110
111pub fn fail2ban_ban_ip(jail: &str, ip: &str) -> Result<ActionSpec, Fail2banError> {
121 if !jail_is_valid(jail) {
122 return Err(Fail2banError::InvalidJail(jail.to_string()));
123 }
124 IpAddr::from_str(ip).map_err(|_| Fail2banError::InvalidIpAddress(ip.to_string()))?;
125 Ok(ActionSpec {
126 action_name: "Fail2banBanIp",
127 mechanism: command_mechanism("sudo", ["fail2ban-client", "set", jail, "banip", ip]),
128 risk_level: RiskLevel::High,
129 reboot_required: false,
130 rollback_available: false,
131 })
132}
133
134pub fn fail2ban_unban_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: "Fail2banUnbanIp",
149 mechanism: command_mechanism("sudo", ["fail2ban-client", "set", jail, "unbanip", ip]),
150 risk_level: RiskLevel::Medium,
151 reboot_required: false,
152 rollback_available: false,
153 })
154}
155
156pub fn configure_fail2ban_jail(name: &str, extra: &[String]) -> ActionSpec {
165 let mut args = vec![
166 JAIL_HELPER.to_string(),
167 "--name".to_string(),
168 name.to_string(),
169 ];
170 args.extend(extra.iter().cloned());
171 ActionSpec {
172 action_name: "ConfigureFail2banJail",
173 mechanism: command_mechanism("sudo", args),
174 risk_level: RiskLevel::High,
175 reboot_required: false,
176 rollback_available: false,
177 }
178}
179
180#[cfg(test)]
185mod tests {
186 use super::*;
187 use crate::actions::ActionMechanism;
188
189 fn extract_args(spec: &ActionSpec) -> (&'static str, Vec<String>) {
190 match &spec.mechanism {
191 ActionMechanism::Command { program, args } => (*program, args.clone()),
192 _ => panic!("expected Command mechanism"),
193 }
194 }
195
196 #[test]
199 fn fail2ban_status_action_name() {
200 assert_eq!(fail2ban_status(None).action_name, "Fail2banStatus");
201 }
202
203 #[test]
204 fn fail2ban_status_risk_is_low() {
205 assert_eq!(fail2ban_status(None).risk_level, RiskLevel::Low);
206 }
207
208 #[test]
209 fn fail2ban_status_global_argv() {
210 let spec = fail2ban_status(None);
211 let (prog, args) = extract_args(&spec);
212 assert_eq!(prog, "sudo");
213 let a: Vec<&str> = args.iter().map(String::as_str).collect();
214 assert_eq!(a, vec!["fail2ban-client", "status"]);
215 }
216
217 #[test]
220 fn fail2ban_status_jail_argv() {
221 let spec = fail2ban_status(Some("sshd"));
222 let (prog, args) = extract_args(&spec);
223 assert_eq!(prog, "sudo");
224 let a: Vec<&str> = args.iter().map(String::as_str).collect();
225 assert_eq!(a, vec!["fail2ban-client", "status", "sshd"]);
226 }
227
228 #[test]
231 fn fail2ban_ban_ip_action_name() {
232 assert_eq!(
233 fail2ban_ban_ip("sshd", "10.0.0.1").unwrap().action_name,
234 "Fail2banBanIp"
235 );
236 }
237
238 #[test]
239 fn fail2ban_ban_ip_risk_is_high() {
240 assert_eq!(
241 fail2ban_ban_ip("sshd", "10.0.0.1").unwrap().risk_level,
242 RiskLevel::High
243 );
244 }
245
246 #[test]
247 fn fail2ban_ban_ip_argv_ordering() {
248 let spec = fail2ban_ban_ip("sshd", "198.51.100.42").unwrap();
250 let (prog, args) = extract_args(&spec);
251 assert_eq!(prog, "sudo");
252 let a: Vec<&str> = args.iter().map(String::as_str).collect();
253 assert_eq!(a[0], "fail2ban-client");
254 assert_eq!(a[1], "set");
255 assert_eq!(a[2], "sshd");
256 assert_eq!(a[3], "banip");
257 assert_eq!(a[4], "198.51.100.42");
258 }
259
260 #[test]
261 fn fail2ban_ban_ip_rejects_invalid_ip() {
262 let err = fail2ban_ban_ip("sshd", "not-an-ip").unwrap_err();
263 assert_eq!(
264 err,
265 Fail2banError::InvalidIpAddress("not-an-ip".to_string())
266 );
267 }
268
269 #[test]
270 fn fail2ban_ban_ip_rejects_jail_with_shell_metachars() {
271 let err = fail2ban_ban_ip("sshd; rm -rf /", "10.0.0.1").unwrap_err();
274 assert!(matches!(err, Fail2banError::InvalidJail(_)));
275 }
276
277 #[test]
278 fn fail2ban_ban_ip_rejects_leading_dash_jail() {
279 let err = fail2ban_ban_ip("--bypass", "10.0.0.1").unwrap_err();
280 assert!(matches!(err, Fail2banError::InvalidJail(_)));
281 }
282
283 #[test]
284 fn fail2ban_ban_ip_accepts_ipv6() {
285 let spec = fail2ban_ban_ip("sshd", "::1").unwrap();
286 let (_, args) = extract_args(&spec);
287 assert!(args.contains(&"::1".to_string()));
288 }
289
290 #[test]
293 fn fail2ban_unban_ip_action_name() {
294 assert_eq!(
295 fail2ban_unban_ip("sshd", "10.0.0.1").unwrap().action_name,
296 "Fail2banUnbanIp"
297 );
298 }
299
300 #[test]
301 fn fail2ban_unban_ip_risk_is_medium() {
302 assert_eq!(
303 fail2ban_unban_ip("sshd", "10.0.0.1").unwrap().risk_level,
304 RiskLevel::Medium
305 );
306 }
307
308 #[test]
309 fn fail2ban_unban_ip_argv_ordering() {
310 let spec = fail2ban_unban_ip("nginx-http-auth", "203.0.113.7").unwrap();
312 let (prog, args) = extract_args(&spec);
313 assert_eq!(prog, "sudo");
314 let a: Vec<&str> = args.iter().map(String::as_str).collect();
315 assert_eq!(a[0], "fail2ban-client");
316 assert_eq!(a[1], "set");
317 assert_eq!(a[2], "nginx-http-auth");
318 assert_eq!(a[3], "unbanip");
319 assert_eq!(a[4], "203.0.113.7");
320 }
321
322 #[test]
323 fn fail2ban_unban_ip_rejects_invalid_ip() {
324 let err = fail2ban_unban_ip("sshd", "256.0.0.1").unwrap_err();
325 assert_eq!(
326 err,
327 Fail2banError::InvalidIpAddress("256.0.0.1".to_string())
328 );
329 }
330
331 #[test]
332 fn fail2ban_unban_ip_rejects_invalid_jail() {
333 let err = fail2ban_unban_ip("bad jail name", "10.0.0.1").unwrap_err();
334 assert!(matches!(err, Fail2banError::InvalidJail(_)));
335 }
336
337 #[test]
338 fn configure_jail_routes_through_helper() {
339 let spec = configure_fail2ban_jail("sshd", &["--maxretry".to_string(), "3".to_string()]);
340 let (program, args) = extract_args(&spec);
341 assert_eq!(program, "sudo");
342 assert_eq!(args, vec![JAIL_HELPER, "--name", "sshd", "--maxretry", "3"]);
343 assert_eq!(spec.risk_level, RiskLevel::High);
344 }
345
346 #[test]
349 fn specs_covers_all_action_names() {
350 let expected = [
351 "Fail2banStatus",
352 "Fail2banBanIp",
353 "Fail2banUnbanIp",
354 "ConfigureFail2banJail",
355 ];
356 let spec_names: Vec<&str> = specs().iter().map(|s| s.action_name).collect();
357 for name in &expected {
358 assert!(spec_names.contains(name), "specs() missing {name}");
359 }
360 }
361}