[PATCH proxmox-perl-rs 1/2] pve-rs: fabrics: wireguard: generate ifupdown2 configuration

Stefan Hanreich
2026-02-19T14:56:49Z

From: Christoph Heiss <c.heiss@proxmox.com>

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 <s.hanreich@proxmox.com>
Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
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<HashMap<String, String>, 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<Item = &'a Cidr>,
+ ) -> Result<String, Error> {
+ 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<String, Error> {
+ fn render_interface(name: &str, cidr: Cidr, link_type: Option<&str>) -> Result<String, Error> {
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<openfabric::NeighborStatus>),
Ospf(Vec<ospf::NeighborStatus>),
+ WireGuard(Vec<wireguard::NeighborStatus>),
}

impl From<Vec<openfabric::NeighborStatus>> for NeighborStatus {
@@ -105,6 +115,7 @@ impl From<Vec<ospf::NeighborStatus>> for NeighborStatus {
pub enum InterfaceStatus {
Openfabric(Vec<openfabric::InterfaceStatus>),
Ospf(Vec<ospf::InterfaceStatus>),
+ WireGuard(Vec<wireguard::InterfaceStatus>),
}

impl From<Vec<openfabric::InterfaceStatus>> 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