667 lines
26 KiB
Rust
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"));
|
|
}
|
|
}
|