use std::net::Ipv4Addr; use crate::{ config, netns::{DockerExecTransport, NsTransport, NsenterTransport, nsenter_available}, }; #[derive(Debug, Clone)] pub struct Peer { pub ipv4: Ipv4Addr, } #[derive(Debug, Clone)] pub struct PeerRuntime { pub public_key: String, pub allowed_ips: Vec, pub endpoint: Option, pub latest_handshake_epoch: Option, pub transfer_rx_bytes: Option, pub transfer_tx_bytes: Option, } pub fn discover_peers(pid: u32, container: &str, peer_cidr: &str) -> anyhow::Result> { let boxed: Box = if nsenter_available() { Box::new(NsenterTransport { pid }) } else { Box::new(DockerExecTransport { container: container.to_string(), }) }; discover_peers_with_transport(boxed.as_ref(), peer_cidr) } pub fn discover_peers_with_transport( transport: &dyn NsTransport, peer_cidr: &str, ) -> anyhow::Result> { let mut peers = match transport.run(&["wg", "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"]) .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( pid: u32, container: &str, peer_cidr: &str, ) -> anyhow::Result> { let boxed: Box = if nsenter_available() { Box::new(NsenterTransport { pid }) } else { Box::new(DockerExecTransport { container: container.to_string(), }) }; discover_peer_runtime_with_transport(boxed.as_ref(), peer_cidr) } pub fn discover_peer_runtime_with_transport( transport: &dyn NsTransport, peer_cidr: &str, ) -> anyhow::Result> { let raw = transport .run(&["wg", "show", "all", "dump"]) .map_err(|e| anyhow::anyhow!("wg show all dump failed: {e}"))?; parse_wg_show_dump_runtime_peers(&raw, peer_cidr) } fn parse_allowed_ips(raw: &str, peer_cidr: &str) -> anyhow::Result> { let mut out = Vec::new(); for line in raw.lines() { let cols: Vec<&str> = line.split_whitespace().collect(); if cols.len() < 3 { continue; } for 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::() { if config::ip_in_cidr(v4, peer_cidr)? { out.push(Peer { ipv4: v4 }); } } } } } Ok(out) } fn parse_fallback_wg_show(raw: &str, peer_cidr: &str) -> anyhow::Result> { let mut out = Vec::new(); for line in raw.lines() { let t = line.trim(); if !t.starts_with("allowed ips:") { continue; } let rest = t.trim_start_matches("allowed ips:").trim(); for 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::() { if config::ip_in_cidr(v4, peer_cidr)? { out.push(Peer { ipv4: v4 }); } } } } } Ok(out) } fn parse_wg_show_dump_runtime_peers( raw: &str, peer_cidr: &str, ) -> anyhow::Result> { let mut out = Vec::new(); for line in raw.lines() { let cols: Vec<&str> = line.split('\t').collect(); if cols.len() < 8 { 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)" { continue; } let filtered_allowed = filter_allowed_ips_in_cidr(allowed, peer_cidr)?; if filtered_allowed.is_empty() { continue; } let endpoint = cols .get(3) .map(|s| s.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::().ok()) .filter(|v| *v > 0); let transfer_rx_bytes = cols.get(6).and_then(|s| s.trim().parse::().ok()); let transfer_tx_bytes = cols.get(7).and_then(|s| s.trim().parse::().ok()); out.push(PeerRuntime { public_key: public_key.to_string(), allowed_ips: filtered_allowed, endpoint, latest_handshake_epoch, transfer_rx_bytes, transfer_tx_bytes, }); } Ok(out) } fn filter_allowed_ips_in_cidr(raw: &str, peer_cidr: &str) -> anyhow::Result> { 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::() 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; struct TestTransport { replies: Mutex>>, } impl TestTransport { fn new(entries: Vec<(&str, anyhow::Result<&str>)>) -> Self { let mut replies = BTreeMap::new(); for (cmd, out) in entries { let mapped = out.map(|s| s.to_string()); replies.insert(cmd.to_string(), mapped); } Self { replies: Mutex::new(replies), } } } impl crate::netns::NsTransport for TestTransport { fn run(&self, args: &[&str]) -> anyhow::Result { let key = args.join(" "); let mut lock = self.replies.lock().expect("lock"); lock.remove(&key) .unwrap_or_else(|| Err(anyhow::anyhow!("missing stub for: {key}"))) } } #[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_parity() { 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"); } #[test] fn primary_failure_falls_back_to_wg_show() { let transport = TestTransport::new(vec![ ("wg show all allowed-ips", Err(anyhow::anyhow!("wg broken"))), ("wg show", Ok("allowed ips: 10.8.1.4/32")), ]); let peers = discover_peers_with_transport(&transport, "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![ ("wg show all allowed-ips", Ok("")), ("wg 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"); 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 = "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"); 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 = "wg0\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); } #[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") .err() .expect("err"); assert!(err.to_string().contains("wg show all dump failed")); } }