use crate::config::{Backend, Config, LoggingConfig};
fn valid_toml() -> String {
r#"
[global]
state_file = "/tmp/state.bin"
socket_path = "/tmp/fail2ban-rs.sock"
log_level = "debug"
channel_size = 512
[jail.sshd]
enabled = true
log_path = "/var/log/auth.log"
date_format = "syslog"
filter = ['sshd\[\d+\]: Failed password for .* from <HOST>']
max_retry = 5
find_time = 600
ban_time = 3600
backend = "nftables"
ignoreip = ["127.0.0.1/8"]
ignoreself = true
"#
.to_string()
}
#[test]
fn parse_valid_config() {
let config = Config::parse(&valid_toml()).unwrap();
assert_eq!(config.global.channel_size, 512);
assert_eq!(config.jail.len(), 1);
let sshd = &config.jail["sshd"];
assert!(sshd.enabled);
assert_eq!(sshd.max_retry, 5);
assert_eq!(sshd.find_time, 600);
assert_eq!(sshd.ban_time, 3600);
}
#[test]
fn enabled_jails_filter() {
let toml = r#"
[global]
[jail.sshd]
enabled = true
log_path = "/var/log/auth.log"
filter = ['from <HOST>']
[jail.nginx]
enabled = false
log_path = "/var/log/nginx.log"
filter = ['from <HOST>']
"#;
let config = Config::parse(toml).unwrap();
let enabled: Vec<&str> = config.enabled_jails().map(|(name, _)| name).collect();
assert_eq!(enabled.len(), 1);
assert!(enabled.contains(&"sshd"));
}
#[test]
fn no_jails_error() {
let toml = "[global]\n";
assert!(Config::parse(toml).is_err());
}
#[test]
fn no_enabled_jails_error() {
let toml = r#"
[global]
[jail.sshd]
enabled = false
log_path = "/var/log/auth.log"
filter = ['from <HOST>']
"#;
assert!(Config::parse(toml).is_err());
}
#[test]
fn missing_host_placeholder_error() {
let toml = r#"
[global]
[jail.sshd]
enabled = true
log_path = "/var/log/auth.log"
filter = ['Failed password for .*']
"#;
assert!(Config::parse(toml).is_err());
}
#[test]
fn zero_max_retry_error() {
let toml = r#"
[global]
[jail.sshd]
enabled = true
log_path = "/var/log/auth.log"
filter = ['from <HOST>']
max_retry = 0
"#;
assert!(Config::parse(toml).is_err());
}
#[test]
fn defaults_applied() {
let toml = r#"
[global]
[jail.sshd]
log_path = "/var/log/auth.log"
filter = ['from <HOST>']
"#;
let config = Config::parse(toml).unwrap();
let sshd = &config.jail["sshd"];
assert_eq!(sshd.max_retry, 5);
assert_eq!(sshd.find_time, 600);
assert_eq!(sshd.ban_time, 3600);
assert!(sshd.enabled);
assert!(sshd.ignoreself);
}
#[test]
fn script_backend() {
let toml = r#"
[global]
[jail.custom]
log_path = "/var/log/custom.log"
filter = ['from <HOST>']
[jail.custom.backend]
script = { ban_cmd = "echo ban <IP>", unban_cmd = "echo unban <IP>" }
"#;
let result = Config::parse(toml);
let _ = result;
}
#[test]
fn iptables_backend() {
let toml = r#"
[global]
[jail.sshd]
log_path = "/var/log/auth.log"
filter = ['from <HOST>']
backend = "iptables"
"#;
let config = Config::parse(toml).unwrap();
let sshd = &config.jail["sshd"];
assert!(matches!(sshd.backend, Backend::Iptables));
}
#[test]
fn multiple_filters() {
let toml = r#"
[global]
[jail.sshd]
log_path = "/var/log/auth.log"
filter = [
'Failed password for .* from <HOST>',
'Invalid user .* from <HOST>',
'Connection closed by .* <HOST>',
]
"#;
let config = Config::parse(toml).unwrap();
assert_eq!(config.jail["sshd"].filter.len(), 3);
}
#[test]
fn zero_find_time_error() {
let toml = r#"
[global]
[jail.sshd]
log_path = "/var/log/auth.log"
filter = ['from <HOST>']
find_time = 0
"#;
let err = Config::parse(toml).unwrap_err();
assert!(err.to_string().contains("find_time"), "got: {err}");
}
#[test]
fn negative_find_time_error() {
let toml = r#"
[global]
[jail.sshd]
log_path = "/var/log/auth.log"
filter = ['from <HOST>']
find_time = -10
"#;
assert!(Config::parse(toml).is_err());
}
#[test]
fn zero_ban_time_error() {
let toml = r#"
[global]
[jail.sshd]
log_path = "/var/log/auth.log"
filter = ['from <HOST>']
ban_time = 0
"#;
let err = Config::parse(toml).unwrap_err();
assert!(err.to_string().contains("ban_time"), "got: {err}");
}
#[test]
fn permanent_ban_time_ok() {
let toml = r#"
[global]
[jail.sshd]
log_path = "/var/log/auth.log"
filter = ['from <HOST>']
ban_time = -1
"#;
let config = Config::parse(toml).unwrap();
assert_eq!(config.jail["sshd"].ban_time, -1);
}
#[test]
fn from_file_not_found() {
let result = Config::from_file(std::path::Path::new("/nonexistent/config.toml"));
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("not found"), "got: {err}");
}
#[test]
fn from_file_invalid_toml() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("bad.toml");
std::fs::write(&path, "not valid [[ toml").unwrap();
let result = Config::from_file(&path);
assert!(result.is_err());
}
#[test]
fn multiple_enabled_jails() {
let toml = r#"
[global]
[jail.sshd]
log_path = "/var/log/auth.log"
filter = ['from <HOST>']
[jail.nginx]
log_path = "/var/log/nginx.log"
filter = ['client <HOST>']
[jail.postfix]
log_path = "/var/log/mail.log"
filter = ['reject from <HOST>']
"#;
let config = Config::parse(toml).unwrap();
let enabled: Vec<&str> = config.enabled_jails().map(|(name, _)| name).collect();
assert_eq!(enabled.len(), 3);
}
#[test]
fn empty_filter_error() {
let toml = r#"
[global]
[jail.sshd]
log_path = "/var/log/auth.log"
filter = []
"#;
let err = Config::parse(toml).unwrap_err();
assert!(err.to_string().contains("no filter"), "got: {err}");
}
#[test]
fn logging_section_parses() {
let toml = r#"
[global]
[logging]
destination = "tell"
endpoint = "ingest.tell.rs:9090"
api_key = "a1b2c3d4e5f60718293a4b5c6d7e8f90"
level = "warn"
service = "my-server"
[jail.sshd]
log_path = "/var/log/auth.log"
filter = ['from <HOST>']
"#;
let config = Config::parse(toml).unwrap();
assert_eq!(config.logging.destination.as_deref(), Some("tell"));
assert_eq!(
config.logging.endpoint.as_deref(),
Some("ingest.tell.rs:9090")
);
assert_eq!(
config.logging.api_key.as_deref(),
Some("a1b2c3d4e5f60718293a4b5c6d7e8f90")
);
assert_eq!(config.logging.level.as_deref(), Some("warn"));
assert_eq!(config.logging.service.as_deref(), Some("my-server"));
}
#[test]
fn logging_defaults_when_omitted() {
let toml = r#"
[global]
[jail.sshd]
log_path = "/var/log/auth.log"
filter = ['from <HOST>']
"#;
let config = Config::parse(toml).unwrap();
let logging = &config.logging;
assert!(logging.destination.is_none());
assert!(logging.endpoint.is_none());
assert!(logging.api_key.is_none());
assert!(logging.level.is_none());
assert!(logging.service.is_none());
assert_eq!(logging.clone(), LoggingConfig::default());
}
#[test]
fn config_d_overlay_adds_jail() {
let dir = tempfile::tempdir().unwrap();
let main_path = dir.path().join("config.toml");
std::fs::write(
&main_path,
r#"
[global]
[jail.sshd]
log_path = "/var/log/auth.log"
filter = ['from <HOST>']
"#,
)
.unwrap();
let config_d = dir.path().join("config.d");
std::fs::create_dir(&config_d).unwrap();
std::fs::write(
config_d.join("nginx.toml"),
r#"
[jail.nginx]
log_path = "/var/log/nginx.log"
filter = ['client <HOST>']
"#,
)
.unwrap();
let config = Config::from_file(&main_path).unwrap();
assert_eq!(config.jail.len(), 2);
assert!(config.jail.contains_key("sshd"));
assert!(config.jail.contains_key("nginx"));
}
#[test]
fn config_d_overlay_overrides_scalar() {
let dir = tempfile::tempdir().unwrap();
let main_path = dir.path().join("config.toml");
std::fs::write(
&main_path,
r#"
[global]
log_level = "info"
[jail.sshd]
log_path = "/var/log/auth.log"
filter = ['from <HOST>']
max_retry = 5
"#,
)
.unwrap();
let config_d = dir.path().join("config.d");
std::fs::create_dir(&config_d).unwrap();
std::fs::write(
config_d.join("override.toml"),
r#"
[jail.sshd]
max_retry = 10
"#,
)
.unwrap();
let config = Config::from_file(&main_path).unwrap();
assert_eq!(config.jail["sshd"].max_retry, 10);
}
#[test]
fn config_d_sorted_order() {
let dir = tempfile::tempdir().unwrap();
let main_path = dir.path().join("config.toml");
std::fs::write(
&main_path,
r#"
[global]
[jail.sshd]
log_path = "/var/log/auth.log"
filter = ['from <HOST>']
max_retry = 1
"#,
)
.unwrap();
let config_d = dir.path().join("config.d");
std::fs::create_dir(&config_d).unwrap();
std::fs::write(config_d.join("b.toml"), "[jail.sshd]\nmax_retry = 5\n").unwrap();
std::fs::write(config_d.join("c.toml"), "[jail.sshd]\nmax_retry = 8\n").unwrap();
let config = Config::from_file(&main_path).unwrap();
assert_eq!(config.jail["sshd"].max_retry, 8);
}
#[test]
fn config_d_missing_dir_is_fine() {
let dir = tempfile::tempdir().unwrap();
let main_path = dir.path().join("config.toml");
std::fs::write(
&main_path,
r#"
[global]
[jail.sshd]
log_path = "/var/log/auth.log"
filter = ['from <HOST>']
"#,
)
.unwrap();
let config = Config::from_file(&main_path).unwrap();
assert_eq!(config.jail.len(), 1);
}
#[test]
fn duration_strings_in_config() {
let toml = r#"
[global]
[jail.sshd]
log_path = "/var/log/auth.log"
filter = ['from <HOST>']
find_time = "10m"
ban_time = "1h"
bantime_maxtime = "1w"
"#;
let config = Config::parse(toml).unwrap();
let sshd = &config.jail["sshd"];
assert_eq!(sshd.find_time, 600);
assert_eq!(sshd.ban_time, 3600);
assert_eq!(sshd.bantime_maxtime, 604800);
}
#[test]
fn duration_integers_still_work() {
let toml = r#"
[global]
[jail.sshd]
log_path = "/var/log/auth.log"
filter = ['from <HOST>']
find_time = 600
ban_time = 3600
"#;
let config = Config::parse(toml).unwrap();
assert_eq!(config.jail["sshd"].find_time, 600);
assert_eq!(config.jail["sshd"].ban_time, 3600);
}
#[test]
fn ignoreregex_parses() {
let toml = r#"
[global]
[jail.sshd]
log_path = "/var/log/auth.log"
filter = ['from <HOST>']
ignoreregex = ['Accepted', 'internal']
"#;
let config = Config::parse(toml).unwrap();
assert_eq!(config.jail["sshd"].ignoreregex.len(), 2);
}
#[test]
fn ignoreregex_defaults_empty() {
let toml = r#"
[global]
[jail.sshd]
log_path = "/var/log/auth.log"
filter = ['from <HOST>']
"#;
let config = Config::parse(toml).unwrap();
assert!(config.jail["sshd"].ignoreregex.is_empty());
}