From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) by lore.proxmox.com (Postfix) with ESMTPS id 69D1F1FF13C for ; Thu, 19 Feb 2026 15:58:23 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 8943C1984F; Thu, 19 Feb 2026 15:57:38 +0100 (CET) From: Stefan Hanreich To: pve-devel@lists.proxmox.com Subject: [PATCH proxmox-ve-rs 9/9] ve-config: fabrics: implement wireguard config generation Date: Thu, 19 Feb 2026 15:56:28 +0100 Message-ID: <20260219145649.441418-12-s.hanreich@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260219145649.441418-1-s.hanreich@proxmox.com> References: <20260219145649.441418-1-s.hanreich@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 0 AWL -0.176 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 KAM_LAZY_DOMAIN_SECURITY 1 Sending domain does not have any anti-forgery methods RDNS_NONE 0.793 Delivered to internal network by a host with no rDNS SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_NONE 0.001 SPF: sender does not publish an SPF Record Message-ID-Hash: YXDPNP64NLXJRYBR72PKZANDOXNRTXAE X-Message-ID-Hash: YXDPNP64NLXJRYBR72PKZANDOXNRTXAE X-MailFrom: hoan@cray.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: Introduce a ConfigBuilder for WireGuard, analogous to the existing configuration builder for FRR. It uses the proxmox-wireguard crate for generating WireGuard configuration files, compatible with the `wg(8)` tool contained in the wireguard-tools package. The WireGuardConfigBuilder utilizes a loader callback for tests, so the private key loading can be mocked by test functions. Signed-off-by: Stefan Hanreich --- proxmox-ve-config/Cargo.toml | 1 + proxmox-ve-config/debian/control | 2 + .../section_config/protocol/wireguard.rs | 7 + proxmox-ve-config/src/sdn/mod.rs | 1 + proxmox-ve-config/src/sdn/wireguard.rs | 311 ++++++++++++++++++ 5 files changed, 322 insertions(+) create mode 100644 proxmox-ve-config/src/sdn/wireguard.rs diff --git a/proxmox-ve-config/Cargo.toml b/proxmox-ve-config/Cargo.toml index bb1a057..22a76a7 100644 --- a/proxmox-ve-config/Cargo.toml +++ b/proxmox-ve-config/Cargo.toml @@ -18,6 +18,7 @@ tracing = "0.1.37" serde = { workspace = true, features = [ "derive" ] } serde_json = "1" +proxmox-base64 = "1" proxmox-frr = { workspace = true, optional = true } proxmox-network-types = { workspace = true, features = [ "api-types" ] } proxmox-schema = { workspace = true, features = [ "api-types" ] } diff --git a/proxmox-ve-config/debian/control b/proxmox-ve-config/debian/control index 28fae4c..5cfc068 100644 --- a/proxmox-ve-config/debian/control +++ b/proxmox-ve-config/debian/control @@ -10,6 +10,7 @@ Build-Depends-Arch: cargo:native , librust-const-format-0.2+default-dev , librust-log-0.4+default-dev , librust-nix-0.29+default-dev , + librust-proxmox-base64-1+default-dev , librust-proxmox-network-types-0.1+api-types-dev (>= 0.1.1-~~) , librust-proxmox-network-types-0.1+default-dev (>= 0.1.1-~~) , librust-proxmox-schema-5+api-types-dev , @@ -43,6 +44,7 @@ Depends: librust-const-format-0.2+default-dev, librust-log-0.4+default-dev, librust-nix-0.29+default-dev, + librust-proxmox-base64-1+default-dev, librust-proxmox-network-types-0.1+api-types-dev (>= 0.1.1-~~), librust-proxmox-network-types-0.1+default-dev (>= 0.1.1-~~), librust-proxmox-schema-5+api-types-dev, diff --git a/proxmox-ve-config/src/sdn/fabric/section_config/protocol/wireguard.rs b/proxmox-ve-config/src/sdn/fabric/section_config/protocol/wireguard.rs index 3acd856..ec3315e 100644 --- a/proxmox-ve-config/src/sdn/fabric/section_config/protocol/wireguard.rs +++ b/proxmox-ve-config/src/sdn/fabric/section_config/protocol/wireguard.rs @@ -440,6 +440,13 @@ impl WireGuardNodePeer { WireGuardNodePeer::External(external_peer) => &external_peer.iface, } } + + pub fn node(&self) -> &NodeId { + match self { + WireGuardNodePeer::Internal(internal_peer) => &internal_peer.node, + WireGuardNodePeer::External(external_peer) => &external_peer.node, + } + } } #[derive(Clone, Debug, Serialize, Deserialize)] diff --git a/proxmox-ve-config/src/sdn/mod.rs b/proxmox-ve-config/src/sdn/mod.rs index 4586c56..9811f7f 100644 --- a/proxmox-ve-config/src/sdn/mod.rs +++ b/proxmox-ve-config/src/sdn/mod.rs @@ -3,6 +3,7 @@ pub mod fabric; #[cfg(feature = "frr")] pub mod frr; pub mod ipam; +pub mod wireguard; use std::{error::Error, fmt::Display, str::FromStr}; diff --git a/proxmox-ve-config/src/sdn/wireguard.rs b/proxmox-ve-config/src/sdn/wireguard.rs new file mode 100644 index 0000000..876eb20 --- /dev/null +++ b/proxmox-ve-config/src/sdn/wireguard.rs @@ -0,0 +1,311 @@ +use std::collections::HashMap; +use std::ops::Deref; + +use anyhow::bail; + +use proxmox_network_types::endpoint::ServiceEndpoint; +use proxmox_network_types::ip_address::{Ipv4Cidr, Ipv6Cidr}; +use proxmox_sdn_types::wireguard::PersistentKeepalive; +use proxmox_wireguard::{PrivateKey, WireGuardConfig, WireGuardInterface, WireGuardPeer}; + +use crate::common::valid::Valid; +use crate::sdn::fabric::section_config::fabric::Fabric; +use crate::sdn::fabric::section_config::node::Node; +use crate::sdn::fabric::section_config::protocol::wireguard::{WireGuardNode, WireGuardNodePeer}; +use crate::sdn::fabric::{section_config::node::NodeId, FabricConfig}; + +type WireGuardPrivateKeyLoader = Box Result>; + +pub struct WireGuardConfigBuilder { + fabrics: Valid, + loader_callback: WireGuardPrivateKeyLoader, +} + +const WIREGUARD_CONFIG_FOLDER: &str = "/etc/wireguard/proxmox"; + +fn default_load_private_key(interface_name: &str) -> Result { + Ok(PrivateKey::from_raw( + proxmox_base64::decode( + std::fs::read_to_string(format!( + "{}/{}.key", + WIREGUARD_CONFIG_FOLDER, interface_name + )) + .map_err(|_| anyhow::format_err!("could not read private key file"))?, + ) + .map_err(|_| anyhow::format_err!("could not decode base64 string"))? + .as_slice() + .try_into() + .map_err(|_| anyhow::format_err!("private key has invalid format"))?, + )) +} + +impl Default for WireGuardConfigBuilder { + fn default() -> Self { + Self { + loader_callback: Box::new(default_load_private_key), + fabrics: Default::default(), + } + } +} + +impl WireGuardConfigBuilder { + pub fn with_loader_callback( + loader_callback: impl Fn(&str) -> Result + 'static, + ) -> Self { + Self { + loader_callback: Box::new(loader_callback), + fabrics: Default::default(), + } + } + + pub fn add_fabrics(mut self, fabric: Valid) -> Self { + self.fabrics = fabric; + self + } + + pub fn build( + self, + current_node: NodeId, + ) -> Result, anyhow::Error> { + let mut wireguard_config = HashMap::new(); + + for fabric_entry in self.fabrics.values() { + let Fabric::WireGuard(fabric_config) = fabric_entry.fabric() else { + continue; + }; + + let Ok(Node::WireGuard(node_config)) = fabric_entry.get_node(¤t_node) else { + continue; + }; + + let WireGuardNode::Internal(node_properties) = node_config.properties() else { + continue; + }; + + for interface in node_properties.interfaces() { + let private_key = (self.loader_callback)(&interface.name)?; + + let wireguard_interface = WireGuardInterface { + private_key, + listen_port: Some(interface.listen_port), + fw_mark: None, + }; + + let mut wireguard_peers = Vec::new(); + + for peer in &node_properties.peers { + let peer = peer.deref(); + + let Ok(Node::WireGuard(referenced_node)) = fabric_entry.get_node(peer.node()) + else { + bail!( + "could not find node referenced in peer definition: {}", + peer.node() + ) + }; + + let peer_config = match (referenced_node.properties(), peer) { + ( + WireGuardNode::Internal(wireguard_node), + WireGuardNodePeer::Internal(peer), + ) => { + if peer.iface != interface.name { + continue; + } + + let peer_interface = wireguard_node + .interfaces() + .find(|interface| interface.name == peer.node_iface) + .ok_or_else(|| { + anyhow::format_err!("could not find referenced iface") + })?; + + let endpoint = peer + .endpoint + .as_ref() + .or(wireguard_node.endpoint.as_ref()) + .map(|endpoint| { + ServiceEndpoint::new( + endpoint.to_string(), + peer_interface.listen_port, + ) + }); + + let mut allowed_ips = Vec::new(); + + if let Some(ip) = referenced_node.ip() { + allowed_ips.push(Ipv4Cidr::from(ip).into()) + } + + if let Some(ip) = peer_interface.ip() { + allowed_ips.push(Ipv4Cidr::new(*ip.address(), 32)?.into()) + } + + if let Some(ip) = peer_interface.ip6() { + allowed_ips.push(Ipv6Cidr::new(*ip.address(), 128)?.into()) + } + + allowed_ips.extend(&wireguard_node.allowed_ips); + allowed_ips.extend(&peer.allowed_ips); + + WireGuardPeer { + public_key: peer_interface.public_key, + preshared_key: None, + allowed_ips, + endpoint, + persistent_keepalive: fabric_config + .properties() + .persistent_keepalive + .as_ref() + .map(PersistentKeepalive::raw), + } + } + ( + WireGuardNode::External(referenced_node), + WireGuardNodePeer::External(peer), + ) => { + let mut allowed_ips = Vec::new(); + allowed_ips.extend(&referenced_node.allowed_ips); + allowed_ips.extend(&peer.allowed_ips); + + let endpoint = peer + .endpoint + .clone() + .unwrap_or_else(|| referenced_node.endpoint.clone()); + + WireGuardPeer { + public_key: referenced_node.public_key, + preshared_key: None, + allowed_ips, + endpoint: Some(endpoint), + persistent_keepalive: fabric_config + .properties() + .persistent_keepalive + .as_ref() + .map(PersistentKeepalive::raw), + } + } + _ => { + bail!("invalid combination of peer / node types") + } + }; + + wireguard_peers.push(peer_config); + } + + wireguard_config.insert( + interface.name.to_string(), + WireGuardConfig { + interface: wireguard_interface, + peers: wireguard_peers, + }, + ); + } + } + + Ok(wireguard_config) + } +} + +#[cfg(test)] +mod tests { + use proxmox_section_config::typed::ApiSectionDataEntry; + + use crate::sdn::fabric::{section_config::Section, FabricConfig}; + + use super::*; + + fn mock_load_private_key(_interface_name: &str) -> Result { + Ok(PrivateKey::from_raw( + proxmox_base64::decode("qGXl+84iE1teMyQeL1DgkuLivKKYasx6fOYqBfr3QEI=") + .expect("valid base64") + .as_slice() + .try_into() + .expect("private key is a 32 byte slice"), + )) + } + + #[test] + fn test_wireguard_config_generation() -> Result<(), anyhow::Error> { + let section_config = r#" +wireguard_fabric: wireg + +wireguard_node: wireg_external + role external + endpoint 192.0.2.1:123 + allowed_ips 198.51.0.123/32 + public_key O+Kzrochm6klMILjSKVw83xb3YyXXLpmZj9n/ICM5xE= + +wireguard_node: wireg_pve1 + role internal + endpoint 192.0.2.2 + allowed_ips 203.0.113.0/25 + interfaces name=wg0,listen_port=51111,public_key=GDPUAnPOY5xGIjYXmcGyXZXbocjBr21dGQ5vwnjmdzA=,ip=198.51.100.1/24 + peers type=internal,node=pve2,node_iface=wg0,iface=wg0 + +wireguard_node: wireg_pve2 + role internal + endpoint 192.0.2.3 + interfaces name=wg0,listen_port=51111,public_key=y0kOpXfo9ff4KoUwO3H1cRuwObbKwsK8mAkwXxNvKUc= + peers type=internal,node=pve1,node_iface=wg0,iface=wg0 + peers type=external,node=external,iface=wg0 +"#; + let mut parsed_config = Section::parse_section_config("fabrics.cfg", section_config)?; + let mut fabric_config = FabricConfig::from_section_config(parsed_config) + .expect("valid wireguard configuration"); + let mut builder = WireGuardConfigBuilder::with_loader_callback(mock_load_private_key) + .add_fabrics(fabric_config); + + let pve1_wg0_config = r#"[Interface] +PrivateKey = qGXl+84iE1teMyQeL1DgkuLivKKYasx6fOYqBfr3QEI= +ListenPort = 51111 + +[Peer] +PublicKey = y0kOpXfo9ff4KoUwO3H1cRuwObbKwsK8mAkwXxNvKUc= +Endpoint = 192.0.2.3:51111 +"#; + + pretty_assertions::assert_eq!( + pve1_wg0_config, + builder + .build("pve1".parse()?)? + .remove("wg0") + .expect("wg0 config has been generated") + .to_raw_config() + .expect("wireguard config can be serialized") + ); + + parsed_config = Section::parse_section_config("fabrics.cfg", section_config)?; + fabric_config = FabricConfig::from_section_config(parsed_config) + .expect("valid wireguard configuration"); + builder = WireGuardConfigBuilder::with_loader_callback(mock_load_private_key) + .add_fabrics(fabric_config); + + let pve2_wg0_config = r#"[Interface] +PrivateKey = qGXl+84iE1teMyQeL1DgkuLivKKYasx6fOYqBfr3QEI= +ListenPort = 51111 + +[Peer] +PublicKey = GDPUAnPOY5xGIjYXmcGyXZXbocjBr21dGQ5vwnjmdzA= +AllowedIPs = 198.51.100.1/32, 203.0.113.0/25 +Endpoint = 192.0.2.2:51111 + +[Peer] +PublicKey = O+Kzrochm6klMILjSKVw83xb3YyXXLpmZj9n/ICM5xE= +AllowedIPs = 198.51.0.123/32 +Endpoint = 192.0.2.1:123 +"#; + + pretty_assertions::assert_eq!( + pve2_wg0_config, + builder + .build("pve2".parse()?)? + .remove("wg0") + .expect("wg0 config has been generated") + .to_raw_config() + .expect("wireguard config can be serialized") + ); + + Ok(()) + } +} -- 2.47.3