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:
2026-08-06 15:32:26 +05:00
parent 5edefa74b1
commit 16214f3c66
13 changed files with 566 additions and 191 deletions
+79 -2
View File
@@ -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]