From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [IPv6:2a01:7e0:0:424::9]) by lore.proxmox.com (Postfix) with ESMTPS id 64DF81FF14F for ; Wed, 17 Jun 2026 13:12:25 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id D505831B0C; Wed, 17 Jun 2026 13:11:22 +0200 (CEST) From: Stefan Hanreich To: pve-devel@lists.proxmox.com Subject: [PATCH proxmox-perl-rs 10/13] sdn status: fabrics: add status reporting for wireguard Date: Wed, 17 Jun 2026 13:10:07 +0200 Message-ID: <20260617111012.312710-11-s.hanreich@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260617111012.312710-1-s.hanreich@proxmox.com> References: <20260617111012.312710-1-s.hanreich@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1781694568986 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.596 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Message-ID-Hash: OJEGNLVXGU57NIR3CNUBLVP33WCA75AI X-Message-ID-Hash: OJEGNLVXGU57NIR3CNUBLVP33WCA75AI X-MailFrom: s.hanreich@proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox VE development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: Utilize the built-in `wg show` command to obtain the status of all WireGuard interfaces that are configured on the node. Additionally, utilize the status output from `ip link` to obtain additional information about the state of the wireguard interfaces themselves. In order to be able to match the interfaces / peers from the `wg show` output to the entities in the fabrics configuration the endpoint is used, since that is unique to the local host. Signed-off-by: Stefan Hanreich --- pve-rs/Cargo.toml | 1 + pve-rs/src/bindings/sdn/fabrics.rs | 41 ++- pve-rs/src/sdn/status.rs | 529 ++++++++++++++++++++++++++++- 3 files changed, 562 insertions(+), 9 deletions(-) diff --git a/pve-rs/Cargo.toml b/pve-rs/Cargo.toml index 5ae9082..8940b27 100644 --- a/pve-rs/Cargo.toml +++ b/pve-rs/Cargo.toml @@ -37,6 +37,7 @@ proxmox-config-digest = "1" proxmox-frr = { version = "0.5.1" } proxmox-http = { version = "1.0.2", features = ["client-sync", "client-trait"] } proxmox-http-error = "1" +proxmox-iproute2 = "0.1.0" proxmox-log = "1" proxmox-network-types = "1.1.2" proxmox-notify = { version = "1", features = ["pve-context"] } diff --git a/pve-rs/src/bindings/sdn/fabrics.rs b/pve-rs/src/bindings/sdn/fabrics.rs index f96b6b1..e971327 100644 --- a/pve-rs/src/bindings/sdn/fabrics.rs +++ b/pve-rs/src/bindings/sdn/fabrics.rs @@ -14,6 +14,7 @@ pub mod pve_rs_sdn_fabrics { use anyhow::{Context, Error, format_err}; use openssl::hash::{MessageDigest, hash}; + use proxmox_iproute2::get_network_interfaces; use proxmox_ve_config::sdn::fabric::section_config::node::api::{Node, NodeUpdater}; use serde::{Deserialize, Serialize}; @@ -953,7 +954,24 @@ pub mod pve_rs_sdn_fabrics { ) .map(|v| v.into()) } - FabricEntry::WireGuard(_) => Ok(status::NeighborStatus::WireGuard(Vec::new())), + FabricEntry::WireGuard(fabric_entry) => { + let wg_dump = String::from_utf8( + Command::new("sh") + .args(["-c", "wg show all dump"]) + .output()? + .stdout, + )?; + + let node_name = proxmox_sys::nodename(); + let current_node_id = NodeId::from_string(node_name.to_string())?; + + status::wireguard::parse_wireguard_neighbors( + &wg_dump, + fabric_entry.node_section(¤t_node_id)?, + &fabric_entry, + ) + .map(Into::into) + } FabricEntry::Bgp(_) => { let bgp_neighbors_string = String::from_utf8( Command::new("sh") @@ -1031,7 +1049,26 @@ pub mod pve_rs_sdn_fabrics { ) .map(|v| v.into()) } - FabricEntry::WireGuard(_) => Ok(status::InterfaceStatus::WireGuard(Vec::new())), + FabricEntry::WireGuard(fabric_entry) => { + let wg_dump = String::from_utf8( + Command::new("sh") + .args(["-c", "wg show all dump"]) + .output()? + .stdout, + )?; + + let network_interfaces = get_network_interfaces()?; + + let node_name = proxmox_sys::nodename(); + let current_node_id = NodeId::from_string(node_name.to_string())?; + + status::wireguard::parse_wireguard_interfaces( + &wg_dump, + fabric_entry.node_section(¤t_node_id)?, + &network_interfaces, + ) + .map(Into::into) + } FabricEntry::Bgp(_) => { let bgp_neighbors_string = String::from_utf8( Command::new("sh") diff --git a/pve-rs/src/sdn/status.rs b/pve-rs/src/sdn/status.rs index 7a1334d..38afab2 100644 --- a/pve-rs/src/sdn/status.rs +++ b/pve-rs/src/sdn/status.rs @@ -81,13 +81,265 @@ mod openfabric { } } -mod wireguard { +pub mod wireguard { + use std::{ + collections::{HashMap, HashSet}, + str::FromStr, + }; + + use anyhow::Context; + use proxmox_iproute2::{IpLink, LinkFlag}; + use proxmox_ve_config::sdn::fabric::{ + Entry, + section_config::{ + node::NodeSection, + protocol::wireguard::{InternalWireGuardNode, WireGuardNode, WireGuardProperties}, + }, + }; + use proxmox_wireguard::PublicKey; use serde::Serialize; - #[derive(Debug, Serialize)] - pub struct NeighborStatus; - #[derive(Debug, Serialize)] - pub struct InterfaceStatus; + #[derive(Debug, Serialize, PartialEq, Eq, PartialOrd, Ord)] + #[repr(transparent)] + pub struct PersistentKeepAliveStatus( + #[serde(skip_serializing_if = "Option::is_none")] Option, + ); + + impl FromStr for PersistentKeepAliveStatus { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + if s == "off" { + return Ok(Self(None)); + } + + Ok(Self(Some(s.parse()?))) + } + } + + #[derive(Debug, Serialize, PartialEq, Eq, PartialOrd, Ord)] + #[serde(rename_all = "kebab-case")] + pub struct NeighborStatus { + pub neighbor: String, + pub name: String, + pub interface: String, + pub public_key: PublicKey, + pub allowed_ips: String, + pub latest_handshake: u64, + pub bytes_rx: u64, + pub bytes_tx: u64, + pub persistent_keepalive: PersistentKeepAliveStatus, + } + + #[derive(Debug, Serialize, PartialEq, Eq, PartialOrd, Ord)] + #[serde(rename_all = "kebab-case")] + pub struct InterfaceStatus { + pub name: String, + #[serde(rename = "type")] + pub ty: String, + pub state: String, + pub public_key: PublicKey, + pub listen_port: u16, + } + + pub fn parse_wireguard_interfaces( + wg_dump_output: &str, + node_section: &NodeSection, + network_interfaces: &HashMap, + ) -> Result, anyhow::Error> { + let WireGuardNode::Internal(wireguard_node) = node_section.properties() else { + anyhow::bail!("is not an internal node"); + }; + + let mut interface_status = Vec::new(); + let mut last_interface = None; + + let interfaces: HashSet<&str> = wireguard_node + .interfaces() + .map(|interface| interface.name().as_ref()) + .collect(); + + for line in wg_dump_output.lines() { + let mut parts = line.split_ascii_whitespace(); + + let interface = parts.next().ok_or_else(|| { + anyhow::anyhow!("could not read interface name from `wg dump` output") + })?; + + if last_interface != Some(interface) && interfaces.contains(interface) { + // skip the private key + parts.next().ok_or_else(|| { + anyhow::anyhow!("could not read private key from `wg dump` output") + })?; + + let public_key = parts + .next() + .ok_or_else(|| { + anyhow::anyhow!("could not read public key from `wg dump` output") + })? + .parse() + .with_context(|| "could not parse public key from `wg dump` output.")?; + + let listen_port = parts + .next() + .ok_or_else(|| { + anyhow::anyhow!("could not read listen port from `wg dump` output") + })? + .parse() + .with_context(|| "could not parse listen_port from `wg dump` output")?; + + let state = if let Some(network_interface) = network_interfaces.get(interface) { + let is_wireguard_interface = network_interface + .linkinfo() + .and_then(|link_info| link_info.info_kind()) + .map(|info_kind| info_kind == "wireguard") + .unwrap_or_default(); + + if !is_wireguard_interface { + "error" + } else if !network_interface.flags().any(|flag| *flag == LinkFlag::Up) { + "down" + } else { + "up" + } + } else { + "error" + } + .to_string(); + + interface_status.push(InterfaceStatus { + name: interface.to_string(), + ty: "wireguard".to_string(), + state, + public_key, + listen_port, + }); + + last_interface = Some(interface); + } + } + + Ok(interface_status) + } + + pub fn parse_wireguard_neighbors( + wg_dump_output: &str, + node_section: &NodeSection, + fabric: &Entry, + ) -> Result, anyhow::Error> { + let WireGuardNode::Internal(wireguard_node) = node_section.properties() else { + anyhow::bail!("is not an internal node"); + }; + + let mut neighbors = Vec::new(); + let mut last_interface = None; + + let interfaces: HashSet<&str> = wireguard_node + .interfaces() + .map(|interface| interface.name().as_ref()) + .collect(); + + for line in wg_dump_output.lines() { + let mut parts = line.split_ascii_whitespace(); + + let interface = parts.next().ok_or_else(|| { + anyhow::anyhow!("could not read interface name from `wg dump` output") + })?; + + if last_interface == Some(interface) && interfaces.contains(interface) { + let public_key = parts + .next() + .ok_or_else(|| { + anyhow::anyhow!("could not read public key from `wg dump` output") + })? + .parse() + .with_context(|| "could not parse public key from `wg dump` output.")?; + + // skip the preshared key + parts.next().ok_or_else(|| { + anyhow::anyhow!("could not read private key from `wg dump` output") + })?; + + let neighbor = parts + .next() + .ok_or_else(|| { + anyhow::anyhow!("could not read neighbor from `wg dump` output") + })? + .to_string(); + + let allowed_ips = parts + .next() + .ok_or_else(|| { + anyhow::anyhow!("could not read allowed ips from `wg dump` output") + })? + .to_string(); + + let latest_handshake = parts + .next() + .ok_or_else(|| { + anyhow::anyhow!("could not read latest handshake from `wg dump` output") + })? + .parse() + .with_context(|| "could not parse latest_handshake timestamp")?; + + let bytes_rx = parts + .next() + .ok_or_else(|| { + anyhow::anyhow!("could not read received bytes from `wg dump` output") + })? + .parse() + .with_context(|| "could not parse received bytes")?; + + let bytes_tx = parts + .next() + .ok_or_else(|| { + anyhow::anyhow!("could not read transmitted bytes from `wg dump` output") + })? + .parse() + .with_context(|| "could not parse transmitted bytes")?; + + let persistent_keepalive = parts + .next() + .ok_or_else(|| { + anyhow::anyhow!("could not read persistent_keepalive from `wg dump` output") + })? + .parse() + .with_context(|| "could not parse persistent_keepalive interval")?; + + let Some((node, node_interface)) = fabric.find_node_and_interface_by_endpoint( + node_section.id().node_id(), + &neighbor.parse()?, + )? + else { + anyhow::bail!("can not find matching peer definition for endpoint {neighbor}"); + }; + + let mut name = node.id().node_id().to_string(); + + if let Some(node_interface) = node_interface { + name.push_str(" ("); + name.push_str(node_interface.name()); + name.push_str(")"); + } + + neighbors.push(NeighborStatus { + neighbor, + interface: interface.to_string(), + public_key, + name, + allowed_ips, + latest_handshake, + bytes_tx, + bytes_rx, + persistent_keepalive, + }); + } else { + last_interface = Some(interface); + } + } + + Ok(neighbors) + } } mod bgp { @@ -129,6 +381,11 @@ impl From> for NeighborStatus { NeighborStatus::Ospf(value) } } +impl From> for NeighborStatus { + fn from(value: Vec) -> Self { + NeighborStatus::WireGuard(value) + } +} impl From> for NeighborStatus { fn from(value: Vec) -> Self { NeighborStatus::Bgp(value) @@ -155,6 +412,11 @@ impl From> for InterfaceStatus { InterfaceStatus::Ospf(value) } } +impl From> for InterfaceStatus { + fn from(value: Vec) -> Self { + InterfaceStatus::WireGuard(value) + } +} impl From> for InterfaceStatus { fn from(value: Vec) -> Self { InterfaceStatus::Bgp(value) @@ -687,9 +949,15 @@ pub fn get_l2vpn_routes(routes: de::evpn::Routes) -> Result Valid { let raw_config = r#"{ @@ -2778,4 +3046,251 @@ mod tests { assert_eq!(reference, output); } } + + #[test] + fn parse_wireguard_interfaces_neighbors() -> Result<(), Error> { + let wg_dump_output = r#"wg1 wF2D1my5Cj962/MOS2UXLvCddm3ozmCSSuBQ1Ey6v3I= Um+Z4Ymq3r24txH2itVVtC86ra1PIrFs9AeMebESdWM= 51821 off +wg1 aHnKVQ4yTtTdfwsk+r4Z12FrRlUnXXksjRcV7x41+G0= (none) 172.31.1.10:51820 (none) 0 0 0 off +wg0 GDHQ8ivFvwz53U0bSmQ7uTD2bJ8GpcFCCxGeU1G9m2U= AQkUKUJXOxR/QH6fjZLx8GO1ZnnD8PueRYMJNOCzc2M= 51820 off +wg0 +PA/xNCZ3G+Wy1DeqF251Us5mcZhBTGc9CT3AaJ89HI= (none) 172.31.1.2:51820 198.51.100.2/32,203.0.113.0/28 0 0 0 off +wg0 8ybxvVfiWqpBG160nMgH14acR3z31ZeceW7ita0zonA= (none) 172.31.1.4:51820 198.51.100.4/32 0 0 0 off"#; + + let ip_link_output = r#"[ +{ + "ifindex": 25, + "ifname": "wg0", + "flags": [ + "POINTOPOINT", + "NOARP", + "UP", + "LOWER_UP" + ], + "mtu": 1500, + "qdisc": "noqueue", + "operstate": "UNKNOWN", + "linkmode": "DEFAULT", + "group": "default", + "txqlen": 1000, + "link_type": "none", + "promiscuity": 0, + "allmulti": 0, + "min_mtu": 0, + "max_mtu": 2147483552, + "linkinfo": { + "info_kind": "wireguard" + }, + "inet6_addr_gen_mode": "none", + "num_tx_queues": 1, + "num_rx_queues": 1, + "gso_max_size": 65536, + "gso_max_segs": 65535, + "tso_max_size": 524280, + "tso_max_segs": 65535, + "gro_max_size": 65536, + "gso_ipv4_max_size": 65536, + "gro_ipv4_max_size": 65536 +}, +{ + "ifindex": 24, + "ifname": "wg1", + "flags": [ + "POINTOPOINT", + "NOARP" + ], + "mtu": 1500, + "qdisc": "noqueue", + "operstate": "DOWN", + "linkmode": "DEFAULT", + "group": "default", + "txqlen": 1000, + "link_type": "none", + "promiscuity": 0, + "allmulti": 0, + "min_mtu": 0, + "max_mtu": 2147483552, + "linkinfo": { + "info_kind": "wireguard" + }, + "inet6_addr_gen_mode": "none", + "num_tx_queues": 1, + "num_rx_queues": 1, + "gso_max_size": 65536, + "gso_max_segs": 65535, + "tso_max_size": 524280, + "tso_max_segs": 65535, + "gro_max_size": 65536, + "gso_ipv4_max_size": 65536, + "gro_ipv4_max_size": 65536 +} +]"#; + + let network_interfaces: HashMap = + serde_json::from_str::>(&ip_link_output)? + .into_iter() + .map(|ip_link| (ip_link.name().to_string(), ip_link)) + .collect(); + + let raw_fabric_config = r#"wireguard_fabric: test + +wireguard_node: test_chronomancer + endpoint 172.31.1.4 + interfaces name=wg1,listen_port=51821,public_key=+RAKruThY8Qx/oLKRoqYZ4DZJwd9/BO3lVVAR6INNTo= + interfaces name=wg0,listen_port=51820,public_key=8ybxvVfiWqpBG160nMgH14acR3z31ZeceW7ita0zonA=,ip=198.51.100.4/24 + peers type=internal,node=elementalist,node_iface=wg0,iface=wg0 + peers type=internal,node=occultist,node_iface=wg0,iface=wg0 + peers type=external,node=stormweaver,iface=wg1 + role internal + +wireguard_node: test_elementalist + endpoint 172.31.1.1 + interfaces name=wg1,listen_port=51821,public_key=Um+Z4Ymq3r24txH2itVVtC86ra1PIrFs9AeMebESdWM= + interfaces name=wg0,listen_port=51820,public_key=AQkUKUJXOxR/QH6fjZLx8GO1ZnnD8PueRYMJNOCzc2M=,ip=198.51.100.1/24 + peers type=internal,node=occultist,node_iface=wg0,iface=wg0 + peers type=internal,node=chronomancer,node_iface=wg0,iface=wg0 + peers type=external,node=stormweaver,iface=wg1 + role internal + +wireguard_node: test_occultist + allowed_ips 203.0.113.0/28 + endpoint 172.31.1.2 + interfaces name=wg1,listen_port=51821,public_key=UBvDcsMICJLpy/+aRpJXFbDfU4eZrBeWnHimjzla/SI= + interfaces name=wg0,listen_port=51820,public_key=+PA/xNCZ3G+Wy1DeqF251Us5mcZhBTGc9CT3AaJ89HI=,ip=198.51.100.2/24 + peers type=external,node=stormweaver,iface=wg1 + peers type=internal,node=elementalist,node_iface=wg0,iface=wg0 + peers type=internal,node=chronomancer,node_iface=wg0,iface=wg0 + role internal + +wireguard_node: test_stormweaver + endpoint 172.31.1.10:51820 + public_key aHnKVQ4yTtTdfwsk+r4Z12FrRlUnXXksjRcV7x41+G0= + role external"#; + + let parsed_config = Section::parse_section_config("fabrics.cfg", raw_fabric_config)?; + + let fabric_config = FabricConfig::from_section_config(parsed_config) + .expect("is a valid fabric configuration"); + + let fabric_entry = fabric_config + .get_fabric(&FabricId::from_str("test").expect("valid fabric id")) + .expect("fabric exists"); + + let ConfigNode::WireGuard(wireguard_node) = fabric_entry + .get_node(&NodeId::from_str("elementalist").expect("is a valid node id")) + .expect("node exists in fabric config") + else { + anyhow::bail!("is not a wireguard node"); + }; + + let FabricEntry::WireGuard(wireguard_entry) = fabric_entry else { + anyhow::bail!("is not a wireguard fabric"); + }; + + let reference = vec![ + wireguard::InterfaceStatus { + name: "wg1".to_string(), + ty: "wireguard".to_string(), + state: "down".to_string(), + public_key: "Um+Z4Ymq3r24txH2itVVtC86ra1PIrFs9AeMebESdWM=" + .parse() + .expect("valid public key"), + listen_port: 51821, + }, + wireguard::InterfaceStatus { + name: "wg0".to_string(), + ty: "wireguard".to_string(), + state: "up".to_string(), + public_key: "AQkUKUJXOxR/QH6fjZLx8GO1ZnnD8PueRYMJNOCzc2M=" + .parse() + .expect("valid public key"), + listen_port: 51820, + }, + ]; + + assert_eq!( + reference, + wireguard::parse_wireguard_interfaces( + wg_dump_output, + &wireguard_node, + &network_interfaces + ) + .expect("can parse wireguard output") + ); + + let reference = vec![ + wireguard::InterfaceStatus { + name: "wg1".to_string(), + ty: "wireguard".to_string(), + state: "error".to_string(), + public_key: "Um+Z4Ymq3r24txH2itVVtC86ra1PIrFs9AeMebESdWM=" + .parse() + .expect("valid public key"), + listen_port: 51821, + }, + wireguard::InterfaceStatus { + name: "wg0".to_string(), + ty: "wireguard".to_string(), + state: "error".to_string(), + public_key: "AQkUKUJXOxR/QH6fjZLx8GO1ZnnD8PueRYMJNOCzc2M=" + .parse() + .expect("valid public key"), + listen_port: 51820, + }, + ]; + + assert_eq!( + reference, + wireguard::parse_wireguard_interfaces(wg_dump_output, &wireguard_node, &HashMap::new()) + .expect("can parse wireguard output") + ); + + let reference = vec![ + wireguard::NeighborStatus { + neighbor: "172.31.1.10:51820".to_string(), + name: "stormweaver".to_string(), + interface: "wg1".to_string(), + public_key: "aHnKVQ4yTtTdfwsk+r4Z12FrRlUnXXksjRcV7x41+G0=" + .parse() + .expect("valid public key"), + allowed_ips: "(none)".to_string(), + latest_handshake: 0, + bytes_rx: 0, + bytes_tx: 0, + persistent_keepalive: "off".parse().expect("valid persistent keepalive value"), + }, + wireguard::NeighborStatus { + neighbor: "172.31.1.2:51820".to_string(), + name: "occultist (wg0)".to_string(), + interface: "wg0".to_string(), + public_key: "+PA/xNCZ3G+Wy1DeqF251Us5mcZhBTGc9CT3AaJ89HI=" + .parse() + .expect("valid public key"), + allowed_ips: "198.51.100.2/32,203.0.113.0/28".to_string(), + latest_handshake: 0, + bytes_rx: 0, + bytes_tx: 0, + persistent_keepalive: "off".parse().expect("valid persistent keepalive value"), + }, + wireguard::NeighborStatus { + neighbor: "172.31.1.4:51820".to_string(), + name: "chronomancer (wg0)".to_string(), + interface: "wg0".to_string(), + public_key: "8ybxvVfiWqpBG160nMgH14acR3z31ZeceW7ita0zonA=" + .parse() + .expect("valid public key"), + allowed_ips: "198.51.100.4/32".to_string(), + latest_handshake: 0, + bytes_rx: 0, + bytes_tx: 0, + persistent_keepalive: "off".parse().expect("valid persistent keepalive value"), + }, + ]; + + assert_eq!( + reference, + wireguard::parse_wireguard_neighbors(wg_dump_output, &wireguard_node, wireguard_entry) + .expect("can parse wireguard output") + ); + + Ok(()) + } } -- 2.47.3