Files
AWG2_harness/traffic_core/src/wg.rs
T
2026-08-28 00:28:26 +05:00

667 lines
26 KiB
Rust

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<String>,
pub endpoint: Option<String>,
pub latest_handshake_epoch: Option<u64>,
pub transfer_rx_bytes: Option<u64>,
pub transfer_tx_bytes: Option<u64>,
}
/// Peer state is read with the official container-side tools. This is the stable access
/// point for both a kernel `amneziawg` interface and the `amneziawg-go` fallback, and it
/// avoids coupling the harness to the version or presence of host-side wg/awg tools.
fn container_transport(target: &RuntimeTarget) -> DockerExecTransport {
DockerExecTransport {
container: target.container_name.clone(),
}
}
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 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<Vec<PeerRuntime>> {
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 = 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() {
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<Vec<Peer>> {
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.x tools emit an
/// extended row for an older 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::<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,
) -> anyhow::Result<Vec<PeerRuntime>> {
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<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 = Some(cols[3].trim())
.filter(|s| !s.is_empty() && *s != "(none)")
.map(|s| s.to_string());
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(),
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 AWG30_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 AWG30_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_AWG30_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 row emitted by amneziawg-tools v3.1.20260812. AWG 3.1 adds
/// RandomTrailers and DisableCookies immediately before fwmark, for 30 fields total.
const AWG31_INTERFACE_LINE: &str = "awg0\twPrivateKeyDevice31AAAAAAAAAAAAAAAAAAAAAA=\twPublicKeyDevice31AAAAAAAAAAAAAAAAAAAAAAA=\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)\thProtectionKey31AAAAAAAAAAAAAAAAAAAAAAAAAA=\t10-40\t120-180\t5-10\t180-200\t25-30\t5\ton\ton\toff";
/// Same AWG 3.1 tools with optional protection/range values disabled and both new
/// boolean parameters off. The row width is still 30 fields.
const AWG31_INTERFACE_LINE_DISABLED: &str = "awg0\twPrivateKeyDevice31DisabledAAAAAAAAAAAAA=\twPublicKeyDevice31DisabledAAAAAAAAAAAAAA=\t0\t0\t0\t0\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\toff\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<BTreeMap<String, anyhow::Result<String>>>,
}
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<String> {
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 run_with_stdin(&self, _args: &[&str], _input: &str) -> anyhow::Result<String> {
unreachable!("peer discovery never pipes stdin")
}
}
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";
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<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"))),
("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!(
"{AWG31_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_awg30_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_awg30_interface_line() {
let cols: Vec<&str> = AWG30_INTERFACE_LINE.split('\t').collect();
assert_eq!(cols.len(), 28);
assert!(!dump_line_is_peer(&cols));
assert!(
parse_wg_show_dump_runtime_peers(AWG30_INTERFACE_LINE, "0.0.0.0/0")
.expect("parse")
.is_empty()
);
}
#[test]
fn rejects_both_awg31_interface_variants() {
for (raw, random_trailers, disable_cookies) in [
(AWG31_INTERFACE_LINE, "on", "on"),
(AWG31_INTERFACE_LINE_DISABLED, "off", "off"),
] {
let cols: Vec<&str> = raw.split('\t').collect();
assert_eq!(cols.len(), 30);
assert_eq!(cols[27], random_trailers);
assert_eq!(cols[28], disable_cookies);
assert_eq!(cols[29], "off");
assert!(!dump_line_is_peer(&cols));
assert!(
parse_wg_show_dump_runtime_peers(raw, "0.0.0.0/0")
.expect("parse")
.is_empty()
);
}
}
#[test]
fn awg31_interface_and_peer_yield_exactly_one_peer() {
let raw = format!("{AWG31_INTERFACE_LINE}\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_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_awg30_interface_line_without_header_protection() {
let cols: Vec<&str> = AWG30_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_awg30_tools() {
let raw = format!("{AWG2_CONFIG_VIA_AWG30_TOOLS_INTERFACE_LINE}\n{PEER_LINE}");
let cols: Vec<&str> = AWG2_CONFIG_VIA_AWG30_TOOLS_INTERFACE_LINE
.split('\t')
.collect();
assert_eq!(
cols.len(),
AWG30_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!(
"{AWG31_INTERFACE_LINE}\n{PEER_LINE}\n{AWG30_INTERFACE_LINE}\n{AWG2_INTERFACE_LINE}\n{WG_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() {
for raw in [AWG31_INTERFACE_LINE, AWG31_INTERFACE_LINE_DISABLED] {
let peers = parse_wg_show_dump_runtime_peers(raw, "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!(
"{AWG31_INTERFACE_LINE}\n{AWG31_INTERFACE_LINE_DISABLED}\n{AWG30_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"));
}
}