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 7C53B1FF13C for ; Thu, 19 Feb 2026 15:57:06 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id DB57018CB8; Thu, 19 Feb 2026 15:57:14 +0100 (CET) From: Stefan Hanreich To: pve-devel@lists.proxmox.com Subject: [PATCH proxmox-ve-rs 6/9] ve-config: fabrics: add protocol-specific properties for wireguard Date: Thu, 19 Feb 2026 15:56:25 +0100 Message-ID: <20260219145649.441418-9-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: TFW4WEFVL2XQ5323763OHODZZMOIEDE5 X-Message-ID-Hash: TFW4WEFVL2XQ5323763OHODZZMOIEDE5 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 the types representing the wireguard entities in the fabric configuration. WireGuard nodes can have two different subtypes (internal or external), depending on whether they are cluster members or not. They are not implemented as distinct section types, but rather as variants of the same section type due to how the FabricConfig is structured. It associates one type of fabric section with one type of node section, so the internal/external nodes are modeled as an enum. Contrary to OSPF and Openfabric, interfaces do not reference existing interfaces on the node but rather which interfaces should get created on a node. WireGuard interfaces can define peers, which are references to the interfaces of internal nodes or to external nodes itself. This schema allows for easily re-using the same peer definition across multiple nodes and also makes it easy to create WireGuard setups that connect Proxmox VE cluster nodes. Since interfaces require a public key, which gets automatically generated when creating the interface, introduce an additional struct that models the interface definitions from the create call, which can be used for deserializing the interface definitions in the create call, before a public key has been generated. Due to peers being able to reference other node sections, validation of those invariants needs to happen at a higher level, for the whole configuration file and will be added in a later commit that adds the WireGuard variants to the FabricConfig. For additional information, also consult the module-level documentation in the wireguard module. Originally-by: Christoph Heiss Signed-off-by: Stefan Hanreich --- proxmox-ve-config/Cargo.toml | 1 + proxmox-ve-config/debian/control | 4 + .../sdn/fabric/section_config/protocol/mod.rs | 1 + .../section_config/protocol/wireguard.rs | 478 ++++++++++++++++++ 4 files changed, 484 insertions(+) create mode 100644 proxmox-ve-config/src/sdn/fabric/section_config/protocol/wireguard.rs diff --git a/proxmox-ve-config/Cargo.toml b/proxmox-ve-config/Cargo.toml index 3a4dd61..fdcb331 100644 --- a/proxmox-ve-config/Cargo.toml +++ b/proxmox-ve-config/Cargo.toml @@ -26,6 +26,7 @@ proxmox-section-config = { version = "3" } proxmox-serde = { workspace = true, features = [ "perl" ]} proxmox-sys = "1" proxmox-sortable-macro = "1" +proxmox-wireguard = { version = "0.1", features = [ "api-types" ] } [features] frr = ["dep:proxmox-frr"] diff --git a/proxmox-ve-config/debian/control b/proxmox-ve-config/debian/control index b211827..28fae4c 100644 --- a/proxmox-ve-config/debian/control +++ b/proxmox-ve-config/debian/control @@ -20,6 +20,8 @@ Build-Depends-Arch: cargo:native , librust-proxmox-serde-1+perl-dev , librust-proxmox-sortable-macro-1+default-dev , librust-proxmox-sys-1+default-dev , + librust-proxmox-wireguard-0.1+api-types-dev , + librust-proxmox-wireguard-0.1+default-dev , librust-regex-1+default-dev (>= 1.7-~~) , librust-serde-1+default-dev , librust-serde-1+derive-dev , @@ -51,6 +53,8 @@ Depends: librust-proxmox-serde-1+perl-dev, librust-proxmox-sortable-macro-1+default-dev, librust-proxmox-sys-1+default-dev, + librust-proxmox-wireguard-0.1+api-types-dev, + librust-proxmox-wireguard-0.1+default-dev, librust-regex-1+default-dev (>= 1.7-~~), librust-serde-1+default-dev, librust-serde-1+derive-dev, diff --git a/proxmox-ve-config/src/sdn/fabric/section_config/protocol/mod.rs b/proxmox-ve-config/src/sdn/fabric/section_config/protocol/mod.rs index c1ec847..fd77426 100644 --- a/proxmox-ve-config/src/sdn/fabric/section_config/protocol/mod.rs +++ b/proxmox-ve-config/src/sdn/fabric/section_config/protocol/mod.rs @@ -1,2 +1,3 @@ pub mod openfabric; pub mod ospf; +pub mod wireguard; 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 new file mode 100644 index 0000000..3765b89 --- /dev/null +++ b/proxmox-ve-config/src/sdn/fabric/section_config/protocol/wireguard.rs @@ -0,0 +1,478 @@ +//! WireGuard fabric properties +//! +//! The main building blocks of the WireGuard section configuration are Fabrics, Nodes, Interfaces +//! and Peers. +//! +//! ## Nodes +//! +//! There are two types of Nodes inside a WireGuard fabric: +//! * Internal - which represents a Proxmox VE node +//! * External - which represents anything that is not a Proxmox VE node +//! +//! For internal nodes, WireGuard interfaces can be configured, which will create a respective +//! WireGuard interface on the node. +//! +//! External nodes can only contain the public key + endpoint - so even if there are multiple +//! WireGuard interfaces on the same external peer they have to be configured as separate nodes, +//! since there is no notion of interfaces for external nodes. +//! +//! The main purpose of external nodes is to provide reusable peer definitions for configuring +//! WireGuard interfaces. For instance, a remote PDM instance can be configured as an external peer +//! and then referenced in the interface defintions. +//! +//! ## Peers +//! +//! For every WireGuard interface, peers can be configured. A peer can either reference the +//! interface of an internal node or an external node. The peer definition is generated +//! automatically from the information contained in the node section. Specific fields from the node +//! definition can be overridden in the peer definition, if e.g. a different endpoint is required +//! for connecting to a node. + +use std::ops::{Deref, DerefMut}; + +use anyhow::Result; + +use const_format::concatcp; +use proxmox_network_types::endpoint::{HostnameOrIpAddr, ServiceEndpoint}; +use proxmox_network_types::ip_address::{Cidr, Ipv4Cidr, Ipv6Cidr}; +use proxmox_schema::api_types::CIDR_SCHEMA; +use proxmox_schema::{api, property_string::PropertyString, ApiStringFormat, Updater, UpdaterType}; +use proxmox_schema::{ + api_string_type, const_regex, ApiType, ArraySchema, ObjectSchema, Schema, StringSchema, +}; +use proxmox_sdn_types::wireguard::PersistentKeepalive; +use proxmox_wireguard::PublicKey; +use serde::{Deserialize, Serialize}; + +use crate::sdn::fabric::section_config::node::NodeId; + +pub const WIREGUARD_INTERFACE_NAME_REGEX_STR: &str = "[a-zA-Z0-9][a-zA-Z0-9-]{0,6}[a-zA-Z0-9]?"; + +const_regex! { + pub WIREGUARD_INTERFACE_NAME_REGEX = concatcp!(r"^", WIREGUARD_INTERFACE_NAME_REGEX_STR, r"$"); +} + +pub const WIREGUARD_INTERFACE_NAME_FORMAT: ApiStringFormat = + ApiStringFormat::Pattern(&WIREGUARD_INTERFACE_NAME_REGEX); + +api_string_type! { + /// Name of a WireGuard network interface. + /// + /// The interface name can have a maximum of 8 characters. The characterset is restricted (as + /// opposed to the other fabric types which can reference arbitrary interfaces on the host), + /// since this name is used in filenames - among other places. + #[api( + min_length: 1, + max_length: 8, + format: &WIREGUARD_INTERFACE_NAME_FORMAT, + )] + #[derive(Debug, Deserialize, Serialize, Clone, Hash, PartialEq, Eq, PartialOrd, Ord, UpdaterType)] + pub struct WireGuardInterfaceName(String); +} + +/// Global properties for a WireGuard fabric. +#[api] +#[derive(Clone, Debug, Serialize, Deserialize, Updater, Hash)] +pub struct WireGuardProperties { + /// Persistent keepalive interval. + #[serde(skip_serializing_if = "persistent_keepalive_is_off")] + pub(crate) persistent_keepalive: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WireGuardDeletableProperties { + PersistentKeepalive, +} + +/// A node in the WireGuard fabric config. +/// +/// Can be either internal (= PVE node that is part of the current cluster) or external (= any +/// other peer that is running WireGuard). For more information see the respective structs or +/// module-level documentation. +#[derive(Debug, Clone, Serialize, Deserialize, Hash)] +#[serde(rename_all = "snake_case", tag = "role")] +pub enum WireGuardNode { + Internal(InternalWireGuardNode), + External(ExternalWireGuardNode), +} + +impl WireGuardNode { + /// An iterator over the subnets that are allowed for this WireGuard node. + pub fn allowed_ips(&self) -> impl Iterator { + match self { + WireGuardNode::Internal(internal_wire_guard_node) => { + internal_wire_guard_node.allowed_ips.iter() + } + WireGuardNode::External(external_wire_guard_node) => { + external_wire_guard_node.allowed_ips.iter() + } + } + } +} + +impl ApiType for WireGuardNode { + const API_SCHEMA: Schema = ObjectSchema::new( + "Wireguard Node", + &[ + ( + "allowed_ips", + true, + &ArraySchema::new( + "A list of CIDRs that are routed via this WireGuard node.", + &CIDR_SCHEMA, + ) + .schema(), + ), + ( + "interfaces", + true, + &ArraySchema::new( + "The WireGuard interfaces that should be created on this node.", + &StringSchema::new("WireGuard Interface definition.") + .format(&ApiStringFormat::PropertyString( + &WireGuardInterfaceProperties::API_SCHEMA, + )) + .schema(), + ) + .schema(), + ), + ( + "peers", + true, + &ArraySchema::new( + "The peers that should be created on this node.", + &StringSchema::new("wireguard iface") + .format(&ApiStringFormat::PropertyString( + &WireGuardNodePeer::API_SCHEMA, + )) + .schema(), + ) + .schema(), + ), + ], + ) + // TODO: not using a OneOf schema here, because it currently cannot handle properties that are + // optional on one variant, but not on the other. To work around this we have to use + // ObjectSchema with additional_properties until fixed in proxmox-schema. + .additional_properties(true) + .schema(); +} + +#[derive(Debug, Clone, Serialize, Deserialize, Hash)] +#[serde(rename_all = "snake_case", tag = "role")] +pub enum WireGuardNodeUpdater { + Internal(InternalWireGuardNodeUpdater), + External(ExternalWireGuardNodeUpdater), +} + +impl Updater for WireGuardNodeUpdater { + fn is_empty(&self) -> bool { + match self { + WireGuardNodeUpdater::Internal(updater) => updater.is_empty(), + WireGuardNodeUpdater::External(updater) => updater.is_empty(), + } + } +} + +impl UpdaterType for WireGuardNode { + type Updater = WireGuardNodeUpdater; +} + +#[api( + properties: { + allowed_ips: { + type: Array, + optional: true, + items: { + type: Cidr, + } + } + } +)] +#[derive(Debug, Clone, Serialize, Deserialize, Hash, Updater)] +/// A node that represents an external Wireguard peer. +/// +/// It can be used to store the configuration of a peer and reuse it across multiple nodes, without +/// having to re-enter the peer information for every Wireguard interface. +pub struct ExternalWireGuardNode { + /// The public key used by this node. + pub(crate) public_key: PublicKey, + + /// The endpoint used for connecting to this node. + pub(crate) endpoint: ServiceEndpoint, + + /// a list of IPs that are allowed for this peer + #[serde(default, skip_serializing_if = "Vec::is_empty")] + #[updater(serde(skip_serializing_if = "Option::is_none"))] + pub(crate) allowed_ips: Vec, +} + +/// A node that represents a member of the current cluster. +/// +/// It contains information about the interfaces that should be created on the node, as well as +/// their peers. +/// +/// The additional properties, like endpoint or allowed_ips, can be used to define the settings +/// when using this node as a peer inside the fabric. +#[api( + properties: { + interfaces: { + type: Array, + optional: true, + items: { + type: String, + description: "WireGuard interface properties.", + format: &ApiStringFormat::PropertyString(&WireGuardInterfaceProperties::API_SCHEMA), + } + }, + peers: { + type: Array, + optional: true, + items: { + type: String, + description: "WireGuard peer properties.", + format: &ApiStringFormat::PropertyString(&WireGuardNodePeer::API_SCHEMA), + } + }, + allowed_ips: { + type: Array, + optional: true, + items: { + type: Cidr, + } + }, + } +)] +#[derive(Clone, Debug, Serialize, Deserialize, Updater, Hash)] +#[serde(rename_all = "snake_case")] +pub struct InternalWireGuardNode { + /// The endpoint used for connecting to this node. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[updater(serde(skip_serializing_if = "Option::is_none"))] + pub(crate) endpoint: Option, + + /// The interfaces that should get created on this node. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + #[updater(serde(skip_serializing_if = "Option::is_none"))] + pub(crate) interfaces: Vec>, + + /// The peers that should get created for interfaces on this node. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + #[updater(serde(skip_serializing_if = "Option::is_none"))] + pub(crate) peers: Vec>, + + /// A list of IPs that are routable via this node in the WireGuard fabric. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + #[updater(serde(skip_serializing_if = "Option::is_none"))] + pub(crate) allowed_ips: Vec, +} + +impl InternalWireGuardNode { + /// Returns an iterator over all wireguard interfaces on this node. + pub fn peers(&self) -> impl Iterator { + self.peers + .iter() + .map(|property_string| property_string.deref()) + } + + /// Returns an iterator over all wireguard interfaces on this node. + pub fn interfaces(&self) -> impl Iterator { + self.interfaces + .iter() + .map(|property_string| property_string.deref()) + } + + /// Returns an iterator over all wireguard interfaces on this node (mutable). + pub fn interfaces_mut(&mut self) -> impl Iterator { + self.interfaces + .iter_mut() + .map(|property_string| property_string.deref_mut()) + } +} + +#[api( + properties: { + allowed_ips: { + type: Array, + optional: true, + items: { + type: Cidr, + } + }, + } +)] +#[derive(Debug, Clone, Serialize, Deserialize, Hash, Updater)] +/// A peer definition for a internal WireGuard node. +/// +/// It references the interface of an internal node. Settings are then automatically taken from the +/// respective node configuration. Additional properties can be set here to override the +/// information for the node for this specific peering instance. +pub struct InternalPeer { + /// The name of the node + pub(crate) node: NodeId, + /// The name of the interface on the node + pub(crate) node_iface: WireGuardInterfaceName, + /// The local interface that uses this peering definition + pub(crate) iface: WireGuardInterfaceName, + /// Override for the endpoint settings in the node section. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[updater(serde(skip_serializing_if = "Option::is_none"))] + pub(crate) endpoint: Option, + /// Additional allowed IPs for this peer + #[serde(default, skip_serializing_if = "Vec::is_empty")] + #[updater(serde(skip_serializing_if = "Option::is_none"))] + pub(crate) allowed_ips: Vec, +} + +#[api( + properties: { + allowed_ips: { + type: Array, + optional: true, + items: { + type: Cidr, + } + }, + } +)] +#[derive(Debug, Clone, Serialize, Deserialize, Hash, Updater)] +/// A peer definition for a external WireGuard node. +/// +/// They reference an external node via the name. The properties here can be used to override the +/// settings in the node definition. +pub struct ExternalPeer { + /// The name of the external peer. + pub(crate) node: NodeId, + /// Override for the endpoint settings in the node section. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[updater(serde(skip_serializing_if = "Option::is_none"))] + pub(crate) endpoint: Option, + /// The local interface that uses this peering definition + pub(crate) iface: WireGuardInterfaceName, + /// Additional allowed IPs for this peer + #[serde(default, skip_serializing_if = "Vec::is_empty")] + #[updater(serde(skip_serializing_if = "Option::is_none"))] + pub(crate) allowed_ips: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Hash, Updater)] +#[serde(rename_all = "snake_case", tag = "type")] +/// A peer entry in a node's section config. +/// +/// References either an internal peer or an external peer from the node sections. +pub enum WireGuardNodePeer { + Internal(InternalPeer), + External(ExternalPeer), +} + +impl ApiType for WireGuardNodePeer { + const API_SCHEMA: Schema = ObjectSchema::new("wireguard node peer", &[]) + .additional_properties(true) + .schema(); +} + +impl WireGuardNodePeer { + pub fn iface(&self) -> &WireGuardInterfaceName { + match self { + WireGuardNodePeer::Internal(internal_peer) => &internal_peer.iface, + WireGuardNodePeer::External(external_peer) => &external_peer.iface, + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WireGuardNodeDeletableProperties { + Interfaces, + Endpoint, + Peers, + AllowedIps, +} + +/// Properties of a WireGuard interface. +#[api()] +#[derive(Clone, Debug, Serialize, Deserialize, Hash)] +pub struct WireGuardInterfaceProperties { + /// Name for this WireGuard interface. + pub(crate) name: WireGuardInterfaceName, + + /// Listen port of the WireGuard interface. + pub(crate) listen_port: u16, + + /// Public Key of this interface + pub(crate) public_key: PublicKey, + + /// If ip and ip6 are unset, then this is an point-to-point interface. + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) ip: Option, + + /// If ip6 and ip are unset, then this is an point-to-point interface. + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) ip6: Option, + + /// whether to generate an IPv6 link-local address for this interface + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) ip6_ll: Option, +} + +impl WireGuardInterfaceProperties { + /// Get the name of the interface. + pub fn name(&self) -> &WireGuardInterfaceName { + &self.name + } + + /// Set the name of the interface. + pub fn set_name(&mut self, name: WireGuardInterfaceName) { + self.name = name + } + + /// Get the ip (IPv4) of the interface. + pub fn ip(&self) -> Option<&Ipv4Cidr> { + self.ip.as_ref() + } + + /// Get the ip6 (IPv6) of the interface. + pub fn ip6(&self) -> Option<&Ipv6Cidr> { + self.ip6.as_ref() + } +} + +/// Determines whether the given `PersistentKeepalive` value means that it is +/// turned off. Useful for usage with serde's `skip_serializing_if`. +fn persistent_keepalive_is_off(value: &Option) -> bool { + value + .as_ref() + .map(PersistentKeepalive::is_off) + .unwrap_or(true) +} + +/// Properties of a WireGuard interface, when creating it from the API. +/// +/// This makes public_key optional, since it isn't included for new interfaces, because it gets +/// generated automatically when creating the interface. +#[api()] +#[derive(Clone, Debug, Serialize, Deserialize, Hash)] +pub struct WireGuardInterfaceCreateProperties { + /// Name for this WireGuard interface. + pub(crate) name: WireGuardInterfaceName, + + /// Listen port of the WireGuard interface. + pub(crate) listen_port: u16, + + /// Public Key of this interface + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) public_key: Option, + + /// If ip and ip6 are unset, then this is an point-to-point interface. + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) ip: Option, + + /// If ip6 and ip are unset, then this is an point-to-point interface. + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) ip6: Option, + + /// whether to generate an IPv6 link-local address for this interface + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) ip6_ll: Option, +} -- 2.47.3