use std::net::{IpAddr, Ipv4Addr}; use crate::{ 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, } #[derive(Debug, Clone)] pub struct PeerRuntime { pub public_key: String, pub allowed_ips: Vec, pub endpoint: Option, pub latest_handshake_epoch: Option, pub transfer_rx_bytes: Option, pub transfer_tx_bytes: Option, } /// 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(), } } pub fn discover_peers(cfg: &Config, target: &RuntimeTarget) -> anyhow::Result> { 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> { 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 = run_wg(transport, binaries, &["show"]) .map_err(|e| anyhow::anyhow!("wg show fallback failed: {e}"))?; peers = parse_fallback_wg_show(&fallback, peer_cidr)?; } peers.sort_by_key(|p| p.ipv4.to_string()); peers.dedup_by_key(|p| p.ipv4); Ok(peers) } pub fn discover_peer_runtime( cfg: &Config, target: &RuntimeTarget, ) -> anyhow::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> { 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 { let mut last_err: Option = 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, peer_cidr: &str, ) -> anyhow::Result> { 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::() 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> { let mut out = Vec::new(); for line in raw.lines() { let cols: Vec<&str> = line.split_whitespace().collect(); if cols.len() < 3 { continue; } for ipv4 in collect_peer_ipv4_in_cidr(cols[2..].iter().copied(), peer_cidr)? { out.push(Peer { ipv4 }); } } Ok(out) } fn parse_fallback_wg_show(raw: &str, peer_cidr: &str) -> anyhow::Result> { let mut out = Vec::new(); for line in raw.lines() { let t = line.trim(); if !t.starts_with("allowed ips:") { continue; } let rest = t.trim_start_matches("allowed ips:").trim(); 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 is not a discriminator and must never be used as one. The width of the /// interface line is decided by the version of `amneziawg-tools` doing the printing, not /// by which protocol parameters the tunnel actually has configured: AWG 3.0 tools emit the /// extended row for an AWG 2.0 configuration too, with the newer fields simply zero or /// empty. Any rule of the form "N columns means version X" is wrong by construction. /// /// The interface line is also *longer* than a peer line, and its handshake/rx/tx positions /// hold junk-packet sizes that parse as integers just as well, so those columns cannot /// separate the two either. 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::().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::() else { return false; }; match addr.parse::() { Ok(IpAddr::V4(_)) => bits <= 32, Ok(IpAddr::V6(_)) => bits <= 128, Err(_) => false, } }) } fn parse_wg_show_dump_runtime_peers( raw: &str, peer_cidr: &str, ) -> anyhow::Result> { let mut out = Vec::new(); for line in raw.lines() { let cols: Vec<&str> = line.split('\t').collect(); if !dump_line_is_peer(&cols) { continue; } let public_key = cols[1].trim(); if public_key.is_empty() { continue; } let mut filtered_allowed: Vec = 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 = Some(cols[3].trim()) .filter(|s| !s.is_empty() && *s != "(none)") .map(|s| s.to_string()); let latest_handshake_epoch = cols[5].trim().parse::().ok().filter(|v| *v > 0); let transfer_rx_bytes = cols[6].trim().parse::().ok(); let transfer_tx_bytes = cols[7].trim().parse::().ok(); out.push(PeerRuntime { public_key: public_key.to_string(), allowed_ips: filtered_allowed, endpoint, latest_handshake_epoch, transfer_rx_bytes, transfer_tx_bytes, }); } Ok(out) } #[cfg(test)] mod tests { use super::*; use std::collections::BTreeMap; use std::sync::Mutex; /// Interface line printed by AmneziaWG 3.0 tools with header protection enabled. 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"; /// Same tools, header protection disabled: the key column is `(none)` and the AWG 3.0 /// range fields collapse to single values. const AWG3_INTERFACE_LINE_NO_HEADER_PROTECTION: &str = "awg0\twPrivateKeyDeviceAAAAAAAAAAAAAAAAAAAAAAAA=\twPublicKeyDeviceAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t4\t40\t70\t0\t0\t0\t0\t1\t2\t3\t4\t(null)\t(null)\t(null)\t(null)\t(null)\t(none)\t0\t120\t5\t180\t25\t5\toff"; /// An AWG 2.0 *configuration* read through AWG 3.0 tools: still the wide row, with the /// newer fields zeroed. This is precisely why column count cannot identify a version. const AWG2_CONFIG_VIA_AWG3_TOOLS_INTERFACE_LINE: &str = "awg0\twPrivateKeyDeviceAAAAAAAAAAAAAAAAAAAAAAAA=\twPublicKeyDeviceAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t4\t40\t70\t0\t0\t0\t0\t1\t2\t3\t4\t(null)\t(null)\t(null)\t(null)\t(null)\t(none)\t0\t0\t0\t0\t0\t0\toff"; /// Interface line printed by AmneziaWG 2.0 tools. const AWG2_INTERFACE_LINE: &str = "awg0\twPrivateKeyDeviceAAAAAAAAAAAAAAAAAAAAAAAA=\twPublicKeyDeviceAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\t4\t40\t70\t0\t0\t1\t2\t3\t4\toff"; /// Interface line printed by plain WireGuard tools. const WG_INTERFACE_LINE: &str = "wg0\twPrivateKeyDeviceAAAAAAAAAAAAAAAAAAAAAAAA=\twPublicKeyDeviceAAAAAAAAAAAAAAAAAAAAAAAAA=\t51820\toff"; const PEER_LINE: &str = "awg0\tpubkey1\t(none)\t1.2.3.4:51820\t10.8.1.10/32\t1715000000\t123\t456\t0"; struct TestTransport { replies: Mutex>>, } impl TestTransport { fn new(entries: Vec<(&str, anyhow::Result<&str>)>) -> Self { let mut replies = BTreeMap::new(); for (cmd, out) in entries { let mapped = out.map(|s| s.to_string()); replies.insert(cmd.to_string(), mapped); } Self { replies: Mutex::new(replies), } } } impl crate::netns::NsTransport for TestTransport { fn run(&self, args: &[&str]) -> anyhow::Result { let key = args.join(" "); let mut lock = self.replies.lock().expect("lock"); lock.remove(&key) .unwrap_or_else(|| Err(anyhow::anyhow!("missing stub for: {key}"))) } } fn binaries() -> Vec { 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"; let peers = parse_allowed_ips(raw, "10.8.1.0/24").expect("parse"); assert_eq!(peers.len(), 2); } #[test] fn parse_fallback() { let raw = "allowed ips: 10.8.1.4/32, 10.8.1.5/32\nallowed ips: 192.168.0.1/32"; let peers = parse_fallback_wg_show(raw, "10.8.1.0/24").expect("parse"); assert_eq!(peers.len(), 2); } #[test] fn cidr_filtering_keeps_only_target_subnet() { let raw = "wg0 key1 10.8.1.10/32\nwg0 key2 10.9.1.2/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.10"); } #[test] fn lexicographic_sorting_preserves_class_id_contract() { let raw = "wg0 key1 10.8.1.2/32\nwg0 key2 10.8.1.10/32"; let mut peers = parse_allowed_ips(raw, "10.8.1.0/24").expect("parse"); peers.sort_by_key(|p| p.ipv4.to_string()); assert_eq!(peers[0].ipv4.to_string(), "10.8.1.10"); 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 = 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"))), ("awg show", Ok("allowed ips: 10.8.1.4/32")), ]); 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"); } #[test] fn empty_primary_output_falls_back_to_wg_show() { let transport = TestTransport::new(vec![ ("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, &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"); } #[test] fn parses_wg_show_dump_runtime_peers() { 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()]); assert_eq!(peers[1].latest_handshake_epoch, None); } #[test] fn maps_allowed_ip_to_runtime_peer() { 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_awg3_interface_line_without_header_protection() { let cols: Vec<&str> = AWG3_INTERFACE_LINE_NO_HEADER_PROTECTION .split('\t') .collect(); assert!(!dump_line_is_peer(&cols)); } /// The case that makes "column count means protocol version" wrong: an AWG 2.0 tunnel /// read through AWG 3.0 tools still prints the wide interface row. Peers must parse /// and the interface row must be dropped, exactly as with a native AWG 3.0 tunnel. #[test] fn awg2_configuration_read_through_awg3_tools() { let raw = format!("{AWG2_CONFIG_VIA_AWG3_TOOLS_INTERFACE_LINE}\n{PEER_LINE}"); let cols: Vec<&str> = AWG2_CONFIG_VIA_AWG3_TOOLS_INTERFACE_LINE .split('\t') .collect(); assert_eq!( cols.len(), AWG3_INTERFACE_LINE.split('\t').count(), "an AWG 2.0 config through AWG 3.0 tools has the same width as AWG 3.0" ); assert!(!dump_line_is_peer(&cols)); 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].public_key, "pubkey1"); } /// Several interfaces in one `show all dump`, each with its own peers. #[test] fn parses_multiple_interfaces_in_one_dump() { let raw = format!( "{AWG3_INTERFACE_LINE}\n{PEER_LINE}\n{AWG2_INTERFACE_LINE}\nwg0\tpubkey2\t(none)\t5.6.7.8:51820\t10.8.1.20/32\t1715000001\t7\t8\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.20".to_string()]); } #[test] fn interface_without_peers_yields_no_peers() { let peers = parse_wg_show_dump_runtime_peers(AWG3_INTERFACE_LINE, "10.8.1.0/24").expect("parse"); assert!(peers.is_empty()); } /// Truncated or garbled rows must be skipped, not partially interpreted. #[test] fn malformed_rows_are_skipped() { let raw = format!( "\nnot-a-dump-line\nawg0\tpubkey3\t(none)\n\ awg0\tpubkey4\t(none)\t1.2.3.4:51820\t10.8.1.30/32\tnot-a-number\t1\t2\t0\n\ awg0\tpubkey5\t(none)\t1.2.3.4:51820\tnot-a-cidr\t1715000000\t1\t2\t0\n{PEER_LINE}" ); 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].public_key, "pubkey1"); } #[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![ ("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")); } }