use std::{fs, net::Ipv4Addr, path::Path, process::Command}; use serde::Deserialize; fn default_host_firewall_enabled() -> bool { true } fn default_ssh_port() -> u16 { 2323 } fn default_amnezia_port() -> u16 { 443 } fn default_amnezia_protocols() -> Vec { vec!["udp".to_string()] } fn default_allow_icmp() -> bool { true } fn default_input_policy() -> String { "drop".to_string() } /// The official AmneziaWG image ships `/usr/bin/awg` and symlinks `/usr/bin/wg` to it, /// so `awg` is the real dependency; `wg` stays as fallback for plain WireGuard containers. fn default_wg_binaries() -> Vec { vec!["awg".to_string(), "wg".to_string()] } fn default_sysctl_tuning_for_missing_block() -> SysctlTuningConfig { SysctlTuningConfig { enabled: false, ..SysctlTuningConfig::default() } } #[derive(Debug, Clone, Deserialize)] pub struct Config { pub amnezia: AmneziaConfig, pub traffic: TrafficConfig, #[serde(default = "default_sysctl_tuning_for_missing_block")] pub sysctl_tuning: SysctlTuningConfig, pub host_firewall: HostFirewallConfig, pub anti_torrent: AntiTorrentConfig, pub reboot: RebootConfig, pub swap: SwapConfig, } #[derive(Debug, Clone, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct SysctlTuningConfig { pub enabled: bool, pub apply_on_daemon_start: bool, pub ip_forward: bool, pub tcp_bbr: String, pub default_qdisc: String, pub rmem_max: u64, pub wmem_max: u64, pub netdev_backlog_enabled: bool, pub netdev_max_backlog: u32, pub udp_rmem_min_enabled: bool, pub udp_rmem_min: u32, } impl Default for SysctlTuningConfig { fn default() -> Self { Self { enabled: true, apply_on_daemon_start: true, ip_forward: true, tcp_bbr: "auto".to_string(), default_qdisc: "fq".to_string(), rmem_max: 67_108_864, wmem_max: 67_108_864, netdev_backlog_enabled: true, netdev_max_backlog: 50_000, udp_rmem_min_enabled: true, udp_rmem_min: 8192, } } } #[derive(Debug, Clone, Deserialize)] pub struct AmneziaConfig { pub container_names: Vec, pub interface_candidates: Vec, pub interface_patterns: Vec, pub peer_ipv4_cidr: String, #[serde(default = "default_wg_binaries")] pub wg_binaries: Vec, } #[derive(Debug, Clone, Deserialize)] pub struct TrafficConfig { pub enabled: bool, pub peer_limit: String, pub root_rate: String, pub reconcile_interval_secs: u64, pub container_ready_timeout_secs: u64, pub class_start_id: u16, pub ifb_name: String, } #[derive(Debug, Clone, Deserialize)] pub struct AntiTorrentConfig { pub enabled: bool, pub max_connections_per_peer: u32, pub peer_ipv4_cidr: String, pub apply_in_amnezia_netns: bool, } #[derive(Debug, Clone, Deserialize)] pub struct HostFirewallConfig { #[serde(default = "default_host_firewall_enabled")] pub enabled: bool, #[serde(default = "default_ssh_port")] pub ssh_port: u16, #[serde(default = "default_amnezia_port")] pub amnezia_port: u16, #[serde(default = "default_amnezia_protocols")] pub amnezia_protocols: Vec, #[serde(default = "default_allow_icmp")] pub allow_icmp: bool, #[serde(default = "default_input_policy")] pub default_input_policy: String, } impl Default for HostFirewallConfig { fn default() -> Self { Self { enabled: default_host_firewall_enabled(), ssh_port: default_ssh_port(), amnezia_port: default_amnezia_port(), amnezia_protocols: default_amnezia_protocols(), allow_icmp: default_allow_icmp(), default_input_policy: default_input_policy(), } } } #[derive(Debug, Clone, Deserialize)] pub struct RebootConfig { pub enabled: bool, pub window_start_utc: String, pub window_end_utc: String, } #[derive(Debug, Clone, Deserialize)] pub struct SwapConfig { pub enabled: bool, pub path: String, pub size_mb: u64, pub priority: i32, pub swappiness: u8, } impl Config { pub fn load(path: &Path) -> anyhow::Result { let raw = fs::read_to_string(path)?; let cfg: Self = toml::from_str(&raw)?; Ok(cfg) } pub fn validate_config(&self) -> anyhow::Result<()> { if self.amnezia.container_names.is_empty() { anyhow::bail!("amnezia.container_names must not be empty"); } if self.amnezia.interface_candidates.is_empty() { anyhow::bail!("amnezia.interface_candidates must not be empty"); } if self.amnezia.interface_patterns.is_empty() { anyhow::bail!("amnezia.interface_patterns must not be empty"); } if self.amnezia.wg_binaries.is_empty() { anyhow::bail!("amnezia.wg_binaries must not be empty"); } for binary in &self.amnezia.wg_binaries { // Values are executed inside the container: keep them bare command names. if binary.is_empty() { anyhow::bail!("amnezia.wg_binaries must not contain an empty value"); } if binary.chars().any(char::is_whitespace) { anyhow::bail!("amnezia.wg_binaries must not contain whitespace: {binary}"); } if binary.contains('/') || binary.contains('\\') { anyhow::bail!( "amnezia.wg_binaries must be bare command names without a path: {binary}" ); } } if self.traffic.class_start_id < 10 { anyhow::bail!("traffic.class_start_id must be >= 10"); } if self.traffic.ifb_name.trim().is_empty() { anyhow::bail!("traffic.ifb_name must not be empty"); } if self.traffic.peer_limit.trim().is_empty() { anyhow::bail!("traffic.peer_limit must not be empty"); } if self.traffic.root_rate.trim().is_empty() { anyhow::bail!("traffic.root_rate must not be empty"); } parse_tc_rate_bps(&self.traffic.peer_limit)?; parse_tc_rate_bps(&self.traffic.root_rate)?; let tcp_bbr_mode = self.sysctl_tuning.tcp_bbr.trim().to_ascii_lowercase(); if tcp_bbr_mode != "off" && tcp_bbr_mode != "auto" && tcp_bbr_mode != "require" { anyhow::bail!("sysctl_tuning.tcp_bbr must be one of: off, auto, require"); } let default_qdisc = self.sysctl_tuning.default_qdisc.trim().to_ascii_lowercase(); if default_qdisc != "none" && default_qdisc != "fq" && default_qdisc != "fq_codel" { anyhow::bail!("sysctl_tuning.default_qdisc must be one of: none, fq, fq_codel"); } const MIB: u64 = 1024 * 1024; if self.sysctl_tuning.rmem_max < MIB || self.sysctl_tuning.rmem_max > 512 * MIB { anyhow::bail!("sysctl_tuning.rmem_max must be in 1MiB..=512MiB"); } if self.sysctl_tuning.wmem_max < MIB || self.sysctl_tuning.wmem_max > 512 * MIB { anyhow::bail!("sysctl_tuning.wmem_max must be in 1MiB..=512MiB"); } if self.sysctl_tuning.netdev_max_backlog < 1000 || self.sysctl_tuning.netdev_max_backlog > 100_000 { anyhow::bail!("sysctl_tuning.netdev_max_backlog must be in 1000..=100000"); } if self.sysctl_tuning.udp_rmem_min < 4096 || self.sysctl_tuning.udp_rmem_min > 65_536 { anyhow::bail!("sysctl_tuning.udp_rmem_min must be in 4096..=65536"); } if self.swap.swappiness > 100 { anyhow::bail!("swap.swappiness must be <= 100"); } if self.traffic.container_ready_timeout_secs == 0 { anyhow::bail!("traffic.container_ready_timeout_secs must be > 0"); } if self.traffic.reconcile_interval_secs == 0 { anyhow::bail!("traffic.reconcile_interval_secs must be > 0"); } if self.swap.enabled { if self.swap.size_mb < 64 { anyhow::bail!("swap.size_mb must be >= 64 when swap.enabled=true"); } if self.swap.size_mb > 65536 { anyhow::bail!("swap.size_mb must be <= 65536 when swap.enabled=true"); } if !self.swap.path.starts_with('/') { anyhow::bail!("swap.path must be absolute"); } let dangerous = ["/", "/etc", "/etc/fstab", "/dev", "/proc", "/sys", "/run"]; if dangerous.iter().any(|p| *p == self.swap.path) { anyhow::bail!("swap.path is dangerous and forbidden: {}", self.swap.path); } if self.swap.priority < -1 || self.swap.priority > 32767 { anyhow::bail!("swap.priority must be between -1 and 32767"); } } if self.anti_torrent.enabled && self.anti_torrent.max_connections_per_peer == 0 { anyhow::bail!("anti_torrent.max_connections_per_peer must be > 0"); } if self.host_firewall.ssh_port == 0 { anyhow::bail!("host_firewall.ssh_port must be in 1..=65535"); } if self.host_firewall.amnezia_port == 0 { anyhow::bail!("host_firewall.amnezia_port must be in 1..=65535"); } if self.host_firewall.amnezia_protocols.is_empty() { anyhow::bail!( "host_firewall.amnezia_protocols must not be empty; allowed values: tcp, udp" ); } let mut normalized = std::collections::BTreeSet::new(); for p in &self.host_firewall.amnezia_protocols { let pp = p.trim().to_ascii_lowercase(); if pp != "tcp" && pp != "udp" { anyhow::bail!( "host_firewall.amnezia_protocols contains invalid value: {p}; allowed: tcp, udp" ); } if !normalized.insert(pp) { anyhow::bail!("host_firewall.amnezia_protocols contains duplicate protocol: {p}"); } } let policy = self .host_firewall .default_input_policy .trim() .to_ascii_lowercase(); if policy != "drop" && policy != "accept" { anyhow::bail!("host_firewall.default_input_policy must be one of: drop, accept"); } if self.reboot.enabled && self.reboot.window_start_utc == self.reboot.window_end_utc { anyhow::bail!("reboot window start and end must differ"); } if self.reboot.enabled { parse_hhmm(&self.reboot.window_start_utc)?; parse_hhmm(&self.reboot.window_end_utc)?; } parse_cidr_v4(&self.amnezia.peer_ipv4_cidr)?; parse_cidr_v4(&self.anti_torrent.peer_ipv4_cidr)?; Ok(()) } pub fn validate_runtime(&self) -> anyhow::Result<()> { self.check_supported_host()?; self.check_host_tools() } pub fn validate_sysctl_tuning_runtime(&self) -> anyhow::Result<()> { self.validate_config()?; self.check_supported_host()?; if !self.sysctl_tuning.enabled { return Ok(()); } if !tool_exists("sysctl") { anyhow::bail!("required host tool not found in PATH: sysctl"); } let tcp_bbr_mode = self.sysctl_tuning.tcp_bbr.trim().to_ascii_lowercase(); if tcp_bbr_mode == "require" && !tool_exists("modprobe") { anyhow::bail!("required host tool not found in PATH: modprobe"); } Ok(()) } pub fn validate_all(&self) -> anyhow::Result<()> { self.validate_config()?; self.validate_runtime()?; Ok(()) } fn check_host_tools(&self) -> anyhow::Result<()> { // `wg` is intentionally not required on the host: peer state is read with // `docker exec awg|wg`, never with a host-side binary. let mut required = vec!["tc", "ip", "docker"]; if !crate::netns::nsenter_available() { anyhow::bail!("required host tool not found in PATH: nsenter"); } required.push("nsenter"); let tcp_bbr_mode = self.sysctl_tuning.tcp_bbr.trim().to_ascii_lowercase(); if self.sysctl_tuning.enabled { required.push("sysctl"); } if requires_modprobe( self.traffic.enabled, self.sysctl_tuning.enabled, &tcp_bbr_mode, ) { required.push("modprobe"); } if self.anti_torrent.enabled { required.push("nft"); } if self.host_firewall.enabled { required.push("nft"); } if self.reboot.enabled { required.push("systemctl"); } if self.swap.enabled { required.extend([ "fallocate", "chmod", "mkswap", "swapon", "swapoff", "sysctl", "dd", ]); } required.sort_unstable(); required.dedup(); for tool in required { if !tool_exists(tool) { anyhow::bail!("required host tool not found in PATH: {tool}"); } } Ok(()) } fn check_supported_host(&self) -> anyhow::Result<()> { let os_release = std::fs::read_to_string("/etc/os-release").map_err(|_| { anyhow::anyhow!( "unsupported host: missing /etc/os-release; only Debian 13 amd64 is supported" ) })?; let mut id = String::new(); let mut version_id = String::new(); for line in os_release.lines() { if let Some((k, v)) = line.split_once('=') { let val = v.trim_matches('"'); match k { "ID" => id = val.to_string(), "VERSION_ID" => version_id = val.to_string(), _ => {} } } } if id != "debian" || version_id != "13" { anyhow::bail!( "unsupported host: only Debian 13 amd64 is supported (got ID={id}, VERSION_ID={version_id})" ); } let arch = std::process::Command::new("dpkg") .args(["--print-architecture"]) .output() .ok() .and_then(|o| { if o.status.success() { Some(String::from_utf8_lossy(&o.stdout).trim().to_string()) } else { None } }) .unwrap_or_else(|| std::env::consts::ARCH.to_string()); if arch != "amd64" && arch != "x86_64" { anyhow::bail!("unsupported host arch: {arch}; only Debian 13 amd64 is supported"); } Ok(()) } } fn requires_modprobe(traffic_enabled: bool, sysctl_enabled: bool, tcp_bbr_mode: &str) -> bool { traffic_enabled || (sysctl_enabled && tcp_bbr_mode == "require") } fn parse_hhmm(v: &str) -> anyhow::Result<()> { let mut s = v.split(':'); let h = s .next() .ok_or_else(|| anyhow::anyhow!("invalid time: {v}"))? .parse::()?; let m = s .next() .ok_or_else(|| anyhow::anyhow!("invalid time: {v}"))? .parse::()?; if s.next().is_some() { anyhow::bail!("invalid time: {v}"); } if h > 23 || m > 59 { anyhow::bail!("invalid time: {v}"); } Ok(()) } fn parse_tc_rate_bps(v: &str) -> anyhow::Result { let raw = v.trim().to_ascii_lowercase().replace(' ', ""); if raw.is_empty() { anyhow::bail!("invalid tc rate: empty"); } let (num, mul) = if let Some(n) = raw.strip_suffix("gbit") { (n, 1_000_000_000u64) } else if let Some(n) = raw.strip_suffix("mbit") { (n, 1_000_000u64) } else if let Some(n) = raw.strip_suffix("kbit") { (n, 1_000u64) } else if let Some(n) = raw.strip_suffix("bit") { (n, 1u64) } else { anyhow::bail!("invalid tc rate unit: {v}"); }; let base = num .parse::() .map_err(|_| anyhow::anyhow!("invalid tc rate number: {v}"))?; base.checked_mul(mul) .ok_or_else(|| anyhow::anyhow!("tc rate overflow: {v}")) } fn tool_exists(tool: &str) -> bool { let probes: &[&[&str]] = match tool { "tc" => &[&["-V"]], "ip" => &[&["-V"], &["-Version"]], "docker" => &[&["--version"]], "modprobe" => &[&["--version"]], "nsenter" => &[&["--help"]], "nft" => &[&["--version"]], "systemctl" => &[&["--version"], &["--help"]], "fallocate" => &[&["--help"]], "chmod" => &[&["--help"]], "mkswap" => &[&["--help"]], "swapon" => &[&["--help"]], "swapoff" => &[&["--help"]], "sysctl" => &[&["--version"], &["--help"]], "dd" => &[&["--help"]], _ => &[&["--version"], &["--help"], &["-V"]], }; for args in probes { if Command::new(tool).args(*args).output().is_ok() { return true; } } false } pub fn parse_cidr_v4(cidr: &str) -> anyhow::Result<(Ipv4Addr, u8)> { let mut parts = cidr.split('/'); let ip = parts .next() .ok_or_else(|| anyhow::anyhow!("invalid cidr: {cidr}"))? .parse::()?; let prefix = parts .next() .ok_or_else(|| anyhow::anyhow!("invalid cidr: {cidr}"))? .parse::()?; if prefix > 32 { anyhow::bail!("invalid prefix in cidr: {cidr}"); } Ok((ip, prefix)) } pub fn ip_in_cidr(ip: Ipv4Addr, cidr: &str) -> anyhow::Result { let (base, prefix) = parse_cidr_v4(cidr)?; let mask = if prefix == 0 { 0u32 } else { u32::MAX << (32 - prefix) }; let ip_u = u32::from(ip); let base_u = u32::from(base); Ok((ip_u & mask) == (base_u & mask)) } #[cfg(test)] mod tests { use super::*; fn base_config() -> Config { Config { amnezia: AmneziaConfig { container_names: vec!["x".into()], interface_candidates: vec!["awg0".into()], interface_patterns: vec!["awg*".into()], peer_ipv4_cidr: "10.8.1.0/24".into(), wg_binaries: vec!["awg".into(), "wg".into()], }, traffic: TrafficConfig { enabled: true, peer_limit: "70mbit".into(), root_rate: "1000mbit".into(), reconcile_interval_secs: 15, container_ready_timeout_secs: 30, class_start_id: 10, ifb_name: "ifb0".into(), }, sysctl_tuning: SysctlTuningConfig::default(), host_firewall: HostFirewallConfig::default(), anti_torrent: AntiTorrentConfig { enabled: false, max_connections_per_peer: 100, peer_ipv4_cidr: "10.8.1.0/24".into(), apply_in_amnezia_netns: false, }, reboot: RebootConfig { enabled: false, window_start_utc: "01:00".into(), window_end_utc: "02:00".into(), }, swap: SwapConfig { enabled: false, path: "/swapfile".into(), size_mb: 2048, priority: 1, swappiness: 1, }, } } #[test] fn parse_cidr_invalid_prefix() { let err = parse_cidr_v4("10.8.1.0/33").err(); assert!(err.is_some()); } #[test] fn ip_membership() { let ok = ip_in_cidr("10.8.1.10".parse().expect("ip"), "10.8.1.0/24").expect("cidr"); assert!(ok); let no = ip_in_cidr("10.9.1.10".parse().expect("ip"), "10.8.1.0/24").expect("cidr"); assert!(!no); } #[test] fn parse_hhmm_works() { assert!(parse_hhmm("01:00").is_ok()); assert!(parse_hhmm("23:59").is_ok()); assert!(parse_hhmm("24:00").is_err()); } #[test] fn parse_tc_rate_works() { assert_eq!(parse_tc_rate_bps("1gbit").expect("bps"), 1_000_000_000); assert_eq!(parse_tc_rate_bps("1000mbit").expect("bps"), 1_000_000_000); assert!(parse_tc_rate_bps("foo").is_err()); } #[test] fn swap_rejects_dangerous_path() { let mut cfg = base_config(); cfg.swap.enabled = true; cfg.swap.path = "/etc/fstab".into(); assert!(cfg.validate_config().is_err()); } #[test] fn host_firewall_rejects_empty_protocols() { let mut cfg = base_config(); cfg.host_firewall.amnezia_protocols = vec![]; assert!(cfg.validate_config().is_err()); } #[test] fn sysctl_tuning_default_is_valid() { let cfg = base_config(); assert!(cfg.validate_config().is_ok()); } #[test] fn sysctl_tuning_rejects_invalid_tcp_bbr() { let mut cfg = base_config(); cfg.sysctl_tuning.tcp_bbr = "bad".into(); assert!(cfg.validate_config().is_err()); } #[test] fn sysctl_tuning_rejects_invalid_default_qdisc() { let mut cfg = base_config(); cfg.sysctl_tuning.default_qdisc = "cake".into(); assert!(cfg.validate_config().is_err()); } #[test] fn sysctl_tuning_rejects_rmem_max_below_1mib() { let mut cfg = base_config(); cfg.sysctl_tuning.rmem_max = 1024; assert!(cfg.validate_config().is_err()); } #[test] fn sysctl_tuning_rejects_rmem_max_above_512mib() { let mut cfg = base_config(); cfg.sysctl_tuning.rmem_max = 513 * 1024 * 1024; assert!(cfg.validate_config().is_err()); } #[test] fn sysctl_tuning_rejects_netdev_backlog_above_limit() { let mut cfg = base_config(); cfg.sysctl_tuning.netdev_max_backlog = 100_001; assert!(cfg.validate_config().is_err()); } #[test] fn sysctl_tuning_rejects_udp_rmem_min_below_limit() { let mut cfg = base_config(); cfg.sysctl_tuning.udp_rmem_min = 4095; assert!(cfg.validate_config().is_err()); } /// The shipped example is the template every deployment starts from: it must parse and /// validate against the current schema. #[test] fn config_example_parses_and_validates() { let raw = include_str!("../../config.example.toml"); let cfg = toml::from_str::(raw).expect("config.example.toml parse"); cfg.validate_config().expect("config.example.toml validate"); assert_eq!(cfg.amnezia.wg_binaries, vec!["awg", "wg"]); assert!( cfg.amnezia .container_names .contains(&"amnezia-awg2".to_string()) ); } #[test] fn wg_binaries_default_prefers_awg() { let cfg = base_config(); assert_eq!(default_wg_binaries(), vec!["awg", "wg"]); assert!(cfg.validate_config().is_ok()); } #[test] fn wg_binaries_rejects_empty_list() { let mut cfg = base_config(); cfg.amnezia.wg_binaries = vec![]; assert!(cfg.validate_config().is_err()); } #[test] fn wg_binaries_rejects_empty_value() { let mut cfg = base_config(); cfg.amnezia.wg_binaries = vec!["awg".into(), "".into()]; assert!(cfg.validate_config().is_err()); } #[test] fn wg_binaries_rejects_whitespace_and_paths() { for bad in ["awg show", "/usr/bin/awg", "..\\awg"] { let mut cfg = base_config(); cfg.amnezia.wg_binaries = vec![bad.into()]; assert!( cfg.validate_config().is_err(), "must reject wg binary value: {bad}" ); } } #[test] fn config_requires_host_firewall_section() { let raw = r#" [amnezia] container_names = ["amnezia-awg2"] interface_candidates = ["awg0"] interface_patterns = ["awg*"] peer_ipv4_cidr = "10.8.1.0/24" [traffic] enabled = true peer_limit = "70mbit" root_rate = "1000mbit" reconcile_interval_secs = 15 container_ready_timeout_secs = 30 class_start_id = 10 ifb_name = "ifb0" [anti_torrent] enabled = false max_connections_per_peer = 100 peer_ipv4_cidr = "10.8.1.0/24" apply_in_amnezia_netns = false [reboot] enabled = false window_start_utc = "01:00" window_end_utc = "02:00" [swap] enabled = false path = "/swapfile" size_mb = 2048 priority = 1 swappiness = 1 "#; let parsed = toml::from_str::(raw); assert!(parsed.is_err()); } #[test] fn missing_sysctl_tuning_block_defaults_to_disabled() { let raw = r#" [amnezia] container_names = ["amnezia-awg2"] interface_candidates = ["awg0"] interface_patterns = ["awg*"] peer_ipv4_cidr = "10.8.1.0/24" [traffic] enabled = true peer_limit = "70mbit" root_rate = "1000mbit" reconcile_interval_secs = 15 container_ready_timeout_secs = 30 class_start_id = 10 ifb_name = "ifb0" [host_firewall] enabled = true ssh_port = 2323 amnezia_port = 443 amnezia_protocols = ["udp"] allow_icmp = true default_input_policy = "drop" [anti_torrent] enabled = false max_connections_per_peer = 100 peer_ipv4_cidr = "10.8.1.0/24" apply_in_amnezia_netns = false [reboot] enabled = false window_start_utc = "01:00" window_end_utc = "02:00" [swap] enabled = false path = "/swapfile" size_mb = 2048 priority = 1 swappiness = 1 "#; let cfg = toml::from_str::(raw).expect("config parse"); assert!(!cfg.sysctl_tuning.enabled); assert!(cfg.sysctl_tuning.apply_on_daemon_start); assert_eq!(cfg.sysctl_tuning.tcp_bbr, "auto"); // Deployed configs predate amnezia.wg_binaries and must keep parsing. assert_eq!(cfg.amnezia.wg_binaries, vec!["awg", "wg"]); } #[test] fn validate_sysctl_tuning_runtime_skips_modprobe_for_auto_when_disabled() { let mut cfg = base_config(); cfg.sysctl_tuning.enabled = false; cfg.sysctl_tuning.tcp_bbr = "auto".into(); if let Err(e) = cfg.validate_sysctl_tuning_runtime() { let msg = e.to_string(); assert!( !msg.contains("modprobe"), "validation must not require modprobe when sysctl_tuning is disabled, got error: {msg}" ); } } #[test] fn requires_modprobe_when_traffic_enabled() { assert!(requires_modprobe(true, true, "auto")); assert!(requires_modprobe(true, false, "off")); } #[test] fn requires_modprobe_for_sysctl_require_mode() { assert!(requires_modprobe(false, true, "require")); assert!(!requires_modprobe(false, true, "auto")); assert!(!requires_modprobe(false, true, "off")); } }