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
54fn jail_is_valid(jail: &str) -> bool {
58 if jail.is_empty() || jail.len() > 64 {
59 return false;
60 }
61 if jail.starts_with('-') {
62 return false;
63 }
64 jail.chars()
65 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'))
66}
67
68const JAIL_HELPER: &str = "/usr/lib/sysknife/fail2ban-jail-edit";
74
75pub fn specs() -> Vec<ActionSpec> {
77 vec![
78 fail2ban_status(None),
79 fail2ban_ban_ip("sshd", "192.0.2.1").expect("valid IP in specs()"),
80 fail2ban_unban_ip("sshd", "192.0.2.1").expect("valid IP in specs()"),
81 configure_fail2ban_jail("sshd", &["--maxretry".to_string(), "3".to_string()]),
82 ]
83}
84
85pub fn fail2ban_status(jail: Option<&str>) -> ActionSpec {
95 let args: Vec<&str> = match jail {
96 Some(j) => vec!["fail2ban-client", "status", j],
97 None => vec!["fail2ban-client", "status"],
98 };
99 ActionSpec {
100 action_name: "Fail2banStatus",
101 mechanism: command_mechanism("sudo", args),
102 risk_level: RiskLevel::Low,
103 reboot_required: false,
104 rollback_available: false,
105 }
106}
107
108pub fn fail2ban_ban_ip(jail: &str, ip: &str) -> Result<ActionSpec, Fail2banError> {
118 if !jail_is_valid(jail) {
119 return Err(Fail2banError::InvalidJail(jail.to_string()));
120 }
121 IpAddr::from_str(ip).map_err(|_| Fail2banError::InvalidIpAddress(ip.to_string()))?;
122 Ok(ActionSpec {
123 action_name: "Fail2banBanIp",
124 mechanism: command_mechanism("sudo", ["fail2ban-client", "set", jail, "banip", ip]),
125 risk_level: RiskLevel::High,
126 reboot_required: false,
127 rollback_available: false,
128 })
129}
130
131pub fn fail2ban_unban_ip(jail: &str, ip: &str) -> Result<ActionSpec, Fail2banError> {
140 if !jail_is_valid(jail) {
141 return Err(Fail2banError::InvalidJail(jail.to_string()));
142 }
143 IpAddr::from_str(ip).map_err(|_| Fail2banError::InvalidIpAddress(ip.to_string()))?;
144 Ok(ActionSpec {
145 action_name: "Fail2banUnbanIp",
146 mechanism: command_mechanism("sudo", ["fail2ban-client", "set", jail, "unbanip", ip]),
147 risk_level: RiskLevel::Medium,
148 reboot_required: false,
149 rollback_available: false,
150 })
151}
152
153pub fn configure_fail2ban_jail(name: &str, extra: &[String]) -> ActionSpec {
162 let mut args = vec![
163 JAIL_HELPER.to_string(),
164 "--name".to_string(),
165 name.to_string(),
166 ];
167 args.extend(extra.iter().cloned());
168 ActionSpec {
169 action_name: "ConfigureFail2banJail",
170 mechanism: command_mechanism("sudo", args),
171 risk_level: RiskLevel::High,
172 reboot_required: false,
173 rollback_available: false,
174 }
175}
176
177#[cfg(test)]
182mod tests {
183 use super::*;
184 use crate::actions::ActionMechanism;
185
186 fn extract_args(spec: &ActionSpec) -> (&'static str, Vec<String>) {
187 match &spec.mechanism {
188 ActionMechanism::Command { program, args } => (*program, args.clone()),
189 _ => panic!("expected Command mechanism"),
190 }
191 }
192
193 #[test]
196 fn fail2ban_status_action_name() {
197 assert_eq!(fail2ban_status(None).action_name, "Fail2banStatus");
198 }
199
200 #[test]
201 fn fail2ban_status_risk_is_low() {
202 assert_eq!(fail2ban_status(None).risk_level, RiskLevel::Low);
203 }
204
205 #[test]
206 fn fail2ban_status_global_argv() {
207 let spec = fail2ban_status(None);
208 let (prog, args) = extract_args(&spec);
209 assert_eq!(prog, "sudo");
210 let a: Vec<&str> = args.iter().map(String::as_str).collect();
211 assert_eq!(a, vec!["fail2ban-client", "status"]);
212 }
213
214 #[test]
217 fn fail2ban_status_jail_argv() {
218 let spec = fail2ban_status(Some("sshd"));
219 let (prog, args) = extract_args(&spec);
220 assert_eq!(prog, "sudo");
221 let a: Vec<&str> = args.iter().map(String::as_str).collect();
222 assert_eq!(a, vec!["fail2ban-client", "status", "sshd"]);
223 }
224
225 #[test]
228 fn fail2ban_ban_ip_action_name() {
229 assert_eq!(
230 fail2ban_ban_ip("sshd", "10.0.0.1").unwrap().action_name,
231 "Fail2banBanIp"
232 );
233 }
234
235 #[test]
236 fn fail2ban_ban_ip_risk_is_high() {
237 assert_eq!(
238 fail2ban_ban_ip("sshd", "10.0.0.1").unwrap().risk_level,
239 RiskLevel::High
240 );
241 }
242
243 #[test]
244 fn fail2ban_ban_ip_argv_ordering() {
245 let spec = fail2ban_ban_ip("sshd", "198.51.100.42").unwrap();
247 let (prog, args) = extract_args(&spec);
248 assert_eq!(prog, "sudo");
249 let a: Vec<&str> = args.iter().map(String::as_str).collect();
250 assert_eq!(a[0], "fail2ban-client");
251 assert_eq!(a[1], "set");
252 assert_eq!(a[2], "sshd");
253 assert_eq!(a[3], "banip");
254 assert_eq!(a[4], "198.51.100.42");
255 }
256
257 #[test]
258 fn fail2ban_ban_ip_rejects_invalid_ip() {
259 let err = fail2ban_ban_ip("sshd", "not-an-ip").unwrap_err();
260 assert_eq!(
261 err,
262 Fail2banError::InvalidIpAddress("not-an-ip".to_string())
263 );
264 }
265
266 #[test]
267 fn fail2ban_ban_ip_rejects_jail_with_shell_metachars() {
268 let err = fail2ban_ban_ip("sshd; rm -rf /", "10.0.0.1").unwrap_err();
271 assert!(matches!(err, Fail2banError::InvalidJail(_)));
272 }
273
274 #[test]
275 fn fail2ban_ban_ip_rejects_leading_dash_jail() {
276 let err = fail2ban_ban_ip("--bypass", "10.0.0.1").unwrap_err();
277 assert!(matches!(err, Fail2banError::InvalidJail(_)));
278 }
279
280 #[test]
281 fn fail2ban_ban_ip_accepts_ipv6() {
282 let spec = fail2ban_ban_ip("sshd", "::1").unwrap();
283 let (_, args) = extract_args(&spec);
284 assert!(args.contains(&"::1".to_string()));
285 }
286
287 #[test]
290 fn fail2ban_unban_ip_action_name() {
291 assert_eq!(
292 fail2ban_unban_ip("sshd", "10.0.0.1").unwrap().action_name,
293 "Fail2banUnbanIp"
294 );
295 }
296
297 #[test]
298 fn fail2ban_unban_ip_risk_is_medium() {
299 assert_eq!(
300 fail2ban_unban_ip("sshd", "10.0.0.1").unwrap().risk_level,
301 RiskLevel::Medium
302 );
303 }
304
305 #[test]
306 fn fail2ban_unban_ip_argv_ordering() {
307 let spec = fail2ban_unban_ip("nginx-http-auth", "203.0.113.7").unwrap();
309 let (prog, args) = extract_args(&spec);
310 assert_eq!(prog, "sudo");
311 let a: Vec<&str> = args.iter().map(String::as_str).collect();
312 assert_eq!(a[0], "fail2ban-client");
313 assert_eq!(a[1], "set");
314 assert_eq!(a[2], "nginx-http-auth");
315 assert_eq!(a[3], "unbanip");
316 assert_eq!(a[4], "203.0.113.7");
317 }
318
319 #[test]
320 fn fail2ban_unban_ip_rejects_invalid_ip() {
321 let err = fail2ban_unban_ip("sshd", "256.0.0.1").unwrap_err();
322 assert_eq!(
323 err,
324 Fail2banError::InvalidIpAddress("256.0.0.1".to_string())
325 );
326 }
327
328 #[test]
329 fn fail2ban_unban_ip_rejects_invalid_jail() {
330 let err = fail2ban_unban_ip("bad jail name", "10.0.0.1").unwrap_err();
331 assert!(matches!(err, Fail2banError::InvalidJail(_)));
332 }
333
334 #[test]
335 fn configure_jail_routes_through_helper() {
336 let spec = configure_fail2ban_jail("sshd", &["--maxretry".to_string(), "3".to_string()]);
337 let (program, args) = extract_args(&spec);
338 assert_eq!(program, "sudo");
339 assert_eq!(args, vec![JAIL_HELPER, "--name", "sshd", "--maxretry", "3"]);
340 assert_eq!(spec.risk_level, RiskLevel::High);
341 }
342
343 #[test]
346 fn specs_covers_all_action_names() {
347 let expected = [
348 "Fail2banStatus",
349 "Fail2banBanIp",
350 "Fail2banUnbanIp",
351 "ConfigureFail2banJail",
352 ];
353 let spec_names: Vec<&str> = specs().iter().map(|s| s.action_name).collect();
354 for name in &expected {
355 assert!(spec_names.contains(name), "specs() missing {name}");
356 }
357 }
358}