6643328567
Both the code comment and the docs said the interface row is "14 columns for AWG 2.0, 28 for AWG 3.0". That reads as a version signal and it is not one. The width of the row is decided by the version of amneziawg-tools doing the printing, not by which protocol parameters the tunnel has configured: AWG 3.0 tools emit the wide row for an AWG 2.0 configuration too, with the newer fields simply zero, empty or (none). Any rule of the form "N columns means version X" is wrong by construction. The classifier never relied on column count for that purpose - it discriminates on the allowed-ips column - so this is a correction to what the code claims about itself and to what the docs would lead a future change to assume. Five fixtures added from the audit's list, all previously uncovered: - AWG 3.0 interface row with header protection disabled: the key column is (none) and the range fields collapse to single values; - an AWG 2.0 configuration read through AWG 3.0 tools, asserted to have the same width as a native AWG 3.0 row and to still be rejected while its peer parses; - a dump containing several interfaces, each with its own peers; - an interface with no peers at all; - malformed rows - truncated, non-numeric handshake, non-CIDR allowed-ips - which must be skipped rather than partially interpreted. Docs also pin what was verified and against which versions, rather than stating it as timeless: AmneziaVPN 5.0.0.5 and the master branches of amneziawg-tools and amneziawg-go at the time of the change. amnezia-awg3 is now described as what it is - not a confirmed official name. AmneziaVPN 5.0.0.5 added AWG 3.0 support but still exposes only Awg and Awg2 container variants. It stays in the example list on purpose, and the reasoning is written down: a name that does not match costs nothing because the search continues down the list, while a name missing from the list produces a silent "container not found". This is a deliberate deviation from the audit's suggestion to build defaults only from names in use today. cargo fmt --check, clippy -D warnings, and cargo test --locked --workspace --release all clean: 163 + 4 passed.
627 lines
24 KiB
Rust
627 lines
24 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 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<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.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::<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 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<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 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!(
|
|
"{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"));
|
|
}
|
|
}
|