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 42BBB1FF13C for ; Thu, 19 Feb 2026 15:56:49 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 1E4C11896C; Thu, 19 Feb 2026 15:57:12 +0100 (CET) From: Stefan Hanreich To: pve-devel@lists.proxmox.com Subject: [PATCH proxmox-perl-rs 1/2] pve-rs: fabrics: wireguard: generate ifupdown2 configuration Date: Thu, 19 Feb 2026 15:56:29 +0100 Message-ID: <20260219145649.441418-13-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: 6W3KY5D7KQUGLV2SEUVYURA7QAHE5H7F X-Message-ID-Hash: 6W3KY5D7KQUGLV2SEUVYURA7QAHE5H7F 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: From: Christoph Heiss Add support for generating the respective sections in /etc/network/interfaces.d/sdn for WireGuard interfaces. The method automatically generates the ifupdown2 configuration for creating the WireGuard interfaces on the host. The WireGuard specific configuration is implemented via a post-up command that syncs the generated configuration via wg(8). Additionally, routes are created for every configured AdditionalIp in the WireGuard configuration. This also happens via respective post-up / down commands in the ifupdown2 configuration file. Currently, it is not ECMP aware, so it is not possible to route the same subnet via two different WireGuard interfaces. This is intended to be implemented in a future patch series. For now, this function generates two entries in the routing table and only the first inserted one works, analogous to how ifupdown2 behaves when configuring the same subnet twice. For the new WireGuard variants, stubs are implemented for the status reporting. This makes all status calls return empty values for everything. Status reporting will be implemented in a future patch series. Co-authored-by: Stefan Hanreich Signed-off-by: Christoph Heiss --- pve-rs/Cargo.toml | 1 + pve-rs/src/bindings/sdn/fabrics.rs | 177 ++++++++++++++++++++++++----- pve-rs/src/sdn/status.rs | 16 +++ 3 files changed, 165 insertions(+), 29 deletions(-) diff --git a/pve-rs/Cargo.toml b/pve-rs/Cargo.toml index 527fcae..3757b80 100644 --- a/pve-rs/Cargo.toml +++ b/pve-rs/Cargo.toml @@ -42,6 +42,7 @@ proxmox-notify = { version = "1", features = ["pve-context"] } proxmox-oci = "0.2.1" proxmox-openid = "1.0.2" proxmox-resource-scheduling = "1.0.1" +proxmox-schema = "5" proxmox-section-config = "3" proxmox-shared-cache = "1" proxmox-subscription = "1" diff --git a/pve-rs/src/bindings/sdn/fabrics.rs b/pve-rs/src/bindings/sdn/fabrics.rs index 54606e7..daec8a7 100644 --- a/pve-rs/src/bindings/sdn/fabrics.rs +++ b/pve-rs/src/bindings/sdn/fabrics.rs @@ -35,6 +35,11 @@ pub mod pve_rs_sdn_fabrics { }; use proxmox_ve_config::sdn::fabric::{FabricConfig, FabricEntry}; use proxmox_ve_config::sdn::frr::FrrConfigBuilder; + use proxmox_ve_config::sdn::wireguard::WireGuardConfigBuilder; + + use proxmox_ve_config::sdn::fabric::section_config::protocol::wireguard::{ + WireGuardInterfaceCreateProperties, WireGuardInterfaceProperties, WireGuardNode, + }; use crate::sdn::status::{self, RunningConfig}; @@ -364,6 +369,7 @@ pub mod pve_rs_sdn_fabrics { } } } + ConfigNode::WireGuard(_) => {} } } @@ -456,6 +462,7 @@ pub mod pve_rs_sdn_fabrics { FabricEntry::Openfabric(_) => { daemons.insert("fabricd"); } + FabricEntry::WireGuard(_) => {} // not a frr fabric }; } @@ -478,8 +485,72 @@ pub mod pve_rs_sdn_fabrics { to_raw_config(&frr_config) } + /// Returns the WireGuard configuration for the interfaces of the given node, + /// + /// It is a hash with the interface name as key and the configuration for that interface + /// as a string in INI-style as accepted by wg(8). + #[export] + pub fn get_wireguard_raw_config( + #[try_from_ref] this: &PerlFabricConfig, + node_id: NodeId, + ) -> Result, Error> { + let config = this.fabric_config.lock().unwrap(); + + let configs = WireGuardConfigBuilder::default() + .add_fabrics(config.clone().into_valid()?) + .build(node_id)?; + + let mut result = HashMap::new(); + + for (id, config) in configs { + result.insert(id.clone(), config.to_raw_config()?); + } + + Ok(result) + } + + /// Helper function to generate the section for a WireGuard interface in `/etc/network/interfaces.d/sdn`. + fn render_wireguard_interface<'a>( + name: &str, + cidrs: (Option<&Ipv4Cidr>, Option<&Ipv6Cidr>), + allowed_ips: impl Iterator, + ) -> Result { + let mut interface = String::new(); + + let (ip, ip6) = cidrs; + + writeln!(interface, "auto {name}")?; + + if let Some(ip) = ip { + writeln!(interface, "iface {name} inet static")?; + writeln!(interface, "\taddress {ip}")?; + } else { + writeln!(interface, "iface {name} inet manual")?; + } + + writeln!(interface, "\tlink-type wireguard")?; + writeln!(interface, "\tip-forward 1")?; + writeln!( + interface, + "\tpost-up wg syncconf {name} /etc/wireguard/proxmox/{name}.conf" + )?; + + for ip in allowed_ips { + writeln!(interface, "\tpost-up ip route add {ip} dev {name}")?; + writeln!(interface, "\tdown ip route del {ip} dev {name}")?; + } + + if let Some(ip) = ip6 { + writeln!(interface)?; + writeln!(interface, "iface {name} inet6 static")?; + writeln!(interface, "\taddress {ip}")?; + } + + Ok(interface) + } + /// Helper function to generate the default `/etc/network/interfaces` config for a given CIDR. - fn render_interface(name: &str, cidr: Cidr, is_dummy: bool) -> Result { + fn render_interface(name: &str, cidr: Cidr, link_type: Option<&str>) -> Result { let mut interface = String::new(); writeln!(interface, "auto {name}")?; @@ -488,14 +559,42 @@ pub mod pve_rs_sdn_fabrics { Cidr::Ipv6(_) => writeln!(interface, "iface {name} inet6 static")?, } writeln!(interface, "\taddress {cidr}")?; - if is_dummy { - writeln!(interface, "\tlink-type dummy")?; + if let Some(link_type) = link_type { + writeln!(interface, "\tlink-type {link_type}")?; } writeln!(interface, "\tip-forward 1")?; Ok(interface) } + fn render_dummy_interfaces( + interfaces: &mut String, + fabric: &Fabric, + node: &ConfigNode, + ) -> Result<(), Error> { + if let Some(ip) = node.ip() { + let interface = render_interface( + &format!("dummy_{}", fabric.id()), + Cidr::new_v4(ip, 32)?, + Some("dummy"), + )?; + writeln!(interfaces)?; + write!(interfaces, "{interface}")?; + } + + if let Some(ip6) = node.ip6() { + let interface = render_interface( + &format!("dummy_{}", fabric.id()), + Cidr::new_v6(ip6, 128)?, + Some("dummy"), + )?; + writeln!(interfaces)?; + write!(interfaces, "{interface}")?; + } + + Ok(()) + } + /// Method: Generate the ifupdown2 configuration for a given node. #[export] pub fn get_interfaces_etc_network_config( @@ -513,37 +612,20 @@ pub mod pve_rs_sdn_fabrics { }); for (fabric, node) in node_fabrics { - // dummy interface - if let Some(ip) = node.ip() { - let interface = render_interface( - &format!("dummy_{}", fabric.id()), - Cidr::new_v4(ip, 32)?, - true, - )?; - writeln!(interfaces)?; - write!(interfaces, "{interface}")?; - } - if let Some(ip6) = node.ip6() { - let interface = render_interface( - &format!("dummy_{}", fabric.id()), - Cidr::new_v6(ip6, 128)?, - true, - )?; - writeln!(interfaces)?; - write!(interfaces, "{interface}")?; - } + render_dummy_interfaces(&mut interfaces, fabric, node)?; + match node { ConfigNode::Openfabric(node_section) => { for interface in node_section.properties().interfaces() { if let Some(ip) = interface.ip() { let interface = - render_interface(interface.name(), Cidr::from(ip), false)?; + render_interface(interface.name(), Cidr::from(ip), None)?; writeln!(interfaces)?; write!(interfaces, "{interface}")?; } if let Some(ip) = interface.ip6() { let interface = - render_interface(interface.name(), Cidr::from(ip), false)?; + render_interface(interface.name(), Cidr::from(ip), None)?; writeln!(interfaces)?; write!(interfaces, "{interface}")?; } @@ -560,7 +642,7 @@ pub mod pve_rs_sdn_fabrics { } else { anyhow::bail!("there has to be a ipv4 or ipv6 node address"); }); - let interface = render_interface(interface.name(), cidr, false)?; + let interface = render_interface(interface.name(), cidr, None)?; writeln!(interfaces)?; write!(interfaces, "{interface}")?; } @@ -568,10 +650,10 @@ pub mod pve_rs_sdn_fabrics { } ConfigNode::Ospf(node_section) => { for interface in node_section.properties().interfaces() { + writeln!(interfaces)?; if let Some(ip) = interface.ip() { let interface = - render_interface(interface.name(), Cidr::from(ip), false)?; - writeln!(interfaces)?; + render_interface(interface.name(), Cidr::from(ip), None)?; write!(interfaces, "{interface}")?; } else { let interface = render_interface( @@ -579,9 +661,43 @@ pub mod pve_rs_sdn_fabrics { Cidr::from(IpAddr::from(node.ip().ok_or_else(|| { anyhow::anyhow!("there has to be a ipv4 address") })?)), - false, + None, )?; - writeln!(interfaces)?; + write!(interfaces, "{interface}")?; + } + } + } + ConfigNode::WireGuard(node_section) => { + if let WireGuardNode::Internal(node_properties) = node_section.properties() { + for interface in node_properties.interfaces() { + let entry = config + .get_fabric(fabric.id()) + // safe because we use the fabric we obtained earlier from the same config + .expect("entry for fabric exists in fabric config"); + + let allowed_ips = node_properties + .peers() + .filter_map(|peer| { + return if peer.iface() == interface.name() { + let ConfigNode::WireGuard(node) = + entry.get_node(peer.node()).ok()? + else { + return None; + }; + + Some(node.properties().allowed_ips()) + } else { + None + }; + }) + .flatten(); + + let interface = render_wireguard_interface( + interface.name(), + (interface.ip(), interface.ip6()), + allowed_ips, + )?; + write!(interfaces, "{interface}")?; } } @@ -678,6 +794,7 @@ pub mod pve_rs_sdn_fabrics { status::get_routes(fabric_id, config, ospf_routes, proxmox_sys::nodename()) } + FabricEntry::WireGuard(_) => Ok(Vec::new()), } } @@ -736,6 +853,7 @@ pub mod pve_rs_sdn_fabrics { ) .map(|v| v.into()) } + FabricEntry::WireGuard(_) => Ok(status::NeighborStatus::WireGuard(Vec::new())), } } @@ -795,6 +913,7 @@ pub mod pve_rs_sdn_fabrics { ) .map(|v| v.into()) } + FabricEntry::WireGuard(_) => Ok(status::InterfaceStatus::WireGuard(Vec::new())), } } diff --git a/pve-rs/src/sdn/status.rs b/pve-rs/src/sdn/status.rs index e1e3362..132a0f4 100644 --- a/pve-rs/src/sdn/status.rs +++ b/pve-rs/src/sdn/status.rs @@ -80,12 +80,22 @@ mod openfabric { } } +mod wireguard { + use serde::Serialize; + + #[derive(Debug, Serialize)] + pub struct NeighborStatus; + #[derive(Debug, Serialize)] + pub struct InterfaceStatus; +} + /// Common NeighborStatus that contains either OSPF or Openfabric neighbors #[derive(Debug, Serialize)] #[serde(untagged)] pub enum NeighborStatus { Openfabric(Vec), Ospf(Vec), + WireGuard(Vec), } impl From> for NeighborStatus { @@ -105,6 +115,7 @@ impl From> for NeighborStatus { pub enum InterfaceStatus { Openfabric(Vec), Ospf(Vec), + WireGuard(Vec), } impl From> for InterfaceStatus { @@ -135,6 +146,8 @@ pub enum Protocol { Openfabric, /// OSPF Ospf, + /// WireGuard + WireGuard, } /// The status of a fabric. @@ -217,6 +230,7 @@ pub fn get_routes( .interfaces() .map(|i| i.name().as_str()) .collect(), + ConfigNode::WireGuard(_) => HashSet::new(), }; let dummy_interface = format!("dummy_{}", fabric_id.as_str()); @@ -429,6 +443,7 @@ pub fn get_status( let (current_protocol, all_routes) = match &node { ConfigNode::Openfabric(_) => (Protocol::Openfabric, &routes.openfabric.0), ConfigNode::Ospf(_) => (Protocol::Ospf, &routes.ospf.0), + ConfigNode::WireGuard(_) => (Protocol::WireGuard, &BTreeMap::new()), }; // get interfaces @@ -443,6 +458,7 @@ pub fn get_status( .interfaces() .map(|i| i.name().as_str()) .collect(), + ConfigNode::WireGuard(_n) => HashSet::new(), }; // determine status by checking if any routes exist for our interfaces -- 2.47.3