16214f3c66
Peer discovery is now the single owner of how wg is invoked and parsed. wg.rs: - resolve the binary through ordered amnezia.wg_binaries (default [awg, wg]); awg is the real binary in the official image and wg is a symlink to it, so awg is tried first and wg stays for plain WireGuard containers. No probe call: resolution is a property of the command being run anyway. - drop the nsenter fallback. AmneziaWG is userspace and its UAPI socket lives under RUNSTATEDIR/amneziawg/ in the container mount namespace, while host wireguard-tools looks under /var/run/wireguard/. nsenter -n enters only the network namespace, so that path could never return peers. - classify dump lines structurally instead of by column count. The AWG interface line is longer than a peer line (14 columns on AWG 2.0, 28 on AWG 3.0) and its handshake/rx/tx positions hold junk-packet sizes that parse as integers, so only the allowed-ips column discriminates. Column 1 of an interface line is the device private key, which the previous heuristic rejected only incidentally. - fix allowed-ips parsing: `show all allowed-ips` separates a peer's addresses with spaces, not commas, so peers with more than one allowed IP were shaped on their first address only and the rest ran unlimited. - collapse the three duplicated /32-in-CIDR filters into one tokenizer shared by the allowed-ips, dump and pretty-print parsers. config.rs: - add amnezia.wg_binaries with serde default, validated as bare command names (non-empty, no whitespace, no path separators) since they execute inside the container. Existing deployed configs keep parsing unchanged. - remove host wg from the required-tool preflight. It gated daemon startup on wireguard-tools for a code path that produced nothing. status.rs: - discover peers once instead of twice per status call and degrade gracefully like the neighbouring sections instead of aborting the whole output. Tests: real AWG 3.0 / AWG 2.0 / plain WireGuard interface-line fixtures, AWG 3.0 range keepalive, space-separated allowed-ips, binary resolution order, and a test that parses and validates the shipped config.example.toml.
845 lines
26 KiB
Rust
845 lines
26 KiB
Rust
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<String> {
|
|
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<String> {
|
|
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<String>,
|
|
pub interface_candidates: Vec<String>,
|
|
pub interface_patterns: Vec<String>,
|
|
pub peer_ipv4_cidr: String,
|
|
#[serde(default = "default_wg_binaries")]
|
|
pub wg_binaries: Vec<String>,
|
|
}
|
|
|
|
#[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<String>,
|
|
#[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<Self> {
|
|
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 <container> 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::<u8>()?;
|
|
let m = s
|
|
.next()
|
|
.ok_or_else(|| anyhow::anyhow!("invalid time: {v}"))?
|
|
.parse::<u8>()?;
|
|
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<u64> {
|
|
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::<u64>()
|
|
.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::<Ipv4Addr>()?;
|
|
let prefix = parts
|
|
.next()
|
|
.ok_or_else(|| anyhow::anyhow!("invalid cidr: {cidr}"))?
|
|
.parse::<u8>()?;
|
|
if prefix > 32 {
|
|
anyhow::bail!("invalid prefix in cidr: {cidr}");
|
|
}
|
|
Ok((ip, prefix))
|
|
}
|
|
|
|
pub fn ip_in_cidr(ip: Ipv4Addr, cidr: &str) -> anyhow::Result<bool> {
|
|
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::<Config>(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::<Config>(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::<Config>(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"));
|
|
}
|
|
}
|