431732396b
Two release blockers, the first a regression introduced by dd5be47.
1. The anti-torrent batch was applied on the host.
Making the rebuild atomic moved it from run_step(transport, ..) to run_nft_batch(script),
and run_nft_batch spawned `nft` directly with Command::new. An nft transaction belongs to
the network namespace of the process that runs nft, so with the production default
apply_in_amnezia_netns = true the sequence became: inspect inside the container, install
the table on the host, verify inside the container, fail. The guard was silently
ineffective, a stray table accumulated on the host, and the next reconcile could fail on
`add table` because the host copy already existed.
NsTransport gains run_with_stdin, implemented for all three transports - host, nsenter,
and docker exec with -i, which is required or the child sees EOF immediately.
run_nft_batch now takes the transport, and both callers pass the one they inspected
with. A recording fake transport asserts the script goes through the transport rather
than to a host process, which is the regression this could not previously catch.
2. Swap replacement rolled back with the wrong priority and not from every branch.
The rollback reactivated with desired.priority - the value that may be exactly what the
kernel had just rejected - because the previously active priority was dropped at the
match arm and never passed in. The active entry's priority is now carried into
replace_active_swapfile and used by the rollback.
Three early failure branches also returned without undoing anything: a failed swapoff of
the original, a failed rename to .previous, and a failed swapoff of the staging file all
left the staging swap active and the transition half-applied, after which the next
reconcile tried to unlink a file that was still active and could not proceed.
The transition now records what it completed - staging_active, original_active,
original_moved, new_installed - and one rollback undoes exactly that much, in an order
that never leaves the host without swap: restore the original file, reactivate it at its
previous priority, confirm it, and only then release and remove the staging swap. The
returned error carries the original cause plus what the recovery achieved.
Leftovers from a killed process are no longer removed blindly. A .new or .previous that
is still active cannot be unlinked, so it is reported with the swapoff command that
clears it instead of failing the same way on every reconcile.
Also in this commit, from the same audit:
- Requires=docker.service dropped in favour of Wants=. The daemon is built to outlive
Docker being down, but Requires= had systemd stop the harness whenever Docker stopped,
and Restart=on-failure does not cover a dependency-ordered stop - so the reconnect loop
never got to run.
- apply_antitorrent_disabled swallowed every discover_target error, not just "no container
running". Docker being unreachable or a permission error on the socket reported success
for a cleanup that never happened. Only AppError::NotFound is tolerated now.
- FstabTransaction::begin asserts /etc/fstab is a regular non-symlink file. read_to_string
and metadata both follow links, so a symlinked fstab would be read through and then
replaced by the rename, detaching the real file from the path the system boots from.
- append_event serialises its read-modify-write behind a mutex; the reconcile loop and the
Docker events thread both append and could drop an entry.
- tool_exists loses the fallocate, chmod and dd arms, dead since mkswap --file replaced the
allocation chain.
Docs corrected where they no longer matched the code: findmnt is described as mandatory
rather than skippable, the swap creation wording is mkswap --file, and the README package
examples use 0.1.6 to match Cargo.toml. Remaining fallocate mentions are explicitly
historical ("the previous chain"), which is accurate.
Seven tests added: batch routed through the given transport, batch failure reported as
such, rollback state defaults and previous-priority carry, active leftovers reported rather
than deleted, inactive leftovers cleared.
cargo fmt --check, clippy -D warnings, cargo test --locked --workspace --release: 188 + 4
passed.
1045 lines
33 KiB
Rust
1045 lines
33 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<()> {
|
|
check_non_empty_unique(&self.amnezia.container_names, "amnezia.container_names")?;
|
|
for name in &self.amnezia.container_names {
|
|
validate_container_name(name)?;
|
|
}
|
|
|
|
check_non_empty_unique(
|
|
&self.amnezia.interface_candidates,
|
|
"amnezia.interface_candidates",
|
|
)?;
|
|
for name in &self.amnezia.interface_candidates {
|
|
validate_interface_name(name, "amnezia.interface_candidates")?;
|
|
}
|
|
|
|
check_non_empty_unique(
|
|
&self.amnezia.interface_patterns,
|
|
"amnezia.interface_patterns",
|
|
)?;
|
|
for pattern in &self.amnezia.interface_patterns {
|
|
validate_interface_pattern(pattern)?;
|
|
}
|
|
|
|
check_non_empty_unique(&self.amnezia.wg_binaries, "amnezia.wg_binaries")?;
|
|
for binary in &self.amnezia.wg_binaries {
|
|
// Values are executed inside the container: keep them bare command names.
|
|
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");
|
|
}
|
|
validate_interface_name(&self.traffic.ifb_name, "traffic.ifb_name")?;
|
|
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");
|
|
}
|
|
// One implementation, shared with the runtime path check in swap.rs.
|
|
crate::swap::validate_swap_path_syntax(&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 {
|
|
// fallocate, dd and chmod are gone: `mkswap --file --size` creates the file
|
|
// itself, so the allocation chain they served no longer exists.
|
|
required.extend([
|
|
"mkswap", "swapon", "swapoff", "sysctl",
|
|
// The candidate /etc/fstab is structurally validated with
|
|
// `findmnt --verify` before it replaces the live file. That check is
|
|
// mandatory, so its absence must fail preflight rather than be discovered
|
|
// at rewrite time.
|
|
"findmnt",
|
|
]);
|
|
}
|
|
|
|
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"]],
|
|
"mkswap" => &[&["--help"]],
|
|
"swapon" => &[&["--help"]],
|
|
"swapoff" => &[&["--help"]],
|
|
"sysctl" => &[&["--version"], &["--help"]],
|
|
"findmnt" => &[&["--version"]],
|
|
_ => &[&["--version"], &["--help"], &["-V"]],
|
|
};
|
|
|
|
for args in probes {
|
|
if Command::new(tool).args(*args).output().is_ok() {
|
|
return true;
|
|
}
|
|
}
|
|
false
|
|
}
|
|
|
|
/// Linux caps interface names at `IFNAMSIZ - 1`.
|
|
const MAX_INTERFACE_NAME_LEN: usize = 15;
|
|
|
|
/// Rejects list entries that are blank or repeated.
|
|
///
|
|
/// A blank entry silently becomes an unmatchable candidate, and a duplicate makes the
|
|
/// ordered lists lie about their priority, so both are configuration errors rather than
|
|
/// something to normalize away.
|
|
fn check_non_empty_unique(values: &[String], field: &str) -> anyhow::Result<()> {
|
|
if values.is_empty() {
|
|
anyhow::bail!("{field} must not be empty");
|
|
}
|
|
let mut seen = std::collections::BTreeSet::new();
|
|
for value in values {
|
|
if value.trim().is_empty() {
|
|
anyhow::bail!("{field} must not contain an empty value");
|
|
}
|
|
if !seen.insert(value.as_str()) {
|
|
anyhow::bail!("{field} contains a duplicate value: {value}");
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Validates a name that will be passed to `ip`/`tc` as an interface.
|
|
fn validate_interface_name(name: &str, field: &str) -> anyhow::Result<()> {
|
|
if name.is_empty() {
|
|
anyhow::bail!("{field} must not be empty");
|
|
}
|
|
if name.len() > MAX_INTERFACE_NAME_LEN {
|
|
anyhow::bail!(
|
|
"{field} exceeds the Linux interface name limit of {MAX_INTERFACE_NAME_LEN} characters: {name}"
|
|
);
|
|
}
|
|
if name == "." || name == ".." {
|
|
anyhow::bail!("{field} must not be '.' or '..': {name}");
|
|
}
|
|
if let Some(bad) = name
|
|
.chars()
|
|
.find(|c| c.is_whitespace() || c.is_control() || *c == '/' || *c == ':')
|
|
{
|
|
anyhow::bail!("{field} contains an invalid character {bad:?}: {name}");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Same rules as an interface name, but a single trailing `*` is allowed.
|
|
///
|
|
/// Only the suffix form is supported by the matcher, so a pattern with `*` anywhere else
|
|
/// would silently never match.
|
|
fn validate_interface_pattern(pattern: &str) -> anyhow::Result<()> {
|
|
let field = "amnezia.interface_patterns";
|
|
let stem = pattern.strip_suffix('*').unwrap_or(pattern);
|
|
if stem.contains('*') {
|
|
anyhow::bail!("{field} supports only a single trailing '*': {pattern}");
|
|
}
|
|
if stem.is_empty() {
|
|
anyhow::bail!("{field} must not match every interface: {pattern}");
|
|
}
|
|
validate_interface_name(stem, field)
|
|
}
|
|
|
|
/// Docker container names are `[a-zA-Z0-9][a-zA-Z0-9_.-]*`; matching is exact, so a name
|
|
/// that cannot exist would silently never be found.
|
|
fn validate_container_name(name: &str) -> anyhow::Result<()> {
|
|
let field = "amnezia.container_names";
|
|
let mut chars = name.chars();
|
|
let first = chars
|
|
.next()
|
|
.ok_or_else(|| anyhow::anyhow!("{field} must not contain an empty value"))?;
|
|
if !first.is_ascii_alphanumeric() {
|
|
anyhow::bail!("{field} must start with an alphanumeric character: {name}");
|
|
}
|
|
if let Some(bad) =
|
|
chars.find(|c| !(c.is_ascii_alphanumeric() || *c == '_' || *c == '.' || *c == '-'))
|
|
{
|
|
anyhow::bail!("{field} contains an invalid character {bad:?}: {name}");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
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>()?;
|
|
// Without this, "10.8.1.0/24/garbage" parsed as a valid /24 and the trailing junk was
|
|
// silently dropped.
|
|
if parts.next().is_some() {
|
|
anyhow::bail!("invalid cidr: {cidr} has more than one '/' separator");
|
|
}
|
|
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 parse_cidr_accepts_well_formed_values() {
|
|
assert_eq!(
|
|
parse_cidr_v4("10.8.1.0/24").expect("cidr"),
|
|
("10.8.1.0".parse().expect("ip"), 24)
|
|
);
|
|
assert_eq!(
|
|
parse_cidr_v4("0.0.0.0/0").expect("cidr"),
|
|
("0.0.0.0".parse().expect("ip"), 0)
|
|
);
|
|
}
|
|
|
|
/// "10.8.1.0/24/garbage" used to parse as a valid /24 with the tail silently dropped.
|
|
#[test]
|
|
fn parse_cidr_rejects_malformed_values() {
|
|
for bad in [
|
|
"10.8.1.0",
|
|
"10.8.1.0/",
|
|
"10.8.1.0/24/extra",
|
|
"10.8.1.0//24",
|
|
"10.8.1.0/-1",
|
|
"10.8.1.0/33",
|
|
"10.8.1.999/24",
|
|
"",
|
|
"/24",
|
|
] {
|
|
assert!(parse_cidr_v4(bad).is_err(), "must reject cidr: {bad:?}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn interface_name_rules() {
|
|
for good in ["awg0", "wg0", "ifb0", "amnezia0", "abcdefghijklmno"] {
|
|
assert!(
|
|
validate_interface_name(good, "f").is_ok(),
|
|
"must accept: {good}"
|
|
);
|
|
}
|
|
for bad in [
|
|
"",
|
|
"abcdefghijklmnop",
|
|
"eth 0",
|
|
"eth/0",
|
|
"eth:0",
|
|
"eth\u{0}0",
|
|
".",
|
|
"..",
|
|
] {
|
|
assert!(
|
|
validate_interface_name(bad, "f").is_err(),
|
|
"must reject: {bad:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn interface_pattern_rules() {
|
|
for good in ["awg*", "wg*", "tun*", "awg0"] {
|
|
assert!(
|
|
validate_interface_pattern(good).is_ok(),
|
|
"must accept: {good}"
|
|
);
|
|
}
|
|
// A bare '*' would match every interface, and '*' is only supported as a suffix.
|
|
for bad in ["*", "a*g*", "*wg", "aw g*"] {
|
|
assert!(
|
|
validate_interface_pattern(bad).is_err(),
|
|
"must reject: {bad:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn container_name_rules() {
|
|
for good in ["amnezia-awg2", "amnezia_awg", "awg.1", "a"] {
|
|
assert!(validate_container_name(good).is_ok(), "must accept: {good}");
|
|
}
|
|
for bad in ["", "-awg", ".awg", "amnezia awg", "amnezia/awg"] {
|
|
assert!(
|
|
validate_container_name(bad).is_err(),
|
|
"must reject: {bad:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn lists_reject_blank_and_duplicate_entries() {
|
|
let mut cfg = base_config();
|
|
cfg.amnezia.container_names = vec!["a".into(), "".into()];
|
|
assert!(cfg.validate_config().is_err());
|
|
|
|
let mut cfg = base_config();
|
|
cfg.amnezia.container_names = vec!["a".into(), "a".into()];
|
|
assert!(cfg.validate_config().is_err());
|
|
|
|
let mut cfg = base_config();
|
|
cfg.amnezia.interface_candidates = vec!["awg0".into(), "awg0".into()];
|
|
assert!(cfg.validate_config().is_err());
|
|
|
|
let mut cfg = base_config();
|
|
cfg.amnezia.wg_binaries = vec!["awg".into(), "awg".into()];
|
|
assert!(cfg.validate_config().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn ifb_name_is_validated_as_an_interface() {
|
|
let mut cfg = base_config();
|
|
cfg.traffic.ifb_name = "ifb 0".into();
|
|
assert!(cfg.validate_config().is_err());
|
|
|
|
let mut cfg = base_config();
|
|
cfg.traffic.ifb_name = "abcdefghijklmnop".into();
|
|
assert!(cfg.validate_config().is_err());
|
|
}
|
|
|
|
#[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"));
|
|
}
|
|
}
|