public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
* [PATCH proxmox v2 0/8] sdn: add wireguard fabric configuration support
@ 2026-02-13 14:35 Christoph Heiss
  2026-02-13 14:35 ` [PATCH proxmox v2 1/8] serde: implement ini serializer Christoph Heiss
                   ` (7 more replies)
  0 siblings, 8 replies; 9+ messages in thread
From: Christoph Heiss @ 2026-02-13 14:35 UTC (permalink / raw)
  To: pve-devel

This series lays the groundwork with initial primitives and configuration
support for adding WireGuard as a new SDN fabric to our stack in the 
future.

Nothing of this code is actively used anywhere in the stack yet. I'm
sending it early to a) make it more reviewable as a smaller series and
b) potentially get in some prepratory work early.

Stefan already has a working prototype for adding WireGuard as a new
SDN fabric type, built on top of this.

One of the bigger topics next up will be wiring up actual section config
support for the fabric, which involves some challenges around proper
key handling.

History
=======

v1: https://lore.proxmox.com/pve-devel/20260116153317.1146323-1-c.heiss@proxmox.com/

Notable changes v1 -> v2:
  * key generation (and thus the dependency on proxmox-sys) is now
    feature-gated and optional 
  * dropped proxmox-ve-rs patches, Stefan will be incorporating them
    his SDN series

Diffstat
========

proxmox:

Christoph Heiss (6):
  serde: implement ini serializer
  serde: add base64 module for byte arrays
  network-types: add ServiceEndpoint type as host/port tuple abstraction
  schema: provide integer schema for node ports
  schema: api-types: add ed25519 base64 encoded key schema
  wireguard: init configuration support crate

Stefan Hanreich (2):
  wireguard: implement api for PublicKey
  wireguard: make per-peer preshared key optional

 Cargo.toml                             |   2 +
 proxmox-network-types/src/endpoint.rs  | 154 +++++
 proxmox-network-types/src/lib.rs       |   3 +-
 proxmox-schema/src/api_types.rs        |  19 +-
 proxmox-serde/Cargo.toml               |   2 +
 proxmox-serde/debian/control           |   4 +
 proxmox-serde/src/ini.rs               | 901 +++++++++++++++++++++++++
 proxmox-serde/src/lib.rs               |  94 +++
 proxmox-wireguard/Cargo.toml           |  28 +
 proxmox-wireguard/debian/changelog     |   5 +
 proxmox-wireguard/debian/control       |  86 +++
 proxmox-wireguard/debian/copyright     |  18 +
 proxmox-wireguard/debian/debcargo.toml |   7 +
 proxmox-wireguard/src/lib.rs           | 408 +++++++++++
 14 files changed, 1729 insertions(+), 2 deletions(-)
 create mode 100644 proxmox-network-types/src/endpoint.rs
 create mode 100644 proxmox-serde/src/ini.rs
 create mode 100644 proxmox-wireguard/Cargo.toml
 create mode 100644 proxmox-wireguard/debian/changelog
 create mode 100644 proxmox-wireguard/debian/control
 create mode 100644 proxmox-wireguard/debian/copyright
 create mode 100644 proxmox-wireguard/debian/debcargo.toml
 create mode 100644 proxmox-wireguard/src/lib.rs

-- 
2.52.0




^ permalink raw reply	[flat|nested] 9+ messages in thread

* [PATCH proxmox v2 1/8] serde: implement ini serializer
  2026-02-13 14:35 [PATCH proxmox v2 0/8] sdn: add wireguard fabric configuration support Christoph Heiss
@ 2026-02-13 14:35 ` Christoph Heiss
  2026-02-13 14:35 ` [PATCH proxmox v2 2/8] serde: add base64 module for byte arrays Christoph Heiss
                   ` (6 subsequent siblings)
  7 siblings, 0 replies; 9+ messages in thread
From: Christoph Heiss @ 2026-02-13 14:35 UTC (permalink / raw)
  To: pve-devel

The official WireGuard tooling wg(8) uses a (mostly) INI-like format
for consuming configuration.

E.g. `wg syncconf` will be used by in the future by the WireGuard fabric
for applying changes to a particular WireGuard interface.

One of the quirks of the INI format used by wg(8) are that there can be
multiple sections with the same name, which is also explicitly supported
by this serializer.

Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
Changes v1 -> v2:
  * use correct version of the `pretty-assertions` crate

 Cargo.toml                   |   1 +
 proxmox-serde/Cargo.toml     |   2 +
 proxmox-serde/debian/control |   4 +
 proxmox-serde/src/ini.rs     | 901 +++++++++++++++++++++++++++++++++++
 proxmox-serde/src/lib.rs     |   3 +
 5 files changed, 911 insertions(+)
 create mode 100644 proxmox-serde/src/ini.rs

diff --git a/Cargo.toml b/Cargo.toml
index 6ce4d5ec..f650a9f7 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -114,6 +114,7 @@ openssl = "0.10"
 pam-sys = "0.5"
 percent-encoding = "2.1"
 pin-utils = "0.1.0"
+pretty_assertions = "1.4"
 proc-macro2 = "1.0"
 quick-xml = "0.36.1"
 quote = "1.0"
diff --git a/proxmox-serde/Cargo.toml b/proxmox-serde/Cargo.toml
index 78d733d0..d4f9fe43 100644
--- a/proxmox-serde/Cargo.toml
+++ b/proxmox-serde/Cargo.toml
@@ -20,6 +20,8 @@ proxmox-time.workspace = true
 
 [dev-dependencies]
 serde_json.workspace = true
+pretty_assertions.workspace = true
 
 [features]
 perl = []
+ini-ser = []
diff --git a/proxmox-serde/debian/control b/proxmox-serde/debian/control
index 62a5033a..d956d54b 100644
--- a/proxmox-serde/debian/control
+++ b/proxmox-serde/debian/control
@@ -36,15 +36,19 @@ Suggests:
  librust-proxmox-serde+serde-json-dev (= ${binary:Version})
 Provides:
  librust-proxmox-serde+default-dev (= ${binary:Version}),
+ librust-proxmox-serde+ini-ser-dev (= ${binary:Version}),
  librust-proxmox-serde+perl-dev (= ${binary:Version}),
  librust-proxmox-serde-1-dev (= ${binary:Version}),
  librust-proxmox-serde-1+default-dev (= ${binary:Version}),
+ librust-proxmox-serde-1+ini-ser-dev (= ${binary:Version}),
  librust-proxmox-serde-1+perl-dev (= ${binary:Version}),
  librust-proxmox-serde-1.0-dev (= ${binary:Version}),
  librust-proxmox-serde-1.0+default-dev (= ${binary:Version}),
+ librust-proxmox-serde-1.0+ini-ser-dev (= ${binary:Version}),
  librust-proxmox-serde-1.0+perl-dev (= ${binary:Version}),
  librust-proxmox-serde-1.0.1-dev (= ${binary:Version}),
  librust-proxmox-serde-1.0.1+default-dev (= ${binary:Version}),
+ librust-proxmox-serde-1.0.1+ini-ser-dev (= ${binary:Version}),
  librust-proxmox-serde-1.0.1+perl-dev (= ${binary:Version})
 Description: Serde formatting tools - Rust source code
  Source code for Debianized Rust crate "proxmox-serde"
diff --git a/proxmox-serde/src/ini.rs b/proxmox-serde/src/ini.rs
new file mode 100644
index 00000000..3141e6df
--- /dev/null
+++ b/proxmox-serde/src/ini.rs
@@ -0,0 +1,901 @@
+//! Implements a serde serializer for the INI file format.
+//!
+//! Nested structs/maps are supported and use the widely used variant of using dots as hierarchy
+//! separators.
+//!
+//! Newtype variants, tuple variants and struct variants are not supported.
+
+use std::{
+    collections::BTreeMap,
+    fmt::{self, Display, Write},
+    io,
+};
+
+use serde::{
+    de,
+    ser::{self, Impossible, Serialize},
+};
+
+/// Errors that can occur during INI serialization.
+#[derive(Debug, PartialEq)]
+pub enum Error {
+    Message(String),
+    UnsupportedType(&'static str),
+    ExpectedKey,
+}
+
+impl fmt::Display for Error {
+    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+        match self {
+            Error::Message(s) => write!(f, "{s}"),
+            Error::UnsupportedType(s) => write!(f, "unsupported data type: {s}"),
+            Error::ExpectedKey => write!(f, "expected key"),
+        }
+    }
+}
+
+impl std::error::Error for Error {}
+
+impl ser::Error for Error {
+    fn custom<T: fmt::Display>(msg: T) -> Self {
+        Error::Message(msg.to_string())
+    }
+}
+
+impl de::Error for Error {
+    fn custom<T: fmt::Display>(msg: T) -> Self {
+        Error::Message(msg.to_string())
+    }
+}
+
+impl From<io::Error> for Error {
+    fn from(err: io::Error) -> Self {
+        Self::Message(err.to_string())
+    }
+}
+
+impl From<fmt::Error> for Error {
+    fn from(err: fmt::Error) -> Self {
+        Self::Message(err.to_string())
+    }
+}
+
+/// Return type used throughout the serializer.
+pub type Result<T> = std::result::Result<T, Error>;
+
+/// Implements a serde serializer for transforming Rust values into the INI
+/// format.
+struct IniSerializer {
+    /// Last key observed during serialization
+    last_key: Option<String>,
+    /// Key-value pairs on this level of the serialization tree
+    kvs: String,
+    /// Nested sections under this part of the tree. Multiple sections with the
+    /// same name are allowed.
+    sections: BTreeMap<String, Vec<IniSerializer>>,
+}
+
+impl IniSerializer {
+    /// Creates a new INI serializer.
+    fn new() -> Self {
+        IniSerializer {
+            last_key: None,
+            kvs: String::new(),
+            sections: BTreeMap::new(),
+        }
+    }
+
+    /// Write out the serialized INI to a target implementing [`io::Write`].
+    fn write<W: io::Write>(self, mut w: W) -> Result<()> {
+        write!(w, "{}", self.kvs)?;
+
+        let mut is_first_byte = self.kvs.is_empty();
+        self.sections.iter().try_for_each(|(name, sections)| {
+            Self::write_nested(&mut w, name, sections, &mut is_first_byte)
+        })
+    }
+
+    /// Internal, recursive method for writing out serialized INI to a target implementing [`io::Write`].
+    fn write_nested<W: io::Write>(
+        w: &mut W,
+        name: &str,
+        sections: &[IniSerializer],
+        is_first_byte: &mut bool,
+    ) -> Result<()> {
+        for section in sections {
+            if !section.kvs.is_empty() {
+                if !*is_first_byte {
+                    writeln!(w)?;
+                }
+
+                write!(w, "[{name}]\n{}", section.kvs)?;
+                *is_first_byte = false;
+            }
+
+            section.sections.iter().try_for_each(|(secname, secs)| {
+                Self::write_nested(w, &format!("{name}.{secname}"), secs, is_first_byte)
+            })?;
+        }
+
+        Ok(())
+    }
+
+    /// Serializes a single key-value pair, expecting some previously picked up key.
+    fn serialize_value_as_string<T: Display>(&mut self, v: T) -> Result<()> {
+        let key = self.last_key.take().ok_or(Error::ExpectedKey)?;
+        writeln!(&mut self.kvs, "{key} = {v}")?;
+        Ok(())
+    }
+}
+
+/// Serialize the given data structure as INI into an I/O stream.
+pub fn to_writer<W, T>(writer: W, value: &T) -> Result<()>
+where
+    W: io::Write,
+    T: ?Sized + Serialize,
+{
+    let mut ser = IniSerializer::new();
+    value.serialize(&mut ser)?;
+    ser.write(writer)
+}
+
+/// Serialize the given data structure as INI into a string.
+pub fn to_string<T>(value: &T) -> Result<String>
+where
+    T: ?Sized + Serialize,
+{
+    let mut buf = Vec::new();
+    to_writer(&mut buf, value)?;
+
+    String::from_utf8(buf).map_err(|err| Error::Message(err.to_string()))
+}
+
+impl<'a> ser::Serializer for &'a mut IniSerializer {
+    type Ok = ();
+    type Error = Error;
+
+    type SerializeSeq = IniSeqSerializer<'a>;
+    type SerializeTuple = IniSeqSerializer<'a>;
+    type SerializeTupleStruct = IniSeqSerializer<'a>;
+    type SerializeTupleVariant = Impossible<Self::Ok, Self::Error>;
+    type SerializeMap = Self;
+    type SerializeStruct = Self;
+    type SerializeStructVariant = Impossible<Self::Ok, Self::Error>;
+
+    fn serialize_bool(self, v: bool) -> Result<Self::Ok> {
+        self.serialize_value_as_string(v)
+    }
+
+    fn serialize_i8(self, v: i8) -> Result<Self::Ok> {
+        self.serialize_value_as_string(v)
+    }
+
+    fn serialize_i16(self, v: i16) -> Result<Self::Ok> {
+        self.serialize_value_as_string(v)
+    }
+
+    fn serialize_i32(self, v: i32) -> Result<Self::Ok> {
+        self.serialize_value_as_string(v)
+    }
+
+    fn serialize_i64(self, v: i64) -> Result<Self::Ok> {
+        self.serialize_value_as_string(v)
+    }
+
+    fn serialize_u8(self, v: u8) -> Result<Self::Ok> {
+        self.serialize_value_as_string(v)
+    }
+
+    fn serialize_u16(self, v: u16) -> Result<Self::Ok> {
+        self.serialize_value_as_string(v)
+    }
+
+    fn serialize_u32(self, v: u32) -> Result<Self::Ok> {
+        self.serialize_value_as_string(v)
+    }
+
+    fn serialize_u64(self, v: u64) -> Result<Self::Ok> {
+        self.serialize_value_as_string(v)
+    }
+
+    fn serialize_f32(self, v: f32) -> Result<Self::Ok> {
+        self.serialize_value_as_string(v)
+    }
+
+    fn serialize_f64(self, v: f64) -> Result<Self::Ok> {
+        self.serialize_value_as_string(v)
+    }
+
+    fn serialize_char(self, v: char) -> Result<Self::Ok> {
+        self.serialize_value_as_string(v)
+    }
+
+    fn serialize_str(self, v: &str) -> Result<Self::Ok> {
+        if self.last_key.is_none() {
+            self.last_key = Some(v.to_owned());
+            Ok(())
+        } else {
+            self.serialize_value_as_string(v)
+        }
+    }
+
+    fn serialize_bytes(self, _: &[u8]) -> Result<Self::Ok> {
+        Err(Error::UnsupportedType("raw bytes"))
+    }
+
+    fn serialize_none(self) -> Result<Self::Ok> {
+        self.serialize_value_as_string("")
+    }
+
+    fn serialize_some<T>(self, v: &T) -> Result<Self::Ok>
+    where
+        T: ?Sized + Serialize,
+    {
+        v.serialize(self)
+    }
+
+    fn serialize_unit(self) -> Result<Self::Ok> {
+        self.last_key = None;
+        Ok(())
+    }
+
+    fn serialize_unit_struct(self, _name: &'static str) -> Result<Self::Ok> {
+        self.last_key = None;
+        self.serialize_unit()
+    }
+
+    fn serialize_unit_variant(
+        self,
+        _name: &'static str,
+        _variant_index: u32,
+        variant: &'static str,
+    ) -> Result<Self::Ok> {
+        self.serialize_str(variant)
+    }
+
+    fn serialize_newtype_struct<T>(self, _name: &'static str, value: &T) -> Result<Self::Ok>
+    where
+        T: ?Sized + Serialize,
+    {
+        value.serialize(self)
+    }
+
+    // Serializes as externally tagged representation.
+    fn serialize_newtype_variant<T>(
+        self,
+        _name: &'static str,
+        _variant_index: u32,
+        _variant: &'static str,
+        _value: &T,
+    ) -> Result<Self::Ok>
+    where
+        T: ?Sized + Serialize,
+    {
+        Err(Error::UnsupportedType("enum newtype variant"))
+    }
+
+    fn serialize_seq(self, _len: Option<usize>) -> Result<Self::SerializeSeq> {
+        Ok(IniSeqSerializer::new(self.last_key.take(), self))
+    }
+
+    fn serialize_tuple(self, len: usize) -> Result<Self::SerializeTuple> {
+        self.serialize_seq(Some(len))
+    }
+
+    fn serialize_tuple_struct(
+        self,
+        _name: &'static str,
+        len: usize,
+    ) -> Result<Self::SerializeTupleStruct> {
+        self.serialize_seq(Some(len))
+    }
+
+    // Serializes as externally tagged representation.
+    fn serialize_tuple_variant(
+        self,
+        _name: &'static str,
+        _variant_index: u32,
+        _variant: &'static str,
+        _len: usize,
+    ) -> Result<Self::SerializeTupleVariant> {
+        Err(Error::UnsupportedType("enum tuple variant"))
+    }
+
+    fn serialize_map(self, _len: Option<usize>) -> Result<Self::SerializeMap> {
+        Ok(self)
+    }
+
+    fn serialize_struct(self, _name: &'static str, len: usize) -> Result<Self::SerializeStruct> {
+        self.serialize_map(Some(len))
+    }
+
+    fn serialize_struct_variant(
+        self,
+        _name: &'static str,
+        _variant_index: u32,
+        _variant: &'static str,
+        _len: usize,
+    ) -> Result<Self::SerializeStructVariant> {
+        Err(Error::UnsupportedType("enum struct variant"))
+    }
+}
+
+impl ser::SerializeMap for &'_ mut IniSerializer {
+    type Ok = ();
+    type Error = Error;
+
+    fn serialize_key<T>(&mut self, key: &T) -> Result<()>
+    where
+        T: ?Sized + Serialize,
+    {
+        let mut buf = String::new();
+        self.last_key = None;
+        key.serialize(&mut IniValueSerializer::new(&mut buf, self))?;
+        self.last_key = Some(buf);
+
+        Ok(())
+    }
+
+    fn serialize_value<T>(&mut self, value: &T) -> Result<()>
+    where
+        T: ?Sized + Serialize,
+    {
+        let key = self.last_key.clone().ok_or(Error::ExpectedKey)?;
+        let mut buf = String::new();
+
+        if value
+            .serialize(&mut IniValueSerializer::new(&mut buf, self))
+            .is_ok()
+        {
+            // Value serialized as a primitive type, so write it out
+            if !buf.is_empty() {
+                writeln!(self.kvs, "{buf}")?;
+            }
+            self.last_key = None;
+        } else {
+            // Otherwise its a struct or map, so do a recursive serialization
+            let mut serializer = IniSerializer::new();
+            serializer.last_key = Some(key.clone());
+            value.serialize(&mut serializer)?;
+
+            self.sections.entry(key).or_default().push(serializer);
+        }
+
+        Ok(())
+    }
+
+    fn end(self) -> Result<()> {
+        self.last_key = None;
+        Ok(())
+    }
+}
+
+impl ser::SerializeStruct for &'_ mut IniSerializer {
+    type Ok = ();
+    type Error = Error;
+
+    fn serialize_field<T>(&mut self, key: &'static str, value: &T) -> Result<()>
+    where
+        T: ?Sized + Serialize,
+    {
+        ser::SerializeMap::serialize_key(self, key)?;
+        ser::SerializeMap::serialize_value(self, value)
+    }
+
+    fn end(self) -> Result<()> {
+        ser::SerializeMap::end(self)
+    }
+}
+
+struct IniSeqSerializer<'a> {
+    name: Option<String>,
+    ser: &'a mut IniSerializer,
+    kvs: String,
+}
+
+impl<'a> IniSeqSerializer<'a> {
+    pub fn new(name: Option<String>, ser: &'a mut IniSerializer) -> Self {
+        Self {
+            name,
+            ser,
+            kvs: String::new(),
+        }
+    }
+}
+
+impl ser::SerializeSeq for IniSeqSerializer<'_> {
+    type Ok = ();
+    type Error = Error;
+
+    fn serialize_element<T>(&mut self, value: &T) -> Result<()>
+    where
+        T: ?Sized + Serialize,
+    {
+        let mut buf = String::new();
+        if value
+            .serialize(&mut IniValueSerializer::new(&mut buf, self.ser))
+            .is_ok()
+        {
+            // Value serialized as a primitive type, so write it out
+            if let Some(name) = &self.name.take() {
+                write!(self.kvs, "{name} = ")?;
+            } else {
+                write!(self.kvs, ", ")?;
+            }
+            write!(self.kvs, "{buf}")?;
+        } else if let Some(name) = &self.name {
+            // Otherwise its a struct or map, so do a recursive serialization
+            let mut serializer = IniSerializer::new();
+            value.serialize(&mut serializer)?;
+
+            self.ser
+                .sections
+                .entry(name.clone())
+                .or_default()
+                .push(serializer)
+        } else {
+            return Err(Error::Message(
+                "got non-primitive value but no name!".into(),
+            ));
+        }
+
+        Ok(())
+    }
+
+    fn end(self) -> Result<()> {
+        if !self.kvs.is_empty() {
+            Ok(writeln!(self.ser.kvs, "{}", self.kvs)?)
+        } else {
+            Ok(())
+        }
+    }
+}
+
+impl ser::SerializeTuple for IniSeqSerializer<'_> {
+    type Ok = ();
+    type Error = Error;
+
+    fn serialize_element<T>(&mut self, value: &T) -> Result<()>
+    where
+        T: ?Sized + Serialize,
+    {
+        ser::SerializeSeq::serialize_element(self, value)
+    }
+
+    fn end(self) -> Result<()> {
+        ser::SerializeSeq::end(self)
+    }
+}
+
+impl ser::SerializeTupleStruct for IniSeqSerializer<'_> {
+    type Ok = ();
+    type Error = Error;
+
+    fn serialize_field<T>(&mut self, value: &T) -> Result<()>
+    where
+        T: ?Sized + Serialize,
+    {
+        ser::SerializeSeq::serialize_element(self, value)
+    }
+
+    fn end(self) -> Result<()> {
+        ser::SerializeSeq::end(self)
+    }
+}
+
+struct IniValueSerializer<'a, W: fmt::Write> {
+    writer: &'a mut W,
+    ser: &'a mut IniSerializer,
+}
+
+impl<'a, W: fmt::Write> IniValueSerializer<'a, W> {
+    fn new(writer: &'a mut W, ser: &'a mut IniSerializer) -> Self {
+        Self { writer, ser }
+    }
+}
+
+impl<W: fmt::Write> IniValueSerializer<'_, W> {
+    fn serialize_value<T: Display>(&mut self, v: T) -> Result<()> {
+        if let Some(key) = self.ser.last_key.take() {
+            write!(self.writer, "{key} = ")?;
+        }
+
+        Ok(write!(self.writer, "{v}")?)
+    }
+}
+
+impl<'a, W: fmt::Write> ser::Serializer for &'a mut IniValueSerializer<'a, W> {
+    type Ok = ();
+    type Error = Error;
+
+    type SerializeSeq = IniSeqSerializer<'a>;
+    type SerializeTuple = IniSeqSerializer<'a>;
+    type SerializeTupleStruct = IniSeqSerializer<'a>;
+    type SerializeTupleVariant = Impossible<Self::Ok, Self::Error>;
+    type SerializeMap = Impossible<Self::Ok, Self::Error>;
+    type SerializeStruct = Impossible<Self::Ok, Self::Error>;
+    type SerializeStructVariant = Impossible<Self::Ok, Self::Error>;
+
+    fn serialize_bool(self, v: bool) -> Result<Self::Ok> {
+        self.serialize_value(v)
+    }
+
+    fn serialize_i8(self, v: i8) -> Result<Self::Ok> {
+        self.serialize_value(v)
+    }
+
+    fn serialize_i16(self, v: i16) -> Result<Self::Ok> {
+        self.serialize_value(v)
+    }
+
+    fn serialize_i32(self, v: i32) -> Result<Self::Ok> {
+        self.serialize_value(v)
+    }
+
+    fn serialize_i64(self, v: i64) -> Result<Self::Ok> {
+        self.serialize_value(v)
+    }
+
+    fn serialize_u8(self, v: u8) -> Result<Self::Ok> {
+        self.serialize_value(v)
+    }
+
+    fn serialize_u16(self, v: u16) -> Result<Self::Ok> {
+        self.serialize_value(v)
+    }
+
+    fn serialize_u32(self, v: u32) -> Result<Self::Ok> {
+        self.serialize_value(v)
+    }
+
+    fn serialize_u64(self, v: u64) -> Result<Self::Ok> {
+        self.serialize_value(v)
+    }
+
+    fn serialize_f32(self, v: f32) -> Result<Self::Ok> {
+        self.serialize_value(v)
+    }
+
+    fn serialize_f64(self, v: f64) -> Result<Self::Ok> {
+        self.serialize_value(v)
+    }
+
+    fn serialize_char(self, v: char) -> Result<Self::Ok> {
+        self.serialize_value(v)
+    }
+
+    fn serialize_str(self, v: &str) -> Result<Self::Ok> {
+        self.serialize_value(v)
+    }
+
+    fn serialize_bytes(self, _v: &[u8]) -> Result<Self::Ok> {
+        Err(Error::UnsupportedType("raw bytes"))
+    }
+
+    fn serialize_none(self) -> Result<Self::Ok> {
+        self.serialize_value("")
+    }
+
+    fn serialize_some<T>(self, v: &T) -> Result<Self::Ok>
+    where
+        T: ?Sized + Serialize,
+    {
+        v.serialize(self)
+    }
+
+    fn serialize_unit(self) -> Result<Self::Ok> {
+        self.serialize_value("")
+    }
+
+    fn serialize_unit_struct(self, _name: &'static str) -> Result<Self::Ok> {
+        self.serialize_unit()
+    }
+
+    fn serialize_unit_variant(
+        self,
+        _name: &'static str,
+        _variant_index: u32,
+        variant: &'static str,
+    ) -> Result<Self::Ok> {
+        self.serialize_str(variant)
+    }
+
+    fn serialize_newtype_struct<T>(self, _name: &'static str, value: &T) -> Result<Self::Ok>
+    where
+        T: ?Sized + Serialize,
+    {
+        value.serialize(self)
+    }
+
+    // Serializes as externally tagged representation.
+    fn serialize_newtype_variant<T>(
+        self,
+        _name: &'static str,
+        _variant_index: u32,
+        _variant: &'static str,
+        _value: &T,
+    ) -> Result<Self::Ok>
+    where
+        T: ?Sized + Serialize,
+    {
+        Err(Error::UnsupportedType("newtype variant"))
+    }
+
+    fn serialize_seq(self, _len: Option<usize>) -> Result<Self::SerializeSeq> {
+        Ok(IniSeqSerializer::new(self.ser.last_key.take(), self.ser))
+    }
+
+    fn serialize_tuple(self, _len: usize) -> Result<Self::SerializeTuple> {
+        Ok(IniSeqSerializer::new(self.ser.last_key.take(), self.ser))
+    }
+
+    fn serialize_tuple_struct(
+        self,
+        _name: &'static str,
+        _len: usize,
+    ) -> Result<Self::SerializeTupleStruct> {
+        Ok(IniSeqSerializer::new(self.ser.last_key.take(), self.ser))
+    }
+
+    // Serializes as externally tagged representation.
+    fn serialize_tuple_variant(
+        self,
+        _name: &'static str,
+        _variant_index: u32,
+        _variant: &'static str,
+        _len: usize,
+    ) -> Result<Self::SerializeTupleVariant> {
+        Err(Error::UnsupportedType("tuple variant"))
+    }
+
+    fn serialize_map(self, _len: Option<usize>) -> Result<Self::SerializeMap> {
+        Err(Error::UnsupportedType("nested maps"))
+    }
+
+    fn serialize_struct(self, _name: &'static str, _len: usize) -> Result<Self::SerializeStruct> {
+        Err(Error::UnsupportedType("nested structs"))
+    }
+
+    fn serialize_struct_variant(
+        self,
+        _name: &'static str,
+        _variant_index: u32,
+        _variant: &'static str,
+        _len: usize,
+    ) -> Result<Self::SerializeStructVariant> {
+        Err(Error::UnsupportedType("struct variant"))
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use std::marker::PhantomData;
+
+    use super::{to_string, Error};
+    use serde::Serialize;
+
+    #[test]
+    fn all_supported_types() {
+        #[derive(Serialize)]
+        struct NestedStruct {
+            s: &'static str,
+            x: f64,
+            l: Vec<&'static str>,
+        }
+
+        #[derive(Serialize)]
+        enum Enum {
+            A,
+        }
+
+        #[derive(Serialize)]
+        struct NewtypeStruct(u8);
+
+        #[derive(Serialize)]
+        struct TopLevel {
+            a: u32,
+            nested: NestedStruct,
+            none: Option<i32>,
+            some: Option<i32>,
+            bytes: [u8; 3],
+            unit: (),
+            unit_struct: PhantomData<u32>,
+            unit_variant: Enum,
+            newtype_struct: NewtypeStruct,
+        }
+
+        let serialized = to_string(&TopLevel {
+            a: 1,
+            nested: NestedStruct {
+                s: "foo",
+                x: 123.4567,
+                l: vec!["a", "b", "c"],
+            },
+            none: None,
+            some: Some(42),
+            bytes: [1, 2, 3],
+            unit: (),
+            unit_struct: PhantomData,
+            unit_variant: Enum::A,
+            newtype_struct: NewtypeStruct(42),
+        })
+        .unwrap();
+
+        eprintln!("{}", &serialized);
+        pretty_assertions::assert_eq!(
+            "a = 1
+none =\x20
+some = 42
+bytes = 1, 2, 3
+unit =\x20
+unit_struct =\x20
+unit_variant = A
+newtype_struct = 42
+
+[nested]
+s = foo
+x = 123.4567
+l = a, b, c
+",
+            serialized,
+        );
+    }
+
+    #[test]
+    fn two_levels_nested() {
+        #[derive(Serialize)]
+        struct SecondLevel {
+            x: u32,
+        }
+
+        #[derive(Serialize)]
+        struct FirstLevel {
+            b: f32,
+            second_level: SecondLevel,
+        }
+
+        #[derive(Serialize)]
+        struct NestedStruct {
+            s: &'static str,
+        }
+
+        #[derive(Serialize)]
+        struct TopLevel {
+            a: u32,
+            nested: NestedStruct,
+            first_level: FirstLevel,
+        }
+
+        let serialized = to_string(&TopLevel {
+            a: 1,
+            nested: NestedStruct { s: "foo" },
+            first_level: FirstLevel {
+                b: 12.3,
+                second_level: SecondLevel { x: 100 },
+            },
+        })
+        .unwrap();
+
+        pretty_assertions::assert_eq!(
+            "a = 1
+
+[first_level]
+b = 12.3
+
+[first_level.second_level]
+x = 100
+
+[nested]
+s = foo
+",
+            serialized,
+        );
+    }
+
+    #[test]
+    fn no_top_level_kvs() {
+        #[derive(Serialize)]
+        struct NestedStruct {
+            s: &'static str,
+        }
+
+        #[derive(Serialize)]
+        struct TopLevel {
+            a: NestedStruct,
+            b: NestedStruct,
+        }
+
+        let serialized = to_string(&TopLevel {
+            a: NestedStruct { s: "foo" },
+            b: NestedStruct { s: "bar" },
+        })
+        .unwrap();
+
+        pretty_assertions::assert_eq!(
+            "[a]
+s = foo
+
+[b]
+s = bar
+",
+            serialized,
+        );
+    }
+
+    #[test]
+    fn unsupported_datatypes() {
+        let a = 1u32;
+        assert_eq!(to_string(&a), Err(Error::ExpectedKey));
+
+        #[derive(Serialize)]
+        enum Enum {
+            A(u32),
+            B(u32, f32),
+            C { a: u8, b: &'static str },
+        }
+
+        #[derive(Serialize)]
+        struct TopLevel {
+            x: Enum,
+        }
+
+        assert_eq!(
+            to_string(&TopLevel { x: Enum::A(1) }),
+            Err(Error::UnsupportedType("enum newtype variant"))
+        );
+
+        assert_eq!(
+            to_string(&TopLevel { x: Enum::B(1, 2.) }),
+            Err(Error::UnsupportedType("enum tuple variant"))
+        );
+
+        assert_eq!(
+            to_string(&TopLevel {
+                x: Enum::C {
+                    a: 100,
+                    b: "foobar"
+                }
+            }),
+            Err(Error::UnsupportedType("enum struct variant"))
+        );
+    }
+
+    #[test]
+    fn multiple_sections_with_same_name() {
+        #[derive(Serialize)]
+        struct NestedStruct {
+            x: u32,
+        }
+
+        #[derive(Serialize)]
+        struct TopLevel {
+            a: u32,
+            nested: Vec<NestedStruct>,
+        }
+
+        let serialized = to_string(&TopLevel {
+            a: 42,
+            nested: vec![
+                NestedStruct { x: 1 },
+                NestedStruct { x: 2 },
+                NestedStruct { x: 3 },
+            ],
+        })
+        .unwrap();
+
+        pretty_assertions::assert_eq!(
+            "a = 42
+
+[nested]
+x = 1
+
+[nested]
+x = 2
+
+[nested]
+x = 3
+",
+            serialized,
+        );
+    }
+}
diff --git a/proxmox-serde/src/lib.rs b/proxmox-serde/src/lib.rs
index c16f4efb..28c3054d 100644
--- a/proxmox-serde/src/lib.rs
+++ b/proxmox-serde/src/lib.rs
@@ -11,6 +11,9 @@ pub mod json;
 #[cfg(feature = "perl")]
 pub mod perl;
 
+#[cfg(feature = "ini-ser")]
+pub mod ini;
+
 /// Serialize Unix epoch (i64) as RFC3339.
 ///
 /// Usage example:
-- 
2.52.0





^ permalink raw reply	[flat|nested] 9+ messages in thread

* [PATCH proxmox v2 2/8] serde: add base64 module for byte arrays
  2026-02-13 14:35 [PATCH proxmox v2 0/8] sdn: add wireguard fabric configuration support Christoph Heiss
  2026-02-13 14:35 ` [PATCH proxmox v2 1/8] serde: implement ini serializer Christoph Heiss
@ 2026-02-13 14:35 ` Christoph Heiss
  2026-02-13 14:35 ` [PATCH proxmox v2 3/8] network-types: add ServiceEndpoint type as host/port tuple abstraction Christoph Heiss
                   ` (5 subsequent siblings)
  7 siblings, 0 replies; 9+ messages in thread
From: Christoph Heiss @ 2026-02-13 14:35 UTC (permalink / raw)
  To: pve-devel

Allows to directly en-/decode [u8; N] to/from a base64 string, much like
the already existing bytes_as_base64 allows for Vec<u8>.

Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
Changes v1 -> v2:
  * no changes

 proxmox-serde/src/lib.rs | 91 ++++++++++++++++++++++++++++++++++++++++
 1 file changed, 91 insertions(+)

diff --git a/proxmox-serde/src/lib.rs b/proxmox-serde/src/lib.rs
index 28c3054d..c3ec4f41 100644
--- a/proxmox-serde/src/lib.rs
+++ b/proxmox-serde/src/lib.rs
@@ -159,3 +159,94 @@ pub mod string_as_base64 {
         <T as StrAsBase64>::de::<'de, D>(deserializer)
     }
 }
+
+/// Serialize `[u8; N]` or `Option<[u8; N]>` as base64 encoded.
+///
+/// If you do not need the convenience of handling both [u8; N] and Option transparently, you could
+/// also use [`proxmox_base64`] directly.
+///
+/// Usage example:
+/// ```
+/// use serde::{Deserialize, Serialize};
+///
+/// #[derive(Debug, Deserialize, PartialEq, Serialize)]
+/// struct Foo {
+///     #[serde(with = "proxmox_serde::byte_array_as_base64")]
+///     data: [u8; 4],
+/// }
+///
+/// let obj = Foo { data: [1, 2, 3, 4] };
+/// let json = serde_json::to_string(&obj).unwrap();
+/// assert_eq!(json, r#"{"data":"AQIDBA=="}"#);
+///
+/// let deserialized: Foo = serde_json::from_str(&json).unwrap();
+/// assert_eq!(obj, deserialized);
+/// ```
+pub mod byte_array_as_base64 {
+    use serde::{Deserialize, Deserializer, Serializer};
+
+    /// Private trait to enable `byte_array_as_base64` for `Option<[u8; N]>` in addition to `[u8; N]`.
+    #[doc(hidden)]
+    pub trait ByteArrayAsBase64<const N: usize>: Sized {
+        fn ser<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error>;
+        fn de<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error>;
+    }
+
+    fn finish_deserializing<'de, const N: usize, D: Deserializer<'de>>(
+        string: String,
+    ) -> Result<[u8; N], D::Error> {
+        use serde::de::Error;
+
+        let vec = proxmox_base64::decode(string).map_err(|err| {
+            let msg = format!("base64 decode: {}", err);
+            Error::custom(msg)
+        })?;
+
+        vec.as_slice().try_into().map_err(|_| {
+            let msg = format!("expected {N} bytes, got {}", vec.len());
+            Error::custom(msg)
+        })
+    }
+
+    impl<const N: usize> ByteArrayAsBase64<N> for [u8; N] {
+        fn ser<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
+            serializer.serialize_str(&proxmox_base64::encode(self))
+        }
+
+        fn de<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
+            finish_deserializing::<'de, N, D>(String::deserialize(deserializer)?)
+        }
+    }
+
+    impl<const N: usize> ByteArrayAsBase64<N> for Option<[u8; N]> {
+        fn ser<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
+            match self {
+                Some(s) => Self::ser(&Some(*s), serializer),
+                None => serializer.serialize_none(),
+            }
+        }
+
+        fn de<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
+            match Option::<String>::deserialize(deserializer)? {
+                Some(s) => Ok(Some(finish_deserializing::<'de, N, D>(s)?)),
+                None => Ok(None),
+            }
+        }
+    }
+
+    pub fn serialize<const N: usize, S, T>(data: &T, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: Serializer,
+        T: ByteArrayAsBase64<N>,
+    {
+        <T as ByteArrayAsBase64<N>>::ser(data, serializer)
+    }
+
+    pub fn deserialize<'de, const N: usize, D, T>(deserializer: D) -> Result<T, D::Error>
+    where
+        D: Deserializer<'de>,
+        T: ByteArrayAsBase64<N>,
+    {
+        <T as ByteArrayAsBase64<N>>::de::<'de, D>(deserializer)
+    }
+}
-- 
2.52.0





^ permalink raw reply	[flat|nested] 9+ messages in thread

* [PATCH proxmox v2 3/8] network-types: add ServiceEndpoint type as host/port tuple abstraction
  2026-02-13 14:35 [PATCH proxmox v2 0/8] sdn: add wireguard fabric configuration support Christoph Heiss
  2026-02-13 14:35 ` [PATCH proxmox v2 1/8] serde: implement ini serializer Christoph Heiss
  2026-02-13 14:35 ` [PATCH proxmox v2 2/8] serde: add base64 module for byte arrays Christoph Heiss
@ 2026-02-13 14:35 ` Christoph Heiss
  2026-02-13 14:35 ` [PATCH proxmox v2 4/8] schema: provide integer schema for node ports Christoph Heiss
                   ` (4 subsequent siblings)
  7 siblings, 0 replies; 9+ messages in thread
From: Christoph Heiss @ 2026-02-13 14:35 UTC (permalink / raw)
  To: pve-devel

Basically a composite data type to provide bit of an better abstraction
to a tuple (host, port).

Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
Changes v1 -> v2:
  * forward to `Display` impl directly where possible

 proxmox-network-types/src/endpoint.rs | 154 ++++++++++++++++++++++++++
 proxmox-network-types/src/lib.rs      |   3 +-
 2 files changed, 156 insertions(+), 1 deletion(-)
 create mode 100644 proxmox-network-types/src/endpoint.rs

diff --git a/proxmox-network-types/src/endpoint.rs b/proxmox-network-types/src/endpoint.rs
new file mode 100644
index 00000000..10f6a813
--- /dev/null
+++ b/proxmox-network-types/src/endpoint.rs
@@ -0,0 +1,154 @@
+//! Implements a wrapper around a (host, port) tuple, where host can either
+//! be a plain IP address or a resolvable hostname.
+
+use std::{
+    fmt::{self, Display},
+    net::IpAddr,
+    str::FromStr,
+};
+
+use serde_with::{DeserializeFromStr, SerializeDisplay};
+
+/// Represents either a resolvable hostname or an IPv4/IPv6 address.
+/// IPv6 address are correctly bracketed on [`Display`], and parsing
+/// automatically tries parsing it as an IP address first, falling back to a
+/// plain hostname in the other case.
+#[derive(Clone, Debug, PartialEq)]
+pub enum HostnameOrIpAddr {
+    Hostname(String),
+    IpAddr(IpAddr),
+}
+
+impl Display for HostnameOrIpAddr {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        match self {
+            HostnameOrIpAddr::Hostname(s) => Display::fmt(s, f),
+            HostnameOrIpAddr::IpAddr(addr) => match addr {
+                IpAddr::V4(v4) => Display::fmt(v4, f),
+                IpAddr::V6(v6) => write!(f, "[{v6}]"),
+            },
+        }
+    }
+}
+
+impl<S: Into<String>> From<S> for HostnameOrIpAddr {
+    fn from(value: S) -> Self {
+        let s = value.into();
+        if let Ok(ip_addr) = IpAddr::from_str(&s) {
+            Self::IpAddr(ip_addr)
+        } else {
+            Self::Hostname(s)
+        }
+    }
+}
+
+/// Represents a (host, port) tuple, where the host can either be a resolvable
+/// hostname or an IPv4/IPv6 address.
+#[derive(Clone, Debug, PartialEq, SerializeDisplay, DeserializeFromStr)]
+pub struct ServiceEndpoint {
+    host: HostnameOrIpAddr,
+    port: u16,
+}
+
+impl ServiceEndpoint {
+    pub fn new<S: Into<String>>(host: S, port: u16) -> Self {
+        let s = host.into();
+        Self {
+            host: s.into(),
+            port,
+        }
+    }
+}
+
+impl Display for ServiceEndpoint {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        write!(f, "{}:{}", self.host, self.port)
+    }
+}
+
+#[derive(thiserror::Error, Debug)]
+pub enum ParseError {
+    #[error("host and port must be separated by a colon")]
+    MissingSeparator,
+    #[error("host part missing")]
+    MissingHost,
+    #[error("invalid port: {0}")]
+    InvalidPort(String),
+}
+
+impl FromStr for ServiceEndpoint {
+    type Err = ParseError;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        let (mut host, port) = s.rsplit_once(':').ok_or(Self::Err::MissingSeparator)?;
+
+        if host.is_empty() {
+            return Err(Self::Err::MissingHost);
+        }
+
+        // [ and ] are not valid characters in a hostname, so strip them in case it
+        // is a IPv6 address.
+        host = host.trim_matches(['[', ']']);
+
+        Ok(ServiceEndpoint {
+            host: host.into(),
+            port: port
+                .parse()
+                .map_err(|err: std::num::ParseIntError| Self::Err::InvalidPort(err.to_string()))?,
+        })
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use crate::endpoint::HostnameOrIpAddr;
+
+    use super::ServiceEndpoint;
+
+    #[test]
+    fn display_works() {
+        let s = ServiceEndpoint::new("127.0.0.1", 123);
+        assert_eq!(s.to_string(), "127.0.0.1:123");
+
+        let s = ServiceEndpoint::new("fc00:f00d::4321", 123);
+        assert_eq!(s.to_string(), "[fc00:f00d::4321]:123");
+
+        let s = ServiceEndpoint::new("::", 123);
+        assert_eq!(s.to_string(), "[::]:123");
+
+        let s = ServiceEndpoint::new("fc00::", 123);
+        assert_eq!(s.to_string(), "[fc00::]:123");
+
+        let s = ServiceEndpoint::new("example.com", 123);
+        assert_eq!(s.to_string(), "example.com:123");
+    }
+
+    #[test]
+    fn fromstr_works() {
+        assert_eq!(
+            "127.0.0.1:123".parse::<ServiceEndpoint>().unwrap(),
+            ServiceEndpoint {
+                host: HostnameOrIpAddr::IpAddr([127, 0, 0, 1].into()),
+                port: 123
+            }
+        );
+
+        assert_eq!(
+            "[::1]:123".parse::<ServiceEndpoint>().unwrap(),
+            ServiceEndpoint {
+                host: HostnameOrIpAddr::IpAddr(
+                    [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1].into()
+                ),
+                port: 123
+            }
+        );
+
+        assert_eq!(
+            "example.com:123".parse::<ServiceEndpoint>().unwrap(),
+            ServiceEndpoint {
+                host: HostnameOrIpAddr::Hostname("example.com".to_owned()),
+                port: 123
+            }
+        );
+    }
+}
diff --git a/proxmox-network-types/src/lib.rs b/proxmox-network-types/src/lib.rs
index ee26b1c1..1bacbaf3 100644
--- a/proxmox-network-types/src/lib.rs
+++ b/proxmox-network-types/src/lib.rs
@@ -1,5 +1,6 @@
 #![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))]
-#![deny(unsafe_op_in_unsafe_fn)]
+#![deny(unsafe_code, unsafe_op_in_unsafe_fn)]
 
+pub mod endpoint;
 pub mod ip_address;
 pub mod mac_address;
-- 
2.52.0





^ permalink raw reply	[flat|nested] 9+ messages in thread

* [PATCH proxmox v2 4/8] schema: provide integer schema for node ports
  2026-02-13 14:35 [PATCH proxmox v2 0/8] sdn: add wireguard fabric configuration support Christoph Heiss
                   ` (2 preceding siblings ...)
  2026-02-13 14:35 ` [PATCH proxmox v2 3/8] network-types: add ServiceEndpoint type as host/port tuple abstraction Christoph Heiss
@ 2026-02-13 14:35 ` Christoph Heiss
  2026-02-13 14:35 ` [PATCH proxmox v2 5/8] schema: api-types: add ed25519 base64 encoded key schema Christoph Heiss
                   ` (3 subsequent siblings)
  7 siblings, 0 replies; 9+ messages in thread
From: Christoph Heiss @ 2026-02-13 14:35 UTC (permalink / raw)
  To: pve-devel

Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
Changes v1 -> v2:
  * no changes

 proxmox-schema/src/api_types.rs | 7 ++++++-
 1 file changed, 6 insertions(+), 1 deletion(-)

diff --git a/proxmox-schema/src/api_types.rs b/proxmox-schema/src/api_types.rs
index cfad4a10..3e31d97e 100644
--- a/proxmox-schema/src/api_types.rs
+++ b/proxmox-schema/src/api_types.rs
@@ -1,7 +1,7 @@
 //! The "basic" api types we generally require along with some of their macros.
 use const_format::concatcp;
 
-use crate::{ApiStringFormat, ArraySchema, Schema, StringSchema};
+use crate::{ApiStringFormat, ArraySchema, IntegerSchema, Schema, StringSchema};
 
 #[rustfmt::skip]
 const IPV4OCTET: &str = r"(?:25[0-5]|(?:2[0-4]|1[0-9]|[1-9])?[0-9])";
@@ -221,6 +221,11 @@ pub const HOST_PORT_SCHEMA: Schema =
         .format(&HOST_PORT_FORMAT)
         .schema();
 
+pub const PORT_SCHEMA: Schema = IntegerSchema::new("Node port")
+    .minimum(1)
+    .maximum(65535)
+    .schema();
+
 pub const HTTP_URL_SCHEMA: Schema = StringSchema::new("HTTP(s) url with optional port.")
     .format(&HTTP_URL_FORMAT)
     .schema();
-- 
2.52.0





^ permalink raw reply	[flat|nested] 9+ messages in thread

* [PATCH proxmox v2 5/8] schema: api-types: add ed25519 base64 encoded key schema
  2026-02-13 14:35 [PATCH proxmox v2 0/8] sdn: add wireguard fabric configuration support Christoph Heiss
                   ` (3 preceding siblings ...)
  2026-02-13 14:35 ` [PATCH proxmox v2 4/8] schema: provide integer schema for node ports Christoph Heiss
@ 2026-02-13 14:35 ` Christoph Heiss
  2026-02-13 14:35 ` [PATCH proxmox v2 6/8] wireguard: init configuration support crate Christoph Heiss
                   ` (2 subsequent siblings)
  7 siblings, 0 replies; 9+ messages in thread
From: Christoph Heiss @ 2026-02-13 14:35 UTC (permalink / raw)
  To: pve-devel

Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
Changes v1 -> v2:
  * no changes

 proxmox-schema/src/api_types.rs | 12 ++++++++++++
 1 file changed, 12 insertions(+)

diff --git a/proxmox-schema/src/api_types.rs b/proxmox-schema/src/api_types.rs
index 3e31d97e..d6a0608c 100644
--- a/proxmox-schema/src/api_types.rs
+++ b/proxmox-schema/src/api_types.rs
@@ -122,6 +122,11 @@ const_regex! {
 
     pub BLOCKDEVICE_NAME_REGEX = r"^(?:(?:h|s|x?v)d[a-z]+)|(?:nvme\d+n\d+)$";
     pub BLOCKDEVICE_DISK_AND_PARTITION_NAME_REGEX = r"^(?:(?:h|s|x?v)d[a-z]+\d*)|(?:nvme\d+n\d+(p\d+)?)$";
+
+    /// Regex to match a base64-encoded ED25519 key.
+    /// A ED25519 key has always 32 bytes of raw key material, base64 needs 4 * (n / 3) characters
+    /// to represent n bytes -> 4 * (32 / 3) = 42.6.., thus 43 + 1 padding character.
+    pub ED25519_BASE64_KEY_REGEX =r"^[a-zA-Z0-9+/-]{43}=";
 }
 
 pub const SAFE_ID_FORMAT: ApiStringFormat = ApiStringFormat::Pattern(&SAFE_ID_REGEX);
@@ -289,4 +294,11 @@ fn test_regexes() {
     assert!(IP_BRACKET_REGEX.is_match("[2014:b3a::27]"));
     assert!(IP_BRACKET_REGEX.is_match("[2014:b3a::192.168.0.1]"));
     assert!(IP_BRACKET_REGEX.is_match("[2014:b3a:0102:adf1:1234:4321:4afA:BCDF]"));
+
+    assert!(ED25519_BASE64_KEY_REGEX.is_match("KNpc7alqlLTaWE6RzuzHGioKs7Nqh/z3YxMJojpSelA="));
+    assert!(!ED25519_BASE64_KEY_REGEX.is_match(""));
+    // 31 bytes of data
+    assert!(!ED25519_BASE64_KEY_REGEX.is_match("6zroXbjGs9sdOpr1n/M5hh+UklBxtQ90tGQDnYzJfw=="));
+    // 33 bytes of data
+    assert!(!ED25519_BASE64_KEY_REGEX.is_match("IiC3Nkh4Fn2ukUZUNmdK5K5CWO53Zmk/eGlKO4m6aCD/"));
 }
-- 
2.52.0





^ permalink raw reply	[flat|nested] 9+ messages in thread

* [PATCH proxmox v2 6/8] wireguard: init configuration support crate
  2026-02-13 14:35 [PATCH proxmox v2 0/8] sdn: add wireguard fabric configuration support Christoph Heiss
                   ` (4 preceding siblings ...)
  2026-02-13 14:35 ` [PATCH proxmox v2 5/8] schema: api-types: add ed25519 base64 encoded key schema Christoph Heiss
@ 2026-02-13 14:35 ` Christoph Heiss
  2026-02-13 14:36 ` [PATCH proxmox v2 7/8] wireguard: implement api for PublicKey Christoph Heiss
  2026-02-13 14:36 ` [PATCH proxmox v2 8/8] wireguard: make per-peer preshared key optional Christoph Heiss
  7 siblings, 0 replies; 9+ messages in thread
From: Christoph Heiss @ 2026-02-13 14:35 UTC (permalink / raw)
  To: pve-devel

This introduces a new crate, `proxmox-wireguard`.

It provides:
- a slight abstraction over raw ED25519 keys and keyspairs, as used by
  WireGuard
- keypair generation
- wg(8) configuration support, by providing the necessary structs and
  INI serialization thereof

Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
Changes v1 -> v2:
  * implement `AsRef<{Private,Preshared}Key>` instead of .raw() method
  * scope key generation (and thus proxmox-sys usage) behind a feature
    flag, makes it possible to use this crate in e.g. wasm context
  * expand test cases a bit

 Cargo.toml                             |   1 +
 proxmox-wireguard/Cargo.toml           |  25 ++
 proxmox-wireguard/debian/changelog     |   5 +
 proxmox-wireguard/debian/control       |  67 +++++
 proxmox-wireguard/debian/copyright     |  18 ++
 proxmox-wireguard/debian/debcargo.toml |   7 +
 proxmox-wireguard/src/lib.rs           | 391 +++++++++++++++++++++++++
 7 files changed, 514 insertions(+)
 create mode 100644 proxmox-wireguard/Cargo.toml
 create mode 100644 proxmox-wireguard/debian/changelog
 create mode 100644 proxmox-wireguard/debian/control
 create mode 100644 proxmox-wireguard/debian/copyright
 create mode 100644 proxmox-wireguard/debian/debcargo.toml
 create mode 100644 proxmox-wireguard/src/lib.rs

diff --git a/Cargo.toml b/Cargo.toml
index f650a9f7..40d370ff 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -58,6 +58,7 @@ members = [
     "proxmox-time-api",
     "proxmox-upgrade-checks",
     "proxmox-uuid",
+    "proxmox-wireguard",
     "proxmox-worker-task",
     "pbs-api-types",
     "pve-api-types",
diff --git a/proxmox-wireguard/Cargo.toml b/proxmox-wireguard/Cargo.toml
new file mode 100644
index 00000000..b48ed238
--- /dev/null
+++ b/proxmox-wireguard/Cargo.toml
@@ -0,0 +1,25 @@
+[package]
+name = "proxmox-wireguard"
+description = "WireGuard configuration support"
+version = "0.1.0"
+authors.workspace = true
+edition.workspace = true
+license.workspace = true
+homepage.workspace = true
+exclude.workspace = true
+rust-version.workspace = true
+
+[dependencies]
+ed25519-dalek = "2.1"
+serde = { workspace = true, features = [ "derive" ] }
+thiserror.workspace = true
+proxmox-serde = { workspace = true, features = [ "ini-ser" ] }
+proxmox-network-types.workspace = true
+proxmox-sys = { workspace = true, optional = true }
+
+[dev-dependencies]
+pretty_assertions.workspace = true
+
+[features]
+default = ["key-generation"]
+key-generation = ["dep:proxmox-sys"]
diff --git a/proxmox-wireguard/debian/changelog b/proxmox-wireguard/debian/changelog
new file mode 100644
index 00000000..5a079507
--- /dev/null
+++ b/proxmox-wireguard/debian/changelog
@@ -0,0 +1,5 @@
+rust-proxmox-wireguard (0.1.0-1) unstable; urgency=medium
+
+  * Initial release.
+
+ -- Proxmox Support Team <support@proxmox.com>  Thu, 13 Feb 2026 15:10:12 +0200
diff --git a/proxmox-wireguard/debian/control b/proxmox-wireguard/debian/control
new file mode 100644
index 00000000..9c4c46cd
--- /dev/null
+++ b/proxmox-wireguard/debian/control
@@ -0,0 +1,67 @@
+Source: rust-proxmox-wireguard
+Section: rust
+Priority: optional
+Build-Depends: debhelper-compat (= 13),
+ dh-sequence-cargo
+Build-Depends-Arch: cargo:native <!nocheck>,
+ rustc:native (>= 1.82) <!nocheck>,
+ libstd-rust-dev <!nocheck>,
+ librust-ed25519-dalek-2+default-dev (>= 2.1-~~) <!nocheck>,
+ librust-proxmox-network-types-0.1+default-dev <!nocheck>,
+ librust-proxmox-serde-1+default-dev <!nocheck>,
+ librust-proxmox-serde-1+ini-ser-dev <!nocheck>,
+ librust-proxmox-serde-1+serde-json-dev <!nocheck>,
+ librust-proxmox-sys-1+default-dev <!nocheck>,
+ librust-serde-1+default-dev <!nocheck>,
+ librust-serde-1+derive-dev <!nocheck>,
+ librust-thiserror-2+default-dev <!nocheck>
+Maintainer: Proxmox Support Team <support@proxmox.com>
+Standards-Version: 4.7.2
+Vcs-Git: git://git.proxmox.com/git/proxmox-ve-rs.git
+Vcs-Browser: https://git.proxmox.com/?p=proxmox-ve-rs.git
+Homepage: https://proxmox.com
+X-Cargo-Crate: proxmox-wireguard
+
+Package: librust-proxmox-wireguard-dev
+Architecture: any
+Multi-Arch: same
+Depends:
+ ${misc:Depends},
+ librust-ed25519-dalek-2+default-dev (>= 2.1-~~),
+ librust-proxmox-network-types-0.1+default-dev,
+ librust-proxmox-serde-1+default-dev,
+ librust-proxmox-serde-1+ini-ser-dev,
+ librust-proxmox-serde-1+serde-json-dev,
+ librust-serde-1+default-dev,
+ librust-serde-1+derive-dev,
+ librust-thiserror-2+default-dev
+Recommends:
+ librust-proxmox-wireguard+key-generation-dev (= ${binary:Version})
+Provides:
+ librust-proxmox-wireguard-0-dev (= ${binary:Version}),
+ librust-proxmox-wireguard-0.1-dev (= ${binary:Version}),
+ librust-proxmox-wireguard-0.1.0-dev (= ${binary:Version})
+Description: WireGuard configuration support - Rust source code
+ Source code for Debianized Rust crate "proxmox-wireguard"
+
+Package: librust-proxmox-wireguard+key-generation-dev
+Architecture: any
+Multi-Arch: same
+Depends:
+ ${misc:Depends},
+ librust-proxmox-wireguard-dev (= ${binary:Version}),
+ librust-proxmox-sys-1+default-dev
+Provides:
+ librust-proxmox-wireguard+default-dev (= ${binary:Version}),
+ librust-proxmox-wireguard-0+key-generation-dev (= ${binary:Version}),
+ librust-proxmox-wireguard-0+default-dev (= ${binary:Version}),
+ librust-proxmox-wireguard-0.1+key-generation-dev (= ${binary:Version}),
+ librust-proxmox-wireguard-0.1+default-dev (= ${binary:Version}),
+ librust-proxmox-wireguard-0.1.0+key-generation-dev (= ${binary:Version}),
+ librust-proxmox-wireguard-0.1.0+default-dev (= ${binary:Version})
+Description: WireGuard configuration support - feature "key-generation" and 1 more
+ This metapackage enables feature "key-generation" for the Rust proxmox-
+ wireguard crate, by pulling in any additional dependencies needed by that
+ feature.
+ .
+ Additionally, this package also provides the "default" feature.
diff --git a/proxmox-wireguard/debian/copyright b/proxmox-wireguard/debian/copyright
new file mode 100644
index 00000000..1ea8a56b
--- /dev/null
+++ b/proxmox-wireguard/debian/copyright
@@ -0,0 +1,18 @@
+Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
+
+Files:
+ *
+Copyright: 2019 - 2025 Proxmox Server Solutions GmbH <support@proxmox.com>
+License: AGPL-3.0-or-later
+ This program is free software: you can redistribute it and/or modify it under
+ the terms of the GNU Affero General Public License as published by the Free
+ Software Foundation, either version 3 of the License, or (at your option) any
+ later version.
+ .
+ This program is distributed in the hope that it will be useful, but WITHOUT
+ ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+ FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+ details.
+ .
+ You should have received a copy of the GNU Affero General Public License along
+ with this program. If not, see <https://www.gnu.org/licenses/>.
diff --git a/proxmox-wireguard/debian/debcargo.toml b/proxmox-wireguard/debian/debcargo.toml
new file mode 100644
index 00000000..87a787e6
--- /dev/null
+++ b/proxmox-wireguard/debian/debcargo.toml
@@ -0,0 +1,7 @@
+overlay = "."
+crate_src_path = ".."
+maintainer = "Proxmox Support Team <support@proxmox.com>"
+
+[source]
+vcs_git = "git://git.proxmox.com/git/proxmox-ve-rs.git"
+vcs_browser = "https://git.proxmox.com/?p=proxmox-ve-rs.git"
diff --git a/proxmox-wireguard/src/lib.rs b/proxmox-wireguard/src/lib.rs
new file mode 100644
index 00000000..a3a517b7
--- /dev/null
+++ b/proxmox-wireguard/src/lib.rs
@@ -0,0 +1,391 @@
+//! Implements a interface for handling WireGuard configurations and serializing them into the
+//! INI-style format as described in wg(8) "CONFIGURATION FILE FORMAT".
+//!
+//! WireGuard keys are 32-bytes securely-generated values, encoded as base64
+//! for any usage where users might come in contact with them.
+//!
+//! [`PrivateKey`], [`PublicKey`] and [`PresharedKey`] implement all the needed
+//! key primitives.
+//!
+//! By design there is no key pair, as keys should be treated as opaque from a
+//! configuration perspective and not worked with.
+
+#![forbid(unsafe_code, missing_docs)]
+
+use ed25519_dalek::SigningKey;
+use serde::{Deserialize, Serialize};
+use std::fmt;
+
+use proxmox_network_types::{endpoint::ServiceEndpoint, ip_address::Cidr};
+
+/// Possible error when handling WireGuard configurations.
+#[derive(thiserror::Error, Debug, PartialEq, Clone)]
+pub enum Error {
+    /// (Private) key generation failed
+    #[error("failed to generate private key: {0}")]
+    KeyGenFailed(String),
+    /// Serialization to the WireGuard INI format failed
+    #[error("failed to serialize config: {0}")]
+    SerializationFailed(String),
+}
+
+impl From<proxmox_serde::ini::Error> for Error {
+    fn from(err: proxmox_serde::ini::Error) -> Self {
+        Self::SerializationFailed(err.to_string())
+    }
+}
+
+/// Public key of a WireGuard peer.
+#[derive(Clone, Copy, Deserialize, Serialize, Hash, Debug)]
+#[serde(transparent)]
+pub struct PublicKey(
+    #[serde(with = "proxmox_serde::byte_array_as_base64")] [u8; ed25519_dalek::PUBLIC_KEY_LENGTH],
+);
+
+/// Private key of a WireGuard peer.
+#[derive(Serialize)]
+#[serde(transparent)]
+pub struct PrivateKey(
+    #[serde(with = "proxmox_serde::byte_array_as_base64")] ed25519_dalek::SecretKey,
+);
+
+impl fmt::Debug for PrivateKey {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        write!(f, "<private-key>")
+    }
+}
+
+impl PrivateKey {
+    /// Length of the raw private key data in bytes.
+    pub const RAW_LENGTH: usize = ed25519_dalek::SECRET_KEY_LENGTH;
+
+    /// Generates a new private key suitable for use with WireGuard.
+    #[cfg(feature = "key-generation")]
+    pub fn generate() -> Result<Self, Error> {
+        generate_key().map(Self)
+    }
+
+    /// Calculates the public key from the private key.
+    pub fn public_key(&self) -> PublicKey {
+        PublicKey(
+            ed25519_dalek::SigningKey::from_bytes(&self.0)
+                .verifying_key()
+                .to_bytes(),
+        )
+    }
+
+    /// Builds a new [`PrivateKey`] from raw key material.
+    #[must_use]
+    pub fn from_raw(data: ed25519_dalek::SecretKey) -> Self {
+        // [`SigningKey`] takes care of correct key clamping.
+        Self(SigningKey::from(&data).to_bytes())
+    }
+}
+
+impl From<ed25519_dalek::SecretKey> for PrivateKey {
+    fn from(value: ed25519_dalek::SecretKey) -> Self {
+        Self(value)
+    }
+}
+
+impl AsRef<ed25519_dalek::SecretKey> for PrivateKey {
+    /// Returns the raw private key material.
+    fn as_ref(&self) -> &ed25519_dalek::SecretKey {
+        &self.0
+    }
+}
+
+/// Preshared key between two WireGuard peers.
+#[derive(Clone, Deserialize, Serialize)]
+#[serde(transparent)]
+pub struct PresharedKey(
+    #[serde(with = "proxmox_serde::byte_array_as_base64")] ed25519_dalek::SecretKey,
+);
+
+impl fmt::Debug for PresharedKey {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        write!(f, "<preshared-key>")
+    }
+}
+
+impl PresharedKey {
+    /// Length of the raw private key data in bytes.
+    pub const RAW_LENGTH: usize = ed25519_dalek::SECRET_KEY_LENGTH;
+
+    /// Generates a new preshared key suitable for use with WireGuard.
+    #[cfg(feature = "key-generation")]
+    pub fn generate() -> Result<Self, Error> {
+        generate_key().map(Self)
+    }
+
+    /// Builds a new [`PrivateKey`] from raw key material.
+    #[must_use]
+    pub fn from_raw(data: ed25519_dalek::SecretKey) -> Self {
+        // [`SigningKey`] takes care of correct key clamping.
+        Self(SigningKey::from(&data).to_bytes())
+    }
+}
+
+impl AsRef<ed25519_dalek::SecretKey> for PresharedKey {
+    /// Returns the raw preshared key material.
+    fn as_ref(&self) -> &ed25519_dalek::SecretKey {
+        &self.0
+    }
+}
+
+/// A single WireGuard peer.
+#[derive(Serialize, Debug)]
+#[serde(rename_all = "PascalCase")]
+pub struct WireGuardPeer {
+    /// Public key, matching the private key of of the remote peer.
+    pub public_key: PublicKey,
+    /// Additional key preshared between two peers. Adds an additional layer of symmetric-key
+    /// cryptography to be mixed into the already existing public-key cryptography, for
+    /// post-quantum resistance.
+    pub preshared_key: PresharedKey,
+    /// List of IPv4/v6 CIDRs from which incoming traffic for this peer is allowed and to which
+    /// outgoing traffic for this peer is directed. The catch-all 0.0.0.0/0 may be specified for
+    /// matching all IPv4 addresses, and ::/0 may be specified for matching all IPv6 addresses.
+    #[serde(rename = "AllowedIPs", skip_serializing_if = "Vec::is_empty")]
+    pub allowed_ips: Vec<Cidr>,
+    /// Remote peer endpoint address to connect to. Optional; only needed on the connecting side.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub endpoint: Option<ServiceEndpoint>,
+    /// A seconds interval, between 1 and 65535 inclusive, of how often to send an authenticated
+    /// empty packet to the peer for the purpose of keeping a stateful firewall or NAT mapping
+    /// valid persistently. For example, if the interface very rarely sends traffic, but it might
+    /// at anytime receive traffic from a peer, and it is behind NAT, the interface might benefit
+    /// from having a persistent keepalive interval of 25 seconds. If unset or set to 0, it is
+    /// turned off.
+    #[serde(skip_serializing_if = "persistent_keepalive_is_off")]
+    pub persistent_keepalive: Option<u16>,
+}
+
+/// 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<u16>) -> bool {
+    value.map(|v| v == 0).unwrap_or(true)
+}
+
+/// Properties of a WireGuard interface.
+#[derive(Serialize, Debug)]
+#[serde(rename_all = "PascalCase")]
+pub struct WireGuardInterface {
+    /// Private key for this interface.
+    pub private_key: PrivateKey,
+    /// Port to listen on. Optional; if not specified, chosen randomly. Only needed on the "server"
+    /// side.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub listen_port: Option<u16>,
+    /// Fwmark for outgoing packets.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub fw_mark: Option<u32>,
+}
+
+/// Top-level WireGuard configuration for WireGuard network interface. Holds all
+/// parameters for the interface itself, as well as its remote peers.
+#[derive(Serialize, Debug)]
+#[serde(rename_all = "PascalCase")]
+pub struct WireGuardConfig {
+    /// The WireGuard-specific network interface configuration.
+    pub interface: WireGuardInterface,
+    /// Peers for this WireGuard interface.
+    #[serde(rename = "Peer")]
+    pub peers: Vec<WireGuardPeer>,
+}
+
+impl WireGuardConfig {
+    /// Generate a raw, INI-style configuration file as accepted by wg(8).
+    pub fn to_raw_config(self) -> Result<String, Error> {
+        Ok(proxmox_serde::ini::to_string(&self)?)
+    }
+}
+
+/// Generates a new ED25519 private key.
+#[cfg(feature = "key-generation")]
+fn generate_key() -> Result<ed25519_dalek::SecretKey, Error> {
+    let mut secret = ed25519_dalek::SecretKey::default();
+    proxmox_sys::linux::fill_with_random_data(&mut secret)
+        .map_err(|err| Error::KeyGenFailed(err.to_string()))?;
+
+    // [`SigningKey`] takes care of correct key clamping.
+    Ok(SigningKey::from(&secret).to_bytes())
+}
+
+#[cfg(test)]
+mod tests {
+    use std::net::Ipv4Addr;
+
+    use proxmox_network_types::ip_address::Cidr;
+
+    use crate::{PresharedKey, PrivateKey, WireGuardConfig, WireGuardInterface, WireGuardPeer};
+
+    fn mock_private_key(v: u8) -> PrivateKey {
+        let base = v * 32;
+        PrivateKey((base..base + 32).collect::<Vec<u8>>().try_into().unwrap())
+    }
+
+    fn mock_preshared_key(v: u8) -> PresharedKey {
+        let base = v * 32;
+        PresharedKey((base..base + 32).collect::<Vec<u8>>().try_into().unwrap())
+    }
+
+    #[test]
+    fn single_peer() {
+        let config = WireGuardConfig {
+            interface: WireGuardInterface {
+                private_key: mock_private_key(0),
+                listen_port: Some(51820),
+                fw_mark: Some(127),
+            },
+            peers: vec![WireGuardPeer {
+                public_key: mock_private_key(1).public_key(),
+                preshared_key: mock_preshared_key(1),
+                allowed_ips: vec![Cidr::new_v4(Ipv4Addr::new(192, 168, 0, 0), 24).unwrap()],
+                endpoint: Some("foo.example.com:51820".parse().unwrap()),
+                persistent_keepalive: Some(25),
+            }],
+        };
+
+        pretty_assertions::assert_eq!(
+            config.to_raw_config().unwrap(),
+            "[Interface]
+PrivateKey = AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=
+ListenPort = 51820
+FwMark = 127
+
+[Peer]
+PublicKey = Kay64UG8yvCyLhqU000LxzYeUm0L/hLIl5S8kyKWbdc=
+PresharedKey = ICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj8=
+AllowedIPs = 192.168.0.0/24
+Endpoint = foo.example.com:51820
+PersistentKeepalive = 25
+"
+        );
+    }
+
+    #[test]
+    fn multiple_peers() {
+        let config = WireGuardConfig {
+            interface: WireGuardInterface {
+                private_key: mock_private_key(0),
+                listen_port: Some(51820),
+                fw_mark: None,
+            },
+            peers: vec![
+                WireGuardPeer {
+                    public_key: mock_private_key(1).public_key(),
+                    preshared_key: mock_preshared_key(1),
+                    allowed_ips: vec![Cidr::new_v4(Ipv4Addr::new(192, 168, 0, 0), 24).unwrap()],
+                    endpoint: Some("foo.example.com:51820".parse().unwrap()),
+                    persistent_keepalive: None,
+                },
+                WireGuardPeer {
+                    public_key: mock_private_key(2).public_key(),
+                    preshared_key: mock_preshared_key(2),
+                    allowed_ips: vec![Cidr::new_v4(Ipv4Addr::new(192, 168, 1, 0), 24).unwrap()],
+                    endpoint: None,
+                    persistent_keepalive: Some(25),
+                },
+                WireGuardPeer {
+                    public_key: mock_private_key(3).public_key(),
+                    preshared_key: mock_preshared_key(3),
+                    allowed_ips: vec![Cidr::new_v4(Ipv4Addr::new(192, 168, 2, 0), 24).unwrap()],
+                    endpoint: None,
+                    persistent_keepalive: None,
+                },
+                WireGuardPeer {
+                    public_key: mock_private_key(4).public_key(),
+                    preshared_key: Some(mock_preshared_key(4)),
+                    allowed_ips: vec![],
+                    endpoint: Some("10.0.0.1:51820".parse().unwrap()),
+                    persistent_keepalive: Some(25),
+                },
+            ],
+        };
+
+        pretty_assertions::assert_eq!(
+            config.to_raw_config().unwrap(),
+            "[Interface]
+PrivateKey = AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=
+ListenPort = 51820
+
+[Peer]
+PublicKey = Kay64UG8yvCyLhqU000LxzYeUm0L/hLIl5S8kyKWbdc=
+PresharedKey = ICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj8=
+AllowedIPs = 192.168.0.0/24
+Endpoint = foo.example.com:51820
+
+[Peer]
+PublicKey = JUO5L/EJVRFHatyDadtt3JM2ZaEZeN2hQE7hBmypVZ0=
+PresharedKey = QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl8=
+AllowedIPs = 192.168.1.0/24
+PersistentKeepalive = 25
+
+[Peer]
+PublicKey = F0VTtFbd38aQjsqxwQH+arIeK6oGF3lbfUOmNIKZP9U=
+PresharedKey = YGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn8=
+AllowedIPs = 192.168.2.0/24
+
+[Peer]
+PublicKey = zRSzf5VulTGU/3+3Oz2B3MVh1hp1OAlLfD4aZD7l86o=
+PresharedKey = gIGCg4SFhoeIiYqLjI2Oj5CRkpOUlZaXmJmam5ydnp8=
+Endpoint = 10.0.0.1:51820
+PersistentKeepalive = 25
+"
+        );
+    }
+
+    #[test]
+    fn non_listening_peer() {
+        let config = WireGuardConfig {
+            interface: WireGuardInterface {
+                private_key: mock_private_key(0),
+                listen_port: None,
+                fw_mark: None,
+            },
+            peers: vec![WireGuardPeer {
+                public_key: mock_private_key(1).public_key(),
+                preshared_key: Some(mock_preshared_key(1)),
+                allowed_ips: vec![Cidr::new_v4(Ipv4Addr::new(192, 168, 0, 0), 24).unwrap()],
+                endpoint: Some("10.0.0.1:51820".parse().unwrap()),
+                persistent_keepalive: Some(25),
+            }],
+        };
+
+        pretty_assertions::assert_eq!(
+            config.to_raw_config().unwrap(),
+            "[Interface]
+PrivateKey = AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=
+
+[Peer]
+PublicKey = Kay64UG8yvCyLhqU000LxzYeUm0L/hLIl5S8kyKWbdc=
+PresharedKey = ICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj8=
+AllowedIPs = 192.168.0.0/24
+Endpoint = 10.0.0.1:51820
+PersistentKeepalive = 25
+"
+        );
+    }
+
+    #[test]
+    fn empty_peers() {
+        let config = WireGuardConfig {
+            interface: WireGuardInterface {
+                private_key: mock_private_key(0),
+                listen_port: Some(51830),
+                fw_mark: Some(240),
+            },
+            peers: vec![],
+        };
+
+        pretty_assertions::assert_eq!(
+            config.to_raw_config().unwrap(),
+            "[Interface]
+PrivateKey = AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=
+ListenPort = 51830
+FwMark = 240
+"
+        );
+    }
+}
-- 
2.52.0





^ permalink raw reply	[flat|nested] 9+ messages in thread

* [PATCH proxmox v2 7/8] wireguard: implement api for PublicKey
  2026-02-13 14:35 [PATCH proxmox v2 0/8] sdn: add wireguard fabric configuration support Christoph Heiss
                   ` (5 preceding siblings ...)
  2026-02-13 14:35 ` [PATCH proxmox v2 6/8] wireguard: init configuration support crate Christoph Heiss
@ 2026-02-13 14:36 ` Christoph Heiss
  2026-02-13 14:36 ` [PATCH proxmox v2 8/8] wireguard: make per-peer preshared key optional Christoph Heiss
  7 siblings, 0 replies; 9+ messages in thread
From: Christoph Heiss @ 2026-02-13 14:36 UTC (permalink / raw)
  To: pve-devel

From: Stefan Hanreich <s.hanreich@proxmox.com>

.. such that it can be used in API definitions.

Authored-by: Stefan Hanreich <s.hanreich@proxmox.com>
Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
Changes v1 -> v2:
  * improve schema description

 proxmox-wireguard/Cargo.toml     |  3 +++
 proxmox-wireguard/debian/control | 19 +++++++++++++++++++
 proxmox-wireguard/src/lib.rs     | 17 +++++++++++++++++
 3 files changed, 39 insertions(+)

diff --git a/proxmox-wireguard/Cargo.toml b/proxmox-wireguard/Cargo.toml
index b48ed238..5d3f05fe 100644
--- a/proxmox-wireguard/Cargo.toml
+++ b/proxmox-wireguard/Cargo.toml
@@ -13,13 +13,16 @@ rust-version.workspace = true
 ed25519-dalek = "2.1"
 serde = { workspace = true, features = [ "derive" ] }
 thiserror.workspace = true
+proxmox-schema = { workspace = true, optional = true, features = ["api-types"] }
 proxmox-serde = { workspace = true, features = [ "ini-ser" ] }
 proxmox-network-types.workspace = true
 proxmox-sys = { workspace = true, optional = true }
+regex = { workspace = true, optional = true }
 
 [dev-dependencies]
 pretty_assertions.workspace = true
 
 [features]
 default = ["key-generation"]
+api-types = ["dep:proxmox-schema", "dep:regex"]
 key-generation = ["dep:proxmox-sys"]
diff --git a/proxmox-wireguard/debian/control b/proxmox-wireguard/debian/control
index 9c4c46cd..7c47b8cd 100644
--- a/proxmox-wireguard/debian/control
+++ b/proxmox-wireguard/debian/control
@@ -37,6 +37,8 @@ Depends:
  librust-thiserror-2+default-dev
 Recommends:
  librust-proxmox-wireguard+key-generation-dev (= ${binary:Version})
+Suggests:
+ librust-proxmox-wireguard+api-types-dev (= ${binary:Version})
 Provides:
  librust-proxmox-wireguard-0-dev (= ${binary:Version}),
  librust-proxmox-wireguard-0.1-dev (= ${binary:Version}),
@@ -44,6 +46,23 @@ Provides:
 Description: WireGuard configuration support - Rust source code
  Source code for Debianized Rust crate "proxmox-wireguard"
 
+Package: librust-proxmox-wireguard+api-types-dev
+Architecture: any
+Multi-Arch: same
+Depends:
+ ${misc:Depends},
+ librust-proxmox-wireguard-dev (= ${binary:Version}),
+ librust-proxmox-schema-5+api-types-dev (>= 5.0.1-~~),
+ librust-proxmox-schema-5+default-dev (>= 5.0.1-~~),
+ librust-regex-1+default-dev (>= 1.5-~~)
+Provides:
+ librust-proxmox-wireguard-0+api-types-dev (= ${binary:Version}),
+ librust-proxmox-wireguard-0.1+api-types-dev (= ${binary:Version}),
+ librust-proxmox-wireguard-0.1.0+api-types-dev (= ${binary:Version})
+Description: WireGuard configuration support - feature "api-types"
+ This metapackage enables feature "api-types" for the Rust proxmox-wireguard
+ crate, by pulling in any additional dependencies needed by that feature.
+
 Package: librust-proxmox-wireguard+key-generation-dev
 Architecture: any
 Multi-Arch: same
diff --git a/proxmox-wireguard/src/lib.rs b/proxmox-wireguard/src/lib.rs
index a3a517b7..facdeb99 100644
--- a/proxmox-wireguard/src/lib.rs
+++ b/proxmox-wireguard/src/lib.rs
@@ -17,6 +17,10 @@ use serde::{Deserialize, Serialize};
 use std::fmt;
 
 use proxmox_network_types::{endpoint::ServiceEndpoint, ip_address::Cidr};
+#[cfg(feature = "api-types")]
+use proxmox_schema::{
+    api_types::ED25519_BASE64_KEY_REGEX, ApiStringFormat, ApiType, StringSchema, UpdaterType,
+};
 
 /// Possible error when handling WireGuard configurations.
 #[derive(thiserror::Error, Debug, PartialEq, Clone)]
@@ -42,6 +46,19 @@ pub struct PublicKey(
     #[serde(with = "proxmox_serde::byte_array_as_base64")] [u8; ed25519_dalek::PUBLIC_KEY_LENGTH],
 );
 
+#[cfg(feature = "api-types")]
+impl ApiType for PublicKey {
+    const API_SCHEMA: proxmox_schema::Schema =
+        StringSchema::new("ED25519 public key (base64 encoded)")
+            .format(&ApiStringFormat::Pattern(&ED25519_BASE64_KEY_REGEX))
+            .schema();
+}
+
+#[cfg(feature = "api-types")]
+impl UpdaterType for PublicKey {
+    type Updater = Option<PublicKey>;
+}
+
 /// Private key of a WireGuard peer.
 #[derive(Serialize)]
 #[serde(transparent)]
-- 
2.52.0





^ permalink raw reply	[flat|nested] 9+ messages in thread

* [PATCH proxmox v2 8/8] wireguard: make per-peer preshared key optional
  2026-02-13 14:35 [PATCH proxmox v2 0/8] sdn: add wireguard fabric configuration support Christoph Heiss
                   ` (6 preceding siblings ...)
  2026-02-13 14:36 ` [PATCH proxmox v2 7/8] wireguard: implement api for PublicKey Christoph Heiss
@ 2026-02-13 14:36 ` Christoph Heiss
  7 siblings, 0 replies; 9+ messages in thread
From: Christoph Heiss @ 2026-02-13 14:36 UTC (permalink / raw)
  To: pve-devel

From: Stefan Hanreich <s.hanreich@proxmox.com>

Authored-by: Stefan Hanreich <s.hanreich@proxmox.com>
Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
Changes v1 -> v2:
  * no changes

 proxmox-wireguard/src/lib.rs | 10 +++++-----
 1 file changed, 5 insertions(+), 5 deletions(-)

diff --git a/proxmox-wireguard/src/lib.rs b/proxmox-wireguard/src/lib.rs
index facdeb99..7ff25357 100644
--- a/proxmox-wireguard/src/lib.rs
+++ b/proxmox-wireguard/src/lib.rs
@@ -159,7 +159,7 @@ pub struct WireGuardPeer {
     /// Additional key preshared between two peers. Adds an additional layer of symmetric-key
     /// cryptography to be mixed into the already existing public-key cryptography, for
     /// post-quantum resistance.
-    pub preshared_key: PresharedKey,
+    pub preshared_key: Option<PresharedKey>,
     /// List of IPv4/v6 CIDRs from which incoming traffic for this peer is allowed and to which
     /// outgoing traffic for this peer is directed. The catch-all 0.0.0.0/0 may be specified for
     /// matching all IPv4 addresses, and ::/0 may be specified for matching all IPv6 addresses.
@@ -257,7 +257,7 @@ mod tests {
             },
             peers: vec![WireGuardPeer {
                 public_key: mock_private_key(1).public_key(),
-                preshared_key: mock_preshared_key(1),
+                preshared_key: Some(mock_preshared_key(1)),
                 allowed_ips: vec![Cidr::new_v4(Ipv4Addr::new(192, 168, 0, 0), 24).unwrap()],
                 endpoint: Some("foo.example.com:51820".parse().unwrap()),
                 persistent_keepalive: Some(25),
@@ -292,21 +292,21 @@ PersistentKeepalive = 25
             peers: vec![
                 WireGuardPeer {
                     public_key: mock_private_key(1).public_key(),
-                    preshared_key: mock_preshared_key(1),
+                    preshared_key: Some(mock_preshared_key(1)),
                     allowed_ips: vec![Cidr::new_v4(Ipv4Addr::new(192, 168, 0, 0), 24).unwrap()],
                     endpoint: Some("foo.example.com:51820".parse().unwrap()),
                     persistent_keepalive: None,
                 },
                 WireGuardPeer {
                     public_key: mock_private_key(2).public_key(),
-                    preshared_key: mock_preshared_key(2),
+                    preshared_key: Some(mock_preshared_key(2)),
                     allowed_ips: vec![Cidr::new_v4(Ipv4Addr::new(192, 168, 1, 0), 24).unwrap()],
                     endpoint: None,
                     persistent_keepalive: Some(25),
                 },
                 WireGuardPeer {
                     public_key: mock_private_key(3).public_key(),
-                    preshared_key: mock_preshared_key(3),
+                    preshared_key: Some(mock_preshared_key(3)),
                     allowed_ips: vec![Cidr::new_v4(Ipv4Addr::new(192, 168, 2, 0), 24).unwrap()],
                     endpoint: None,
                     persistent_keepalive: None,
-- 
2.52.0





^ permalink raw reply	[flat|nested] 9+ messages in thread

end of thread, other threads:[~2026-02-13 14:36 UTC | newest]

Thread overview: 9+ messages (download: mbox.gz / follow: Atom feed)
-- links below jump to the message on this page --
2026-02-13 14:35 [PATCH proxmox v2 0/8] sdn: add wireguard fabric configuration support Christoph Heiss
2026-02-13 14:35 ` [PATCH proxmox v2 1/8] serde: implement ini serializer Christoph Heiss
2026-02-13 14:35 ` [PATCH proxmox v2 2/8] serde: add base64 module for byte arrays Christoph Heiss
2026-02-13 14:35 ` [PATCH proxmox v2 3/8] network-types: add ServiceEndpoint type as host/port tuple abstraction Christoph Heiss
2026-02-13 14:35 ` [PATCH proxmox v2 4/8] schema: provide integer schema for node ports Christoph Heiss
2026-02-13 14:35 ` [PATCH proxmox v2 5/8] schema: api-types: add ed25519 base64 encoded key schema Christoph Heiss
2026-02-13 14:35 ` [PATCH proxmox v2 6/8] wireguard: init configuration support crate Christoph Heiss
2026-02-13 14:36 ` [PATCH proxmox v2 7/8] wireguard: implement api for PublicKey Christoph Heiss
2026-02-13 14:36 ` [PATCH proxmox v2 8/8] wireguard: make per-peer preshared key optional Christoph Heiss

This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal