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:
+337
-122
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user