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]
+4
View File
@@ -682,6 +682,7 @@ table inet awg2_host_firewall {
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: crate::config::TrafficConfig {
enabled: true,
@@ -744,6 +745,7 @@ table inet awg2_host_firewall {
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: crate::config::TrafficConfig {
enabled: true,
@@ -790,6 +792,7 @@ table inet awg2_host_firewall {
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: crate::config::TrafficConfig {
enabled: true,
@@ -848,6 +851,7 @@ table inet awg2_host_firewall {
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: crate::config::TrafficConfig {
enabled: true,
+14 -20
View File
@@ -48,27 +48,25 @@ pub fn print_status(cfg: &Config) -> anyhow::Result<()> {
cfg.host_firewall.amnezia_port,
);
let peers = wg::discover_peers(
target.container_pid,
&target.container_name,
&cfg.amnezia.peer_ipv4_cidr,
)?;
// Peer discovery is one container round-trip, shared by both sections below.
let peers = wg::discover_peers(cfg, &target);
println!("Peers:");
println!(" total: {}", peers.len());
for p in peers {
println!(" - {}", p.ipv4);
match &peers {
Ok(list) => {
println!(" total: {}", list.len());
for p in list {
println!(" - {}", p.ipv4);
}
}
Err(e) => println!(" unavailable ({e})"),
}
println!("WireGuard clients:");
println!(
" IP State Latest handshake RX TX Public key Endpoint"
);
match wg::discover_peer_runtime(
target.container_pid,
&target.container_name,
&cfg.amnezia.peer_ipv4_cidr,
) {
Ok(runtime_peers) => {
match (&peers, wg::discover_peer_runtime(cfg, &target)) {
(Ok(peers_sorted), Ok(runtime_peers)) => {
let mut by_ip: BTreeMap<String, &wg::PeerRuntime> = BTreeMap::new();
for rp in &runtime_peers {
for ip in &rp.allowed_ips {
@@ -76,11 +74,6 @@ pub fn print_status(cfg: &Config) -> anyhow::Result<()> {
}
}
let peers_sorted = wg::discover_peers(
target.container_pid,
&target.container_name,
&cfg.amnezia.peer_ipv4_cidr,
)?;
for p in peers_sorted {
let ip = p.ipv4.to_string();
if let Some(rp) = by_ip.get(&ip) {
@@ -104,7 +97,8 @@ pub fn print_status(cfg: &Config) -> anyhow::Result<()> {
}
}
}
Err(e) => println!(" unavailable ({e})"),
(Err(e), _) => println!(" unavailable ({e})"),
(_, Err(e)) => println!(" unavailable ({e})"),
}
println!("Traffic desired:");
+1
View File
@@ -516,6 +516,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,
+3 -15
View File
@@ -51,11 +51,7 @@ pub fn inspect_drift_summary(cfg: &Config) -> anyhow::Result<Option<String>> {
let target = docker::discover_target(cfg)?;
let transport = build_transport(target.container_pid, &target.container_name);
let peers = wg::discover_peers(
target.container_pid,
&target.container_name,
&cfg.amnezia.peer_ipv4_cidr,
)?;
let peers = wg::discover_peers(cfg, &target)?;
if peers.is_empty() {
return Ok(Some("no peers discovered".to_string()));
@@ -87,11 +83,7 @@ pub fn reconcile_traffic(cfg: &Config) -> anyhow::Result<()> {
FileLock::acquire_timeout("/run/awg2-harness/traffic.lock", Duration::from_secs(10))?;
let target = docker::discover_target(cfg)?;
let transport = build_transport(target.container_pid, &target.container_name);
let peers = wg::discover_peers(
target.container_pid,
&target.container_name,
&cfg.amnezia.peer_ipv4_cidr,
)?;
let peers = wg::discover_peers(cfg, &target)?;
if peers.is_empty() {
println!("no peers found, skip tc apply");
@@ -138,11 +130,7 @@ pub fn peer_status_table(cfg: &Config) -> anyhow::Result<Option<Vec<PeerTrafficS
let target = docker::discover_target(cfg)?;
let transport = build_transport(target.container_pid, &target.container_name);
let peers = wg::discover_peers(
target.container_pid,
&target.container_name,
&cfg.amnezia.peer_ipv4_cidr,
)?;
let peers = wg::discover_peers(cfg, &target)?;
if peers.is_empty() {
return Ok(Some(Vec::new()));
}
+337 -122
View File
@@ -1,10 +1,19 @@
use std::net::Ipv4Addr;
use std::net::{IpAddr, Ipv4Addr};
use crate::{
config,
netns::{DockerExecTransport, NsTransport, NsenterTransport, nsenter_available},
config::{self, Config},
docker::RuntimeTarget,
netns::{DockerExecTransport, NsTransport},
};
/// Number of tab-separated columns a peer line of `wg show all dump` always has:
/// interface, public key, preshared key, endpoint, allowed ips, latest handshake,
/// rx bytes, tx bytes, persistent keepalive.
const WG_DUMP_PEER_COLS: usize = 9;
/// Only host routes are shaped: the tc filter contract is one class per peer address.
const HOST_PREFIX: &str = "32";
#[derive(Debug, Clone)]
pub struct Peer {
pub ipv4: Ipv4Addr,
@@ -20,39 +29,35 @@ pub struct PeerRuntime {
pub transfer_tx_bytes: Option<u64>,
}
pub fn discover_peers(pid: u32, container: &str, peer_cidr: &str) -> anyhow::Result<Vec<Peer>> {
let docker = DockerExecTransport {
container: container.to_string(),
};
let docker_result = discover_peers_with_transport(&docker, peer_cidr);
if let Ok(peers) = &docker_result
&& (!peers.is_empty() || !nsenter_available())
{
return docker_result;
/// Peer state is only readable inside the container's mount namespace: AmneziaWG is a
/// userspace implementation and its UAPI socket lives under `RUNSTATEDIR/amneziawg/`
/// in the container filesystem. A host-side `wg` entered into the container netns via
/// `nsenter -n` cannot reach that socket, so `docker exec` is the only valid transport.
fn container_transport(target: &RuntimeTarget) -> DockerExecTransport {
DockerExecTransport {
container: target.container_name.clone(),
}
}
if nsenter_available() {
let nsenter = NsenterTransport { pid };
match discover_peers_with_transport(&nsenter, peer_cidr) {
Ok(peers) if !peers.is_empty() => Ok(peers),
_ => docker_result,
}
} else {
docker_result
}
pub fn discover_peers(cfg: &Config, target: &RuntimeTarget) -> anyhow::Result<Vec<Peer>> {
discover_peers_with_transport(
&container_transport(target),
&cfg.amnezia.wg_binaries,
&cfg.amnezia.peer_ipv4_cidr,
)
}
pub fn discover_peers_with_transport(
transport: &dyn NsTransport,
binaries: &[String],
peer_cidr: &str,
) -> anyhow::Result<Vec<Peer>> {
let mut peers = match transport.run(&["wg", "show", "all", "allowed-ips"]) {
let mut peers = match run_wg(transport, binaries, &["show", "all", "allowed-ips"]) {
Ok(primary) => parse_allowed_ips(&primary, peer_cidr)?,
Err(_) => Vec::new(),
};
if peers.is_empty() {
let fallback = transport
.run(&["wg", "show"])
let fallback = run_wg(transport, binaries, &["show"])
.map_err(|e| anyhow::anyhow!("wg show fallback failed: {e}"))?;
peers = parse_fallback_wg_show(&fallback, peer_cidr)?;
}
@@ -63,41 +68,80 @@ pub fn discover_peers_with_transport(
}
pub fn discover_peer_runtime(
pid: u32,
container: &str,
peer_cidr: &str,
cfg: &Config,
target: &RuntimeTarget,
) -> anyhow::Result<Vec<PeerRuntime>> {
let docker = DockerExecTransport {
container: container.to_string(),
};
let docker_result = discover_peer_runtime_with_transport(&docker, peer_cidr);
if let Ok(peers) = &docker_result
&& (!peers.is_empty() || !nsenter_available())
{
return docker_result;
}
if nsenter_available() {
let nsenter = NsenterTransport { pid };
match discover_peer_runtime_with_transport(&nsenter, peer_cidr) {
Ok(peers) if !peers.is_empty() => Ok(peers),
_ => docker_result,
}
} else {
docker_result
}
discover_peer_runtime_with_transport(
&container_transport(target),
&cfg.amnezia.wg_binaries,
&cfg.amnezia.peer_ipv4_cidr,
)
}
pub fn discover_peer_runtime_with_transport(
transport: &dyn NsTransport,
binaries: &[String],
peer_cidr: &str,
) -> anyhow::Result<Vec<PeerRuntime>> {
let raw = transport
.run(&["wg", "show", "all", "dump"])
let raw = run_wg(transport, binaries, &["show", "all", "dump"])
.map_err(|e| anyhow::anyhow!("wg show all dump failed: {e}"))?;
parse_wg_show_dump_runtime_peers(&raw, peer_cidr)
}
/// Runs a wg subcommand with the first binary that succeeds.
///
/// The official AmneziaWG image ships `/usr/bin/awg` and symlinks `/usr/bin/wg` to it,
/// so `awg` is the real dependency and is tried first; `wg` remains for plain WireGuard
/// containers where `awg` does not exist.
fn run_wg(
transport: &dyn NsTransport,
binaries: &[String],
args: &[&str],
) -> anyhow::Result<String> {
let mut last_err: Option<anyhow::Error> = None;
for binary in binaries {
let mut argv: Vec<&str> = Vec::with_capacity(args.len() + 1);
argv.push(binary.as_str());
argv.extend_from_slice(args);
match transport.run(&argv) {
Ok(out) => return Ok(out),
Err(e) => last_err = Some(anyhow::anyhow!("{binary}: {e}")),
}
}
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("amnezia.wg_binaries is empty")))
}
/// Extracts peer host addresses inside `peer_cidr` from raw allowed-ips text.
///
/// The three wg outputs consumed here use three different separators for a single
/// peer's allowed ips: `wg show all allowed-ips` uses spaces, `wg show all dump` uses
/// commas and plain `wg show` uses `", "`. Callers pass whitespace-separated tokens and
/// each token is additionally split on commas, so all three are handled identically.
fn collect_peer_ipv4_in_cidr<'a>(
tokens: impl IntoIterator<Item = &'a str>,
peer_cidr: &str,
) -> anyhow::Result<Vec<Ipv4Addr>> {
let mut out = Vec::new();
for token in tokens {
for entry in token.split(',') {
let entry = entry.trim();
let Some((addr, prefix)) = entry.split_once('/') else {
continue;
};
if prefix != HOST_PREFIX {
continue;
}
let Ok(v4) = addr.parse::<Ipv4Addr>() else {
continue;
};
if config::ip_in_cidr(v4, peer_cidr)? {
out.push(v4);
}
}
}
Ok(out)
}
fn parse_allowed_ips(raw: &str, peer_cidr: &str) -> anyhow::Result<Vec<Peer>> {
let mut out = Vec::new();
for line in raw.lines() {
@@ -105,18 +149,8 @@ fn parse_allowed_ips(raw: &str, peer_cidr: &str) -> anyhow::Result<Vec<Peer>> {
if cols.len() < 3 {
continue;
}
for tok in cols[2].split(',') {
let tok = tok.trim();
if let Some((ip, pfx)) = tok.split_once('/') {
if pfx != "32" {
continue;
}
if let Ok(v4) = ip.parse::<Ipv4Addr>()
&& config::ip_in_cidr(v4, peer_cidr)?
{
out.push(Peer { ipv4: v4 });
}
}
for ipv4 in collect_peer_ipv4_in_cidr(cols[2..].iter().copied(), peer_cidr)? {
out.push(Peer { ipv4 });
}
}
Ok(out)
@@ -130,23 +164,57 @@ fn parse_fallback_wg_show(raw: &str, peer_cidr: &str) -> anyhow::Result<Vec<Peer
continue;
}
let rest = t.trim_start_matches("allowed ips:").trim();
for tok in rest.split(',') {
let tok = tok.trim();
if let Some((ip, pfx)) = tok.split_once('/') {
if pfx != "32" {
continue;
}
if let Ok(v4) = ip.parse::<Ipv4Addr>()
&& config::ip_in_cidr(v4, peer_cidr)?
{
out.push(Peer { ipv4: v4 });
}
}
for ipv4 in collect_peer_ipv4_in_cidr(rest.split_whitespace(), peer_cidr)? {
out.push(Peer { ipv4 });
}
}
Ok(out)
}
/// Tells a peer line of `wg show all dump` apart from the interface line.
///
/// Column count alone is not a discriminator: the AmneziaWG interface line carries the
/// AWG parameters (14 columns for AWG 2.0, 28 for AWG 3.0) and is therefore longer than
/// a peer line, while its handshake/rx/tx positions hold junk-packet sizes that parse as
/// integers just as well. The allowed-ips column is the reliable signal — on an interface
/// line it holds `junk_packet_count`, which is never `(none)` and never a CIDR list.
///
/// This matters beyond correctness: column 1 of an interface line is the device private
/// key, so an interface line must never be read as a peer.
fn dump_line_is_peer(cols: &[&str]) -> bool {
if cols.len() < WG_DUMP_PEER_COLS {
return false;
}
if !is_allowed_ips_field(cols[4]) {
return false;
}
cols[5..8].iter().all(|v| v.trim().parse::<u64>().is_ok())
}
fn is_allowed_ips_field(raw: &str) -> bool {
let t = raw.trim();
if t == "(none)" {
return true;
}
if t.is_empty() {
return false;
}
t.split(',').all(|entry| {
let entry = entry.trim();
let Some((addr, prefix)) = entry.split_once('/') else {
return false;
};
let Ok(bits) = prefix.parse::<u8>() else {
return false;
};
match addr.parse::<IpAddr>() {
Ok(IpAddr::V4(_)) => bits <= 32,
Ok(IpAddr::V6(_)) => bits <= 128,
Err(_) => false,
}
})
}
fn parse_wg_show_dump_runtime_peers(
raw: &str,
peer_cidr: &str,
@@ -154,34 +222,33 @@ fn parse_wg_show_dump_runtime_peers(
let mut out = Vec::new();
for line in raw.lines() {
let cols: Vec<&str> = line.split('\t').collect();
if cols.len() < 8 {
if !dump_line_is_peer(&cols) {
continue;
}
// peer lines include endpoint/allowed_ips/handshake/rx/tx; interface lines do not match this contract
let public_key = cols.get(1).map(|s| s.trim()).unwrap_or_default();
let allowed = cols.get(4).map(|s| s.trim()).unwrap_or_default();
if public_key.is_empty() || allowed.is_empty() || allowed == "(none)" {
let public_key = cols[1].trim();
if public_key.is_empty() {
continue;
}
let filtered_allowed = filter_allowed_ips_in_cidr(allowed, peer_cidr)?;
let mut filtered_allowed: Vec<String> =
collect_peer_ipv4_in_cidr(std::iter::once(cols[4]), peer_cidr)?
.into_iter()
.map(|v| v.to_string())
.collect();
filtered_allowed.sort();
filtered_allowed.dedup();
if filtered_allowed.is_empty() {
continue;
}
let endpoint = cols
.get(3)
.map(|s| s.trim())
let endpoint = Some(cols[3].trim())
.filter(|s| !s.is_empty() && *s != "(none)")
.map(|s| s.to_string());
let latest_handshake_epoch = cols
.get(5)
.and_then(|s| s.trim().parse::<u64>().ok())
.filter(|v| *v > 0);
let transfer_rx_bytes = cols.get(6).and_then(|s| s.trim().parse::<u64>().ok());
let transfer_tx_bytes = cols.get(7).and_then(|s| s.trim().parse::<u64>().ok());
let latest_handshake_epoch = cols[5].trim().parse::<u64>().ok().filter(|v| *v > 0);
let transfer_rx_bytes = cols[6].trim().parse::<u64>().ok();
let transfer_tx_bytes = cols[7].trim().parse::<u64>().ok();
out.push(PeerRuntime {
public_key: public_key.to_string(),
@@ -195,34 +262,22 @@ fn parse_wg_show_dump_runtime_peers(
Ok(out)
}
fn filter_allowed_ips_in_cidr(raw: &str, peer_cidr: &str) -> anyhow::Result<Vec<String>> {
let mut out = Vec::new();
for tok in raw.split(',') {
let tok = tok.trim();
let Some((ip, pfx)) = tok.split_once('/') else {
continue;
};
if pfx != "32" {
continue;
}
let Ok(v4) = ip.parse::<Ipv4Addr>() else {
continue;
};
if config::ip_in_cidr(v4, peer_cidr)? {
out.push(v4.to_string());
}
}
out.sort();
out.dedup();
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
use std::sync::Mutex;
/// Interface line of `wg show all dump` produced by AmneziaWG 3.0 tools: 28 columns,
/// AWG parameters appended after the listen port and fwmark last.
const AWG3_INTERFACE_LINE: &str = "awg0\twPrivateKeyDeviceAAAAAAAAAAAAAAAAAAAAAAAA=\twPublicKeyDeviceAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t4\t40\t70\t0\t0\t0\t0\t1-5\t2-6\t3-7\t4-8\t(null)\t(null)\t(null)\t(null)\t(null)\thProtectionKeyAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\t10-40\t120-180\t5-10\t180-200\t25-30\t5\toff";
/// Interface line produced by AmneziaWG 2.0 tools: 14 columns.
const AWG2_INTERFACE_LINE: &str = "awg0\twPrivateKeyDeviceAAAAAAAAAAAAAAAAAAAAAAAA=\twPublicKeyDeviceAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t4\t40\t70\t0\t0\t1\t2\t3\t4\toff";
/// Interface line produced by plain WireGuard tools: 5 columns.
const WG_INTERFACE_LINE: &str = "wg0\twPrivateKeyDeviceAAAAAAAAAAAAAAAAAAAAAAAA=\twPublicKeyDeviceAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\toff";
struct TestTransport {
replies: Mutex<BTreeMap<String, anyhow::Result<String>>>,
}
@@ -249,6 +304,10 @@ mod tests {
}
}
fn binaries() -> Vec<String> {
vec!["awg".to_string(), "wg".to_string()]
}
#[test]
fn parse_primary() {
let raw = "wg0 key1 10.8.1.10/32\nwg0 key2 10.8.1.2/32";
@@ -280,14 +339,43 @@ mod tests {
assert_eq!(peers[1].ipv4.to_string(), "10.8.1.2");
}
/// `wg show all allowed-ips` separates a peer's allowed ips with spaces, not commas.
#[test]
fn parse_primary_collects_every_space_separated_allowed_ip() {
let raw = "awg0 key1 10.8.1.10/32 10.8.1.11/32 10.8.1.12/32";
let peers = parse_allowed_ips(raw, "10.8.1.0/24").expect("parse");
let got: Vec<String> = peers.iter().map(|p| p.ipv4.to_string()).collect();
assert_eq!(got, vec!["10.8.1.10", "10.8.1.11", "10.8.1.12"]);
}
#[test]
fn parse_primary_skips_peer_without_allowed_ips() {
let raw = "awg0 key1 (none)\nawg0 key2 10.8.1.7/32";
let peers = parse_allowed_ips(raw, "10.8.1.0/24").expect("parse");
assert_eq!(peers.len(), 1);
assert_eq!(peers[0].ipv4.to_string(), "10.8.1.7");
}
#[test]
fn parse_fallback_collects_every_allowed_ip_on_one_line() {
let raw = " allowed ips: 10.8.1.4/32, 10.8.1.5/32, 10.8.1.6/32";
let peers = parse_fallback_wg_show(raw, "10.8.1.0/24").expect("parse");
assert_eq!(peers.len(), 3);
}
#[test]
fn primary_failure_falls_back_to_wg_show() {
let transport = TestTransport::new(vec![
(
"awg show all allowed-ips",
Err(anyhow::anyhow!("awg broken")),
),
("wg show all allowed-ips", Err(anyhow::anyhow!("wg broken"))),
("wg show", Ok("allowed ips: 10.8.1.4/32")),
("awg show", Ok("allowed ips: 10.8.1.4/32")),
]);
let peers = discover_peers_with_transport(&transport, "10.8.1.0/24").expect("peers");
let peers =
discover_peers_with_transport(&transport, &binaries(), "10.8.1.0/24").expect("peers");
assert_eq!(peers.len(), 1);
assert_eq!(peers[0].ipv4.to_string(), "10.8.1.4");
}
@@ -295,11 +383,12 @@ mod tests {
#[test]
fn empty_primary_output_falls_back_to_wg_show() {
let transport = TestTransport::new(vec![
("wg show all allowed-ips", Ok("")),
("wg show", Ok("allowed ips: 10.8.1.4/32, 10.8.1.5/32")),
("awg show all allowed-ips", Ok("")),
("awg show", Ok("allowed ips: 10.8.1.4/32, 10.8.1.5/32")),
]);
let peers = discover_peers_with_transport(&transport, "10.8.1.0/24").expect("peers");
let peers =
discover_peers_with_transport(&transport, &binaries(), "10.8.1.0/24").expect("peers");
assert_eq!(peers.len(), 2);
assert_eq!(peers[0].ipv4.to_string(), "10.8.1.4");
assert_eq!(peers[1].ipv4.to_string(), "10.8.1.5");
@@ -307,8 +396,10 @@ mod tests {
#[test]
fn parses_wg_show_dump_runtime_peers() {
let raw = "wg0\tpriv\tpub\t51820\toff\nwg0\tpubkey1\t(none)\t1.2.3.4:51820\t10.8.1.10/32\t1715000000\t123\t456\t0\nwg0\tpubkey2\t(none)\t(none)\t10.8.1.27/32,192.168.1.5/32\t0\t0\t0\t0";
let peers = parse_wg_show_dump_runtime_peers(raw, "10.8.1.0/24").expect("parse");
let raw = format!(
"{AWG3_INTERFACE_LINE}\nawg0\tpubkey1\t(none)\t1.2.3.4:51820\t10.8.1.10/32\t1715000000\t123\t456\t0\nawg0\tpubkey2\t(none)\t(none)\t10.8.1.27/32,192.168.1.5/32\t0\t0\t0\t0"
);
let peers = parse_wg_show_dump_runtime_peers(&raw, "10.8.1.0/24").expect("parse");
assert_eq!(peers.len(), 2);
assert_eq!(peers[0].allowed_ips, vec!["10.8.1.10".to_string()]);
assert_eq!(peers[1].allowed_ips, vec!["10.8.1.27".to_string()]);
@@ -317,19 +408,143 @@ mod tests {
#[test]
fn maps_allowed_ip_to_runtime_peer() {
let raw = "wg0\tpubkey1\t(none)\t1.2.3.4:51820\t10.8.1.10/32,10.8.1.11/32\t1715000000\t123\t456\t0";
let raw = "awg0\tpubkey1\t(none)\t1.2.3.4:51820\t10.8.1.10/32,10.8.1.11/32\t1715000000\t123\t456\t0";
let peers = parse_wg_show_dump_runtime_peers(raw, "10.8.1.0/24").expect("parse");
assert_eq!(peers.len(), 1);
assert_eq!(peers[0].allowed_ips.len(), 2);
}
/// AmneziaWG 3.0 prints protocol timers as ranges; the peer keepalive column may be
/// `20-30` instead of a single integer. It is not read, but must not break parsing.
#[test]
fn accepts_awg3_range_persistent_keepalive() {
let raw = "awg0\tpubkey1\t(none)\t1.2.3.4:51820\t10.8.1.10/32\t1715000000\t123\t456\t20-30";
let peers = parse_wg_show_dump_runtime_peers(raw, "10.8.1.0/24").expect("parse");
assert_eq!(peers.len(), 1);
assert_eq!(peers[0].transfer_rx_bytes, Some(123));
assert_eq!(peers[0].transfer_tx_bytes, Some(456));
}
#[test]
fn rejects_awg3_interface_line() {
let cols: Vec<&str> = AWG3_INTERFACE_LINE.split('\t').collect();
assert_eq!(cols.len(), 28);
assert!(!dump_line_is_peer(&cols));
assert!(
parse_wg_show_dump_runtime_peers(AWG3_INTERFACE_LINE, "0.0.0.0/0")
.expect("parse")
.is_empty()
);
}
#[test]
fn rejects_awg2_interface_line() {
let cols: Vec<&str> = AWG2_INTERFACE_LINE.split('\t').collect();
assert_eq!(cols.len(), 14);
assert!(!dump_line_is_peer(&cols));
}
#[test]
fn rejects_plain_wireguard_interface_line() {
let cols: Vec<&str> = WG_INTERFACE_LINE.split('\t').collect();
assert_eq!(cols.len(), 5);
assert!(!dump_line_is_peer(&cols));
}
/// Column 1 of an interface line is the device private key. A misclassified interface
/// line would surface it as a peer public key in `status` output.
#[test]
fn never_exposes_interface_private_key_as_peer() {
let raw = format!(
"{AWG3_INTERFACE_LINE}\n{AWG2_INTERFACE_LINE}\n{WG_INTERFACE_LINE}\nawg0\tpubkey1\t(none)\t1.2.3.4:51820\t10.8.1.10/32\t1715000000\t123\t456\t0"
);
let peers = parse_wg_show_dump_runtime_peers(&raw, "0.0.0.0/0").expect("parse");
assert_eq!(peers.len(), 1);
assert_eq!(peers[0].public_key, "pubkey1");
for peer in &peers {
assert!(!peer.public_key.contains("PrivateKey"));
}
}
#[test]
fn skips_peer_without_allowed_ips_in_dump() {
let raw = "awg0\tpubkey1\t(none)\t(none)\t(none)\t0\t0\t0\t0";
let cols: Vec<&str> = raw.split('\t').collect();
assert!(dump_line_is_peer(&cols));
assert!(
parse_wg_show_dump_runtime_peers(raw, "10.8.1.0/24")
.expect("parse")
.is_empty()
);
}
#[test]
fn propagates_wg_dump_command_failure() {
let transport = TestTransport::new(vec![(
"wg show all dump",
Err(anyhow::anyhow!("wg broken")),
)]);
let err = discover_peer_runtime_with_transport(&transport, "10.8.1.0/24").expect_err("err");
let transport = TestTransport::new(vec![
("awg show all dump", Err(anyhow::anyhow!("awg broken"))),
("wg show all dump", Err(anyhow::anyhow!("wg broken"))),
]);
let err = discover_peer_runtime_with_transport(&transport, &binaries(), "10.8.1.0/24")
.expect_err("err");
assert!(err.to_string().contains("wg show all dump failed"));
}
#[test]
fn prefers_awg_binary_over_wg() {
let transport = TestTransport::new(vec![
(
"awg show all dump",
Ok("awg0\tpubkey1\t(none)\t1.2.3.4:51820\t10.8.1.10/32\t1715000000\t1\t2\t0"),
),
("wg show all dump", Err(anyhow::anyhow!("must not be used"))),
]);
let peers = discover_peer_runtime_with_transport(&transport, &binaries(), "10.8.1.0/24")
.expect("peers");
assert_eq!(peers.len(), 1);
}
#[test]
fn falls_back_to_wg_binary_when_awg_is_absent() {
let transport = TestTransport::new(vec![
(
"awg show all dump",
Err(anyhow::anyhow!("exec: \"awg\": not found")),
),
(
"wg show all dump",
Ok("wg0\tpubkey1\t(none)\t1.2.3.4:51820\t10.8.1.10/32\t1715000000\t1\t2\t0"),
),
]);
let peers = discover_peer_runtime_with_transport(&transport, &binaries(), "10.8.1.0/24")
.expect("peers");
assert_eq!(peers.len(), 1);
assert_eq!(peers[0].public_key, "pubkey1");
}
#[test]
fn reports_error_when_no_wg_binary_is_available() {
let transport = TestTransport::new(vec![
(
"awg show all dump",
Err(anyhow::anyhow!("exec: \"awg\": not found")),
),
(
"wg show all dump",
Err(anyhow::anyhow!("exec: \"wg\": not found")),
),
]);
let err = discover_peer_runtime_with_transport(&transport, &binaries(), "10.8.1.0/24")
.expect_err("err");
let msg = err.to_string();
assert!(msg.contains("wg show all dump failed"));
assert!(msg.contains("wg:"));
}
#[test]
fn reports_error_when_binary_list_is_empty() {
let transport = TestTransport::new(vec![]);
let err =
discover_peer_runtime_with_transport(&transport, &[], "10.8.1.0/24").expect_err("err");
assert!(err.to_string().contains("amnezia.wg_binaries is empty"));
}
}