AmneziaWG 3.0 readiness: centralize wg CLI contract, drop dead host-side fallback
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.
This commit is contained in:
@@ -26,6 +26,12 @@ 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,
|
||||
@@ -85,6 +91,8 @@ pub struct AmneziaConfig {
|
||||
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)]
|
||||
@@ -168,6 +176,23 @@ impl Config {
|
||||
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");
|
||||
}
|
||||
@@ -314,7 +339,9 @@ impl Config {
|
||||
}
|
||||
|
||||
fn check_host_tools(&self) -> anyhow::Result<()> {
|
||||
let mut required = vec!["tc", "ip", "wg", "docker"];
|
||||
// `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");
|
||||
}
|
||||
@@ -460,7 +487,6 @@ fn tool_exists(tool: &str) -> bool {
|
||||
let probes: &[&[&str]] = match tool {
|
||||
"tc" => &[&["-V"]],
|
||||
"ip" => &[&["-V"], &["-Version"]],
|
||||
"wg" => &[&["--version"]],
|
||||
"docker" => &[&["--version"]],
|
||||
"modprobe" => &[&["--version"]],
|
||||
"nsenter" => &[&["--help"]],
|
||||
@@ -523,6 +549,7 @@ mod tests {
|
||||
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,
|
||||
@@ -647,6 +674,54 @@ mod tests {
|
||||
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#"
|
||||
@@ -736,6 +811,8 @@ swappiness = 1
|
||||
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]
|
||||
|
||||
Reference in New Issue
Block a user