all lists on lists.proxmox.com
 help / color / mirror / Atom feed
From: Gabriel Goller <g.goller@proxmox.com>
To: pve-devel@lists.proxmox.com
Subject: [PATCH proxmox-ve-rs v2 5/8] frr: add template serializer and serialize fabrics using templates
Date: Mon,  2 Mar 2026 13:55:24 +0100	[thread overview]
Message-ID: <20260302125701.196916-6-g.goller@proxmox.com> (raw)
In-Reply-To: <20260302125701.196916-1-g.goller@proxmox.com>

Add a new serializer which uses only the builtin (include_str!) templates from
the `proxmox-frr-templates` package in `/usr/share/proxmox-frr/templates` to
generate the frr config file. Also update the `build_fabric` function and the
tests accordingly.

Use the `phf` crate to store them in a const map.

Signed-off-by: Gabriel Goller <g.goller@proxmox.com>
---
 proxmox-frr/Cargo.toml                        |   3 +
 proxmox-frr/debian/control                    |  12 +
 proxmox-frr/src/ser/mod.rs                    | 260 +++++++---------
 proxmox-frr/src/ser/openfabric.rs             |  37 +--
 proxmox-frr/src/ser/ospf.rs                   |  78 +----
 proxmox-frr/src/ser/route_map.rs              | 212 +++++--------
 proxmox-frr/src/ser/serializer.rs             | 227 ++------------
 proxmox-sdn-types/src/net.rs                  |   4 +-
 proxmox-ve-config/src/common/valid.rs         |   4 +-
 proxmox-ve-config/src/sdn/fabric/frr.rs       | 284 ++++++++++--------
 .../fabric__openfabric_default_pve.snap       |   2 +-
 .../fabric__openfabric_default_pve1.snap      |   2 +-
 .../fabric__openfabric_dualstack_pve.snap     |  13 +-
 .../fabric__openfabric_ipv6_only_pve.snap     |   4 +-
 .../fabric__openfabric_multi_fabric_pve1.snap |   2 +-
 .../snapshots/fabric__ospf_default_pve.snap   |   2 +-
 .../snapshots/fabric__ospf_default_pve1.snap  |   2 +-
 .../fabric__ospf_multi_fabric_pve1.snap       |   2 +-
 18 files changed, 442 insertions(+), 708 deletions(-)

diff --git a/proxmox-frr/Cargo.toml b/proxmox-frr/Cargo.toml
index 159f8606956d..d69eb63d967b 100644
--- a/proxmox-frr/Cargo.toml
+++ b/proxmox-frr/Cargo.toml
@@ -15,6 +15,9 @@ anyhow = "1"
 tracing = "0.1"
 serde = { workspace = true, features = [ "derive" ] }
 serde_repr = "0.1"
+minijinja = { version = "2.5", features = [ "multi_template", "loader" ] }
+phf = { version = "0.11.2", features = ["macros"] }
 
 proxmox-network-types = { workspace = true }
 proxmox-sdn-types = { workspace = true }
+proxmox-serde = { workspace = true }
diff --git a/proxmox-frr/debian/control b/proxmox-frr/debian/control
index bc407807ad4f..427a5fccc59d 100644
--- a/proxmox-frr/debian/control
+++ b/proxmox-frr/debian/control
@@ -7,8 +7,14 @@ Build-Depends-Arch: cargo:native <!nocheck>,
  rustc:native (>= 1.82) <!nocheck>,
  libstd-rust-dev <!nocheck>,
  librust-anyhow-1+default-dev <!nocheck>,
+ librust-minijinja-2+default-dev (>= 2.5-~~) <!nocheck>,
+ librust-minijinja-2+loader-dev (>= 2.5-~~) <!nocheck>,
+ librust-minijinja-2+multi-template-dev (>= 2.5-~~) <!nocheck>,
+ librust-phf-0.11+default-dev (>= 0.11.2-~~) <!nocheck>,
+ librust-phf-0.11+macros-dev (>= 0.11.2-~~) <!nocheck>,
  librust-proxmox-network-types-1+default-dev (>= 1.0.1-~~) <!nocheck>,
  librust-proxmox-sdn-types-0.1+default-dev <!nocheck>,
+ librust-proxmox-serde-1+default-dev <!nocheck>,
  librust-serde-1+default-dev <!nocheck>,
  librust-serde-1+derive-dev <!nocheck>,
  librust-serde-repr-0.1+default-dev <!nocheck>,
@@ -27,8 +33,14 @@ Multi-Arch: same
 Depends:
  ${misc:Depends},
  librust-anyhow-1+default-dev,
+ librust-minijinja-2+default-dev (>= 2.5-~~),
+ librust-minijinja-2+loader-dev (>= 2.5-~~),
+ librust-minijinja-2+multi-template-dev (>= 2.5-~~),
+ librust-phf-0.11+default-dev (>= 0.11.2-~~),
+ librust-phf-0.11+macros-dev (>= 0.11.2-~~),
  librust-proxmox-network-types-1+default-dev (>= 1.0.1-~~),
  librust-proxmox-sdn-types-0.1+default-dev,
+ librust-proxmox-serde-1+default-dev,
  librust-serde-1+default-dev,
  librust-serde-1+derive-dev,
  librust-serde-repr-0.1+default-dev,
diff --git a/proxmox-frr/src/ser/mod.rs b/proxmox-frr/src/ser/mod.rs
index a90397b59a9b..86813c7d6415 100644
--- a/proxmox-frr/src/ser/mod.rs
+++ b/proxmox-frr/src/ser/mod.rs
@@ -3,104 +3,16 @@ pub mod ospf;
 pub mod route_map;
 pub mod serializer;
 
-use std::collections::{BTreeMap, BTreeSet};
-use std::fmt::Display;
+use std::collections::BTreeMap;
+use std::net::IpAddr;
 use std::str::FromStr;
 
-use crate::ser::route_map::{AccessList, ProtocolRouteMap, RouteMap};
+use crate::ser::route_map::{AccessListName, AccessListRule, RouteMapEntry, RouteMapName};
 
+use proxmox_network_types::ip_address::Cidr;
+use serde::{Deserialize, Serialize};
 use thiserror::Error;
 
-/// Generic FRR router.
-///
-/// This generic FRR router contains all the protocols that we implement.
-/// In FRR this is e.g.:
-/// ```text
-/// router openfabric test
-/// !....
-/// ! or
-/// router ospf
-/// !....
-/// ```
-#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
-pub enum Router {
-    Openfabric(openfabric::OpenfabricRouter),
-    Ospf(ospf::OspfRouter),
-}
-
-impl From<openfabric::OpenfabricRouter> for Router {
-    fn from(value: openfabric::OpenfabricRouter) -> Self {
-        Router::Openfabric(value)
-    }
-}
-
-/// Generic FRR routername.
-///
-/// The variants represent different protocols. Some have `router <protocol> <name>`, others have
-/// `router <protocol> <process-id>`, some only have `router <protocol>`.
-#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
-pub enum RouterName {
-    Openfabric(openfabric::OpenfabricRouterName),
-    Ospf(ospf::OspfRouterName),
-}
-
-impl From<openfabric::OpenfabricRouterName> for RouterName {
-    fn from(value: openfabric::OpenfabricRouterName) -> Self {
-        Self::Openfabric(value)
-    }
-}
-
-impl Display for RouterName {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        match self {
-            Self::Openfabric(r) => r.fmt(f),
-            Self::Ospf(r) => r.fmt(f),
-        }
-    }
-}
-
-/// The interface name is the same on ospf and openfabric, but it is an enum so that we can have
-/// two different entries in the btreemap. This allows us to have an interface in a ospf and
-/// openfabric fabric.
-#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
-pub enum InterfaceName {
-    Openfabric(CommonInterfaceName),
-    Ospf(CommonInterfaceName),
-}
-
-impl Display for InterfaceName {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        match self {
-            InterfaceName::Openfabric(frr_word) => frr_word.fmt(f),
-            InterfaceName::Ospf(frr_word) => frr_word.fmt(f),
-        }
-    }
-}
-
-/// Generic FRR Interface.
-///
-/// In FRR config it looks like this:
-/// ```text
-/// interface <name>
-/// ! ...
-#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
-pub enum Interface {
-    Openfabric(openfabric::OpenfabricInterface),
-    Ospf(ospf::OspfInterface),
-}
-
-impl From<openfabric::OpenfabricInterface> for Interface {
-    fn from(value: openfabric::OpenfabricInterface) -> Self {
-        Self::Openfabric(value)
-    }
-}
-
-impl From<ospf::OspfInterface> for Interface {
-    fn from(value: ospf::OspfInterface) -> Self {
-        Self::Ospf(value)
-    }
-}
-
 #[derive(Error, Debug)]
 pub enum FrrWordError {
     #[error("word is empty")]
@@ -113,7 +25,7 @@ pub enum FrrWordError {
 ///
 /// Every string argument or value in FRR is an FrrWord. FrrWords must only contain ascii
 /// characters and must not have a whitespace.
-#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
+#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
 pub struct FrrWord(String);
 
 impl FrrWord {
@@ -144,12 +56,6 @@ impl FromStr for FrrWord {
     }
 }
 
-impl Display for FrrWord {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        self.0.fmt(f)
-    }
-}
-
 impl AsRef<str> for FrrWord {
     fn as_ref(&self) -> &str {
         &self.0
@@ -157,7 +63,7 @@ impl AsRef<str> for FrrWord {
 }
 
 #[derive(Error, Debug)]
-pub enum CommonInterfaceNameError {
+pub enum InterfaceNameError {
     #[error("interface name too long")]
     TooLong,
 }
@@ -166,76 +72,132 @@ pub enum CommonInterfaceNameError {
 ///
 /// FRR itself doesn't enforce any limits, but the kernel does. Linux only allows interface names
 /// to be a maximum of 16 bytes. This is enforced by this struct.
-#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
-pub struct CommonInterfaceName(String);
+#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
+pub struct InterfaceName(String);
 
-impl TryFrom<&str> for CommonInterfaceName {
-    type Error = CommonInterfaceNameError;
+impl TryFrom<&str> for InterfaceName {
+    type Error = InterfaceNameError;
 
-    fn try_from(value: &str) -> Result<Self, Self::Error> {
-        Self::new(value)
+    fn try_from(s: &str) -> Result<Self, Self::Error> {
+        Self::validate(s).map(Self::from_str_unchecked)
     }
 }
 
-impl TryFrom<String> for CommonInterfaceName {
-    type Error = CommonInterfaceNameError;
+impl TryFrom<String> for InterfaceName {
+    type Error = InterfaceNameError;
 
     fn try_from(value: String) -> Result<Self, Self::Error> {
-        Self::new(value)
+        if Self::validate(&value).is_ok() {
+            Ok(Self::from_string_unchecked(value))
+        } else {
+            Err(InterfaceNameError::TooLong)
+        }
     }
 }
 
-impl CommonInterfaceName {
-    pub fn new<T: AsRef<str> + Into<String>>(s: T) -> Result<Self, CommonInterfaceNameError> {
-        if s.as_ref().len() <= 15 {
-            Ok(Self(s.into()))
+impl InterfaceName {
+    fn validate(s: &str) -> Result<&str, InterfaceNameError> {
+        if s.len() <= 15 {
+            Ok(s)
         } else {
-            Err(CommonInterfaceNameError::TooLong)
+            Err(InterfaceNameError::TooLong)
         }
     }
-}
+    fn from_string_unchecked(s: String) -> InterfaceName {
+        Self(s)
+    }
 
-impl Display for CommonInterfaceName {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        self.0.fmt(f)
+    fn from_str_unchecked(s: &str) -> InterfaceName {
+        Self::from_string_unchecked(s.to_string())
     }
 }
 
-/// Main FRR config.
-///
-/// Contains the two main frr building blocks: routers and interfaces. It also holds other
-/// top-level FRR options, such as access-lists, router-maps and protocol-routemaps. This struct
-/// gets generated using the `FrrConfigBuilder` in `proxmox-ve-config`.
-#[derive(Clone, Debug, PartialEq, Eq, Default)]
-pub struct FrrConfig {
-    pub router: BTreeMap<RouterName, Router>,
-    pub interfaces: BTreeMap<InterfaceName, Interface>,
-    pub access_lists: Vec<AccessList>,
-    pub routemaps: Vec<RouteMap>,
-    pub protocol_routemaps: BTreeSet<ProtocolRouteMap>,
-}
+#[derive(Debug, Clone, Serialize, PartialEq, Eq, Deserialize)]
+pub struct Interface<T> {
+    #[serde(default, skip_serializing_if = "Vec::is_empty")]
+    pub addresses: Vec<Cidr>,
 
-impl FrrConfig {
-    pub fn new() -> Self {
-        Self::default()
+    #[serde(flatten)]
+    pub properties: T,
+}
+impl From<openfabric::OpenfabricInterface> for Interface<openfabric::OpenfabricInterface> {
+    fn from(value: openfabric::OpenfabricInterface) -> Self {
+        Interface {
+            addresses: Vec::new(),
+            properties: value,
+        }
     }
+}
 
-    pub fn router(&self) -> impl Iterator<Item = (&RouterName, &Router)> + '_ {
-        self.router.iter()
+impl From<ospf::OspfInterface> for Interface<ospf::OspfInterface> {
+    fn from(value: ospf::OspfInterface) -> Self {
+        Interface {
+            addresses: Vec::new(),
+            properties: value,
+        }
     }
+}
 
-    pub fn interfaces(&self) -> impl Iterator<Item = (&InterfaceName, &Interface)> + '_ {
-        self.interfaces.iter()
-    }
+#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
+#[serde(untagged)]
+pub enum IpOrInterface {
+    Ip(IpAddr),
+    Interface(InterfaceName),
+}
 
-    pub fn access_lists(&self) -> impl Iterator<Item = &AccessList> + '_ {
-        self.access_lists.iter()
-    }
-    pub fn routemaps(&self) -> impl Iterator<Item = &RouteMap> + '_ {
-        self.routemaps.iter()
-    }
+#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
+pub struct IpRoute {
+    #[serde(default, deserialize_with = "proxmox_serde::perl::deserialize_bool")]
+    is_ipv6: bool,
+    prefix: Cidr,
+    via: IpOrInterface,
+    vrf: Option<InterfaceName>,
+}
 
-    pub fn protocol_routemaps(&self) -> impl Iterator<Item = &ProtocolRouteMap> + '_ {
-        self.protocol_routemaps.iter()
-    }
+#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum FrrProtocol {
+    Ospf,
+    Openfabric,
+    Bgp,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
+pub struct IpProtocolRouteMap {
+    pub v4: Option<RouteMapName>,
+    pub v6: Option<RouteMapName>,
+}
+
+/// Main FRR config.
+///
+/// Contains the two main frr building blocks: routers and interfaces. It also holds other
+/// top-level FRR options, such as access-lists, router-maps and protocol-routemaps.
+#[derive(Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
+pub struct FrrConfig {
+    #[serde(default)]
+    pub openfabric: OpenfabricFrrConfig,
+    #[serde(default)]
+    pub ospf: OspfFrrConfig,
+    #[serde(default)]
+    pub protocol_routemaps: BTreeMap<FrrProtocol, IpProtocolRouteMap>,
+    #[serde(default)]
+    pub routemaps: BTreeMap<RouteMapName, Vec<RouteMapEntry>>,
+    #[serde(default)]
+    pub access_lists: BTreeMap<AccessListName, Vec<AccessListRule>>,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
+pub struct OpenfabricFrrConfig {
+    #[serde(default)]
+    pub router: BTreeMap<openfabric::OpenfabricRouterName, openfabric::OpenfabricRouter>,
+    #[serde(default)]
+    pub interfaces: BTreeMap<InterfaceName, Interface<openfabric::OpenfabricInterface>>,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
+pub struct OspfFrrConfig {
+    #[serde(default)]
+    pub router: Option<ospf::OspfRouter>,
+    #[serde(default)]
+    pub interfaces: BTreeMap<InterfaceName, Interface<ospf::OspfInterface>>,
 }
diff --git a/proxmox-frr/src/ser/openfabric.rs b/proxmox-frr/src/ser/openfabric.rs
index 0f0c65062d36..58d55da285b1 100644
--- a/proxmox-frr/src/ser/openfabric.rs
+++ b/proxmox-frr/src/ser/openfabric.rs
@@ -1,15 +1,16 @@
 use std::fmt::Debug;
-use std::fmt::Display;
 
 use proxmox_sdn_types::net::Net;
 
+use serde::Deserialize;
+use serde::Serialize;
 use thiserror::Error;
 
 use crate::ser::FrrWord;
 use crate::ser::FrrWordError;
 
 /// The name of a OpenFabric router. Is an FrrWord.
-#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
+#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
 pub struct OpenfabricRouterName(FrrWord);
 
 impl From<FrrWord> for OpenfabricRouterName {
@@ -24,16 +25,16 @@ impl OpenfabricRouterName {
     }
 }
 
-impl Display for OpenfabricRouterName {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        write!(f, "openfabric {}", self.0)
+impl std::fmt::Display for OpenfabricRouterName {
+    fn fmt(&self, f: &mut std::fmt::Formatter) -> Result<(), std::fmt::Error> {
+        self.0.fmt(f)
     }
 }
 
 /// All the properties a OpenFabric router can hold.
 ///
 /// These can serialized with a " " space prefix as they are in the `router openfabric` block.
-#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
+#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
 pub struct OpenfabricRouter {
     /// The NET address
     pub net: Net,
@@ -53,29 +54,29 @@ impl OpenfabricRouter {
 ///
 /// This struct holds all the OpenFabric interface properties. The most important one here is the
 /// fabric_id, which ties the interface to a fabric. When serialized these properties all get
-/// prefixed with a space (" ") as they are inside the interface block. They serialize roughly to:
-///
-/// ```text
-/// interface ens20
-///  ip router openfabric <fabric_id>
-///  ipv6 router openfabric <fabric_id>
-///  openfabric hello-interval <value>
-///  openfabric hello-multiplier <value>
-///  openfabric csnp-interval <value>
-///  openfabric passive <value>
-/// ```
+/// prefixed with a space (" ") as they are inside the interface block.
 ///
 /// The is_ipv4 and is_ipv6 properties decide if we need to add `ip router openfabric`, `ipv6
 /// router openfabric`, or both. An interface can only be part of a single fabric.
-#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
+#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
 pub struct OpenfabricInterface {
     // Note: an interface can only be a part of a single fabric (so no vec needed here)
     pub fabric_id: OpenfabricRouterName,
+    #[serde(
+        default,
+        deserialize_with = "proxmox_serde::perl::deserialize_bool",
+        skip_serializing_if = "Option::is_none"
+    )]
     pub passive: Option<bool>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
     pub hello_interval: Option<proxmox_sdn_types::openfabric::HelloInterval>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
     pub csnp_interval: Option<proxmox_sdn_types::openfabric::CsnpInterval>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
     pub hello_multiplier: Option<proxmox_sdn_types::openfabric::HelloMultiplier>,
+    #[serde(deserialize_with = "proxmox_serde::perl::deserialize_bool")]
     pub is_ipv4: bool,
+    #[serde(deserialize_with = "proxmox_serde::perl::deserialize_bool")]
     pub is_ipv6: bool,
 }
 
diff --git a/proxmox-frr/src/ser/ospf.rs b/proxmox-frr/src/ser/ospf.rs
index 67e39a45b8de..8c9992e901f2 100644
--- a/proxmox-frr/src/ser/ospf.rs
+++ b/proxmox-frr/src/ser/ospf.rs
@@ -1,32 +1,11 @@
 use std::fmt::Debug;
-use std::fmt::Display;
 use std::net::Ipv4Addr;
 
+use serde::{Deserialize, Serialize};
 use thiserror::Error;
 
 use crate::ser::{FrrWord, FrrWordError};
 
-/// The name of the ospf frr router.
-///
-/// We can only have a single ospf router (ignoring multiple invocations of the ospfd daemon)
-/// because the router-id needs to be the same between different routers on a single node.
-/// We can still have multiple fabrics by separating them using areas. Still, different areas have
-/// the same frr router, so the name of the router is just "ospf" in "router ospf".
-///
-/// This serializes roughly to:
-/// ```text
-/// router ospf
-/// !...
-/// ```
-#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
-pub struct OspfRouterName;
-
-impl Display for OspfRouterName {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        write!(f, "ospf")
-    }
-}
-
 #[derive(Error, Debug)]
 pub enum AreaParsingError {
     #[error("Invalid area idenitifier. Area must be a number or an ipv4 address.")]
@@ -44,7 +23,7 @@ pub enum AreaParsingError {
 /// or "0" as an area, which then gets translated to "0.0.0.5" and "0.0.0.0" by FRR. We allow both
 /// a number or an ip-address. Note that the area "0" (or "0.0.0.0") is a special area - it creates
 /// a OSPF "backbone" area.
-#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
+#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
 pub struct Area(FrrWord);
 
 impl TryFrom<FrrWord> for Area {
@@ -65,26 +44,13 @@ impl Area {
     }
 }
 
-impl Display for Area {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        write!(f, "area {}", self.0)
-    }
-}
-
 /// The OSPF router properties.
 ///
 /// Currently the only property of a OSPF router is the router_id. The router_id is used to
 /// differentiate between nodes and every node in the same area must have a different router_id.
 /// The router_id must also be the same on the different fabrics on the same node. The OSPFv2
 /// daemon only supports IPv4.
-/// Note that these properties also serialize with a space prefix (" ") as they are inside the OSPF
-/// router block. It serializes roughly to:
-///
-/// ```text
-/// router ospf
-///  router-id <ipv4-address>
-/// ```
-#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
+#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
 pub struct OspfRouter {
     pub router_id: Ipv4Addr,
 }
@@ -112,14 +78,8 @@ pub enum OspfInterfaceError {
 /// The most important options here are Broadcast (which is the default) and PointToPoint.
 /// When PointToPoint is set, then the interface has to have a /32 address and will be treated as
 /// unnumbered.
-///
-/// This roughly serializes to:
-/// ```text
-/// ip ospf network point-to-point
-/// ! or
-/// ip ospf network broadcast
-/// ```
-#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
 pub enum NetworkType {
     Broadcast,
     NonBroadcast,
@@ -132,34 +92,20 @@ pub enum NetworkType {
     PointToMultipoint,
 }
 
-impl Display for NetworkType {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        match self {
-            NetworkType::Broadcast => write!(f, "broadcast"),
-            NetworkType::NonBroadcast => write!(f, "non-broadcast"),
-            NetworkType::PointToPoint => write!(f, "point-to-point"),
-            NetworkType::PointToMultipoint => write!(f, "point-to-multicast"),
-        }
-    }
-}
-
 /// The OSPF interface properties.
 ///
 /// The interface gets tied to its fabric by the area property and the FRR `ip ospf area <area>`
 /// command.
-///
-/// This serializes to:
-///
-/// ```text
-/// router ospf
-///  ip ospf area <area>
-///  ip ospf passive <value>
-///  ip ospf network <value>
-/// ```
-#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
+#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
 pub struct OspfInterface {
     // Note: an interface can only be a part of a single area(so no vec needed here)
     pub area: Area,
+    #[serde(
+        default,
+        skip_serializing_if = "Option::is_none",
+        deserialize_with = "proxmox_serde::perl::deserialize_bool"
+    )]
     pub passive: Option<bool>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
     pub network_type: Option<NetworkType>,
 }
diff --git a/proxmox-frr/src/ser/route_map.rs b/proxmox-frr/src/ser/route_map.rs
index 0918a3cead14..3e13b46c5d7b 100644
--- a/proxmox-frr/src/ser/route_map.rs
+++ b/proxmox-frr/src/ser/route_map.rs
@@ -1,29 +1,19 @@
-use std::{
-    fmt::{self, Display},
-    net::IpAddr,
-};
+use std::net::IpAddr;
 
 use proxmox_network_types::ip_address::Cidr;
+use serde::{Deserialize, Serialize};
 
 /// The action for a [`AccessListRule`].
 ///
 /// The default is Permit. Deny can be used to create a NOT match (e.g. match all routes that are
 /// NOT in 10.10.10.0/24 using `ip access-list TEST deny 10.10.10.0/24`).
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
 pub enum AccessAction {
     Permit,
     Deny,
 }
 
-impl fmt::Display for AccessAction {
-    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-        match self {
-            AccessAction::Permit => write!(f, "permit"),
-            AccessAction::Deny => write!(f, "deny"),
-        }
-    }
-}
-
 /// A single [`AccessList`] rule.
 ///
 /// Every rule in a [`AccessList`] is its own command and gets written into a new line (with the
@@ -32,132 +22,113 @@ impl fmt::Display for AccessAction {
 /// between access-lists of the same name and rules. Every [`AccessListRule`] has to have a
 /// different seq number.
 /// The `ip` or `ipv6` prefix gets decided based on the Cidr address passed.
-///
-/// This serializes to:
-///
-/// ```text
-/// ip access-list filter permit 10.0.0.0/8
-/// ! or
-/// ipv6 access-list filter permit 2001:db8::/64
-/// ```
-#[derive(Clone, Debug, PartialEq, Eq)]
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
 pub struct AccessListRule {
     pub action: AccessAction,
     pub network: Cidr,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
     pub seq: Option<u32>,
+    #[serde(deserialize_with = "proxmox_serde::perl::deserialize_bool")]
+    pub is_ipv6: bool,
 }
 
 /// The name of an [`AccessList`].
-#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
+#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
 pub struct AccessListName(String);
 
+#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
+pub struct PrefixListName(String);
+
 impl AccessListName {
     pub fn new(name: String) -> AccessListName {
         AccessListName(name)
     }
 }
 
-impl Display for AccessListName {
-    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-        self.0.fmt(f)
-    }
-}
-
 /// A FRR access-list.
 ///
 /// Holds a vec of rules. Each rule will get its own line, FRR will collect all the rules with the
 /// same name and combine them.
-///
-/// This serializes to:
-///
-/// ```text
-/// ip access-list pve_test permit 10.0.0.0/24
-/// ip access-list pve_test permit 12.1.1.0/24
-/// ip access-list pve_test deny 8.8.8.8/32
-/// ```
-#[derive(Clone, Debug, PartialEq, Eq)]
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
 pub struct AccessList {
     pub name: AccessListName,
     pub rules: Vec<AccessListRule>,
 }
 
+#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
+pub struct PrefixList {
+    pub name: PrefixListName,
+    pub rules: Vec<PrefixListRule>,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
+pub struct PrefixListRule {
+    pub action: AccessAction,
+    pub network: Cidr,
+    pub seq: Option<u32>,
+    pub le: Option<u32>,
+    pub ge: Option<u32>,
+    #[serde(default, deserialize_with = "proxmox_serde::perl::deserialize_bool")]
+    pub is_ipv6: bool,
+}
+
 /// A match statement inside a route-map.
 ///
 /// A route-map has one or more match statements which decide on which routes the route-map will
 /// execute its actions. If we match on an IP, there are two different syntaxes: `match ip ...` or
 /// `match ipv6 ...`.
-///
-/// Serializes to:
-///
-/// ```text
-///  match ip address <access-list-name>
-/// ! or
-///  match ip next-hop <ip-address>
-/// ! or
-///  match ipv6 address <access-list-name>
-/// ! or
-///  match ipv6 next-hop <ip-address>
-/// ```
-#[derive(Clone, Debug, PartialEq, Eq)]
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(tag = "protocol_type")]
 pub enum RouteMapMatch {
+    #[serde(rename = "ip")]
     V4(RouteMapMatchInner),
+    #[serde(rename = "ipv6")]
     V6(RouteMapMatchInner),
+    Vni(u32),
 }
 
-impl Display for RouteMapMatch {
-    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-        match self {
-            RouteMapMatch::V4(route_map_match_v4) => match route_map_match_v4 {
-                RouteMapMatchInner::IpAddress(access_list_name) => {
-                    write!(f, "match ip address {access_list_name}")
-                }
-                RouteMapMatchInner::IpNextHop(next_hop) => {
-                    write!(f, "match ip next-hop {next_hop}")
-                }
-            },
-            RouteMapMatch::V6(route_map_match_v6) => match route_map_match_v6 {
-                RouteMapMatchInner::IpAddress(access_list_name) => {
-                    write!(f, "match ipv6 address {access_list_name}")
-                }
-                RouteMapMatchInner::IpNextHop(next_hop) => {
-                    write!(f, "match ipv6 next-hop {next_hop}")
-                }
-            },
-        }
-    }
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(tag = "list_type", content = "list_name", rename_all = "lowercase")]
+pub enum AccessListOrPrefixList {
+    PrefixList(PrefixListName),
+    AccessList(AccessListName),
 }
 
 /// A route-map match statement generic on the IP-version.
-#[derive(Clone, Debug, PartialEq, Eq)]
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(tag = "match_type", content = "value", rename_all = "kebab-case")]
 pub enum RouteMapMatchInner {
-    IpAddress(AccessListName),
-    IpNextHop(String),
+    Address(AccessListOrPrefixList),
+    NextHop(String),
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub enum SetIpNextHopValue {
+    PeerAddress,
+    Unchanged,
+    IpAddr(IpAddr),
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub enum SetTagValue {
+    Untagged,
+    Numeric(u32),
 }
 
 /// Defines the Action a route-map takes when it matches on a route.
 ///
 /// If the route matches the [`RouteMapMatch`], then a [`RouteMapSet`] action will be executed.
 /// We currently only use the IpSrc command which changes the source address of the route.
-#[derive(Clone, Debug, PartialEq, Eq)]
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(tag = "set_type", content = "value", rename_all = "kebab-case")]
 pub enum RouteMapSet {
     LocalPreference(u32),
-    IpSrc(IpAddr),
+    Src(IpAddr),
     Metric(u32),
     Community(String),
 }
 
-impl Display for RouteMapSet {
-    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-        match self {
-            RouteMapSet::LocalPreference(pref) => write!(f, "set local-preference {}", pref),
-            RouteMapSet::IpSrc(addr) => write!(f, "set src {}", addr),
-            RouteMapSet::Metric(metric) => write!(f, "set metric {}", metric),
-            RouteMapSet::Community(community) => write!(f, "set community {}", community),
-        }
-    }
-}
-
-#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
+#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
 pub struct RouteMapName(String);
 
 impl RouteMapName {
@@ -166,68 +137,19 @@ impl RouteMapName {
     }
 }
 
-impl Display for RouteMapName {
-    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-        self.0.fmt(f)
-    }
-}
-
 /// A FRR route-map.
 ///
 /// In FRR route-maps are used to manipulate routes learned by protocols. We can match on specific
 /// routes (from specific protocols or subnets) and then change them, by e.g. editing the source
 /// address or adding a metric, bgp community, or local preference.
-///
-/// This serializes to:
-///
-/// ```text
-/// route-map <name> permit 100
-///  match ip address <access-list>
-///  set src <ip-address>
-/// exit
-/// ```
-#[derive(Clone, Debug, PartialEq, Eq)]
-pub struct RouteMap {
-    pub name: RouteMapName,
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+pub struct RouteMapEntry {
     pub seq: u32,
     pub action: AccessAction,
+    #[serde(default)]
     pub matches: Vec<RouteMapMatch>,
+    #[serde(default)]
     pub sets: Vec<RouteMapSet>,
-}
-
-/// The ProtocolType used in the [`ProtocolRouteMap`].
-///
-/// Specifies to which protocols we can attach route-maps.
-#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
-pub enum ProtocolType {
-    Openfabric,
-    Ospf,
-}
-
-impl Display for ProtocolType {
-    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-        match self {
-            ProtocolType::Openfabric => write!(f, "openfabric"),
-            ProtocolType::Ospf => write!(f, "ospf"),
-        }
-    }
-}
-
-/// ProtocolRouteMap statement.
-///
-/// This statement attaches the route-map to the protocol, so that all the routes learned through
-/// the specified protocol can be matched on and manipulated with the route-map.
-///
-/// This serializes to:
-///
-/// ```text
-/// ip protocol <protocol> route-map <route-map-name>
-/// ! or
-/// ipv6 protocol <protocol> route-map <route-map-name>
-/// ```
-#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
-pub struct ProtocolRouteMap {
-    pub is_ipv6: bool,
-    pub protocol: ProtocolType,
-    pub routemap_name: RouteMapName,
+    #[serde(default, skip_serializing_if = "Vec::is_empty")]
+    pub custom_frr_config: Vec<String>,
 }
diff --git a/proxmox-frr/src/ser/serializer.rs b/proxmox-frr/src/ser/serializer.rs
index 3a681e2f0d7a..51c46729c8ce 100644
--- a/proxmox-frr/src/ser/serializer.rs
+++ b/proxmox-frr/src/ser/serializer.rs
@@ -1,203 +1,42 @@
-use std::fmt::{self, Write};
-
-use crate::ser::{
-    openfabric::{OpenfabricInterface, OpenfabricRouter},
-    ospf::{OspfInterface, OspfRouter},
-    route_map::{AccessList, AccessListName, ProtocolRouteMap, RouteMap},
-    FrrConfig, Interface, InterfaceName, Router, RouterName,
+use anyhow::Context;
+use minijinja::Environment;
+
+use crate::ser::FrrConfig;
+
+pub static TEMPLATES: phf::Map<&'static str, &'static str> = phf::phf_map! {
+        "fabricd.jinja" => include_str!("/usr/share/proxmox-frr/templates/fabricd.jinja"),
+        "ospfd.jinja" => include_str!("/usr/share/proxmox-frr/templates/ospfd.jinja"),
+        "interface.jinja" => include_str!("/usr/share/proxmox-frr/templates/interface.jinja"),
+        "access_lists.jinja" => include_str!("/usr/share/proxmox-frr/templates/access_lists.jinja"),
+        "route_maps.jinja" => include_str!("/usr/share/proxmox-frr/templates/route_maps.jinja"),
+        "protocol_routemaps.jinja" => include_str!("/usr/share/proxmox-frr/templates/protocol_routemaps.jinja"),
+        "frr.conf.jinja" => include_str!("/usr/share/proxmox-frr/templates/frr.conf.jinja"),
 };
 
-pub struct FrrConfigBlob<'a> {
-    buf: &'a mut (dyn Write + 'a),
-}
-
-impl Write for FrrConfigBlob<'_> {
-    fn write_str(&mut self, s: &str) -> Result<(), fmt::Error> {
-        self.buf.write_str(s)
-    }
-}
+fn create_env<'a>() -> Environment<'a> {
+    let mut env = Environment::new();
 
-pub trait FrrSerializer {
-    fn serialize(&self, f: &mut FrrConfigBlob<'_>) -> fmt::Result;
-}
+    // avoid unnecessary additional newlines
+    env.set_trim_blocks(true);
+    env.set_lstrip_blocks(true);
 
-pub fn to_raw_config(frr_config: &FrrConfig) -> Result<Vec<String>, anyhow::Error> {
-    let mut out = String::new();
-    let mut blob = FrrConfigBlob { buf: &mut out };
-    frr_config.serialize(&mut blob)?;
+    env.set_loader(move |name| Ok(TEMPLATES.get(name).map(|template| (*template).to_owned())));
 
-    Ok(out.as_str().lines().map(String::from).collect())
+    env
 }
 
+/// Render the passed [`FrrConfig`] into a single string containing the whole config.
 pub fn dump(config: &FrrConfig) -> Result<String, anyhow::Error> {
-    let mut out = String::new();
-    let mut blob = FrrConfigBlob { buf: &mut out };
-    config.serialize(&mut blob)?;
-    Ok(out)
-}
-
-impl FrrSerializer for FrrConfig {
-    fn serialize(&self, f: &mut FrrConfigBlob<'_>) -> fmt::Result {
-        self.router().try_for_each(|router| router.serialize(f))?;
-        self.interfaces()
-            .try_for_each(|interface| interface.serialize(f))?;
-        self.access_lists().try_for_each(|list| list.serialize(f))?;
-        self.routemaps().try_for_each(|map| map.serialize(f))?;
-        self.protocol_routemaps()
-            .try_for_each(|pm| pm.serialize(f))?;
-        Ok(())
-    }
-}
-
-impl FrrSerializer for (&RouterName, &Router) {
-    fn serialize(&self, f: &mut FrrConfigBlob<'_>) -> fmt::Result {
-        let router_name = self.0;
-        let router = self.1;
-        writeln!(f, "router {router_name}")?;
-        router.serialize(f)?;
-        writeln!(f, "exit")?;
-        writeln!(f, "!")?;
-        Ok(())
-    }
-}
-
-impl FrrSerializer for (&InterfaceName, &Interface) {
-    fn serialize(&self, f: &mut FrrConfigBlob<'_>) -> fmt::Result {
-        let interface_name = self.0;
-        let interface = self.1;
-        writeln!(f, "interface {interface_name}")?;
-        interface.serialize(f)?;
-        writeln!(f, "exit")?;
-        writeln!(f, "!")?;
-        Ok(())
-    }
-}
-
-impl FrrSerializer for (&AccessListName, &AccessList) {
-    fn serialize(&self, f: &mut FrrConfigBlob<'_>) -> fmt::Result {
-        self.1.serialize(f)?;
-        writeln!(f, "!")
-    }
-}
-
-impl FrrSerializer for Interface {
-    fn serialize(&self, f: &mut FrrConfigBlob<'_>) -> fmt::Result {
-        match self {
-            Interface::Openfabric(openfabric_interface) => openfabric_interface.serialize(f)?,
-            Interface::Ospf(ospf_interface) => ospf_interface.serialize(f)?,
-        }
-        Ok(())
-    }
-}
-
-impl FrrSerializer for OpenfabricInterface {
-    fn serialize(&self, f: &mut FrrConfigBlob<'_>) -> fmt::Result {
-        if self.is_ipv6 {
-            writeln!(f, " ipv6 router {}", self.fabric_id)?;
-        }
-        if self.is_ipv4 {
-            writeln!(f, " ip router {}", self.fabric_id)?;
-        }
-        if self.passive == Some(true) {
-            writeln!(f, " openfabric passive")?;
-        }
-        if let Some(interval) = self.hello_interval {
-            writeln!(f, " openfabric hello-interval {interval}",)?;
-        }
-        if let Some(multiplier) = self.hello_multiplier {
-            writeln!(f, " openfabric hello-multiplier {multiplier}",)?;
-        }
-        if let Some(interval) = self.csnp_interval {
-            writeln!(f, " openfabric csnp-interval {interval}",)?;
-        }
-        Ok(())
-    }
-}
-
-impl FrrSerializer for OspfInterface {
-    fn serialize(&self, f: &mut FrrConfigBlob<'_>) -> fmt::Result {
-        writeln!(f, " ip ospf {}", self.area)?;
-        if self.passive == Some(true) {
-            writeln!(f, " ip ospf passive")?;
-        }
-        if let Some(network_type) = &self.network_type {
-            writeln!(f, " ip ospf network {network_type}")?;
-        }
-        Ok(())
-    }
-}
-
-impl FrrSerializer for Router {
-    fn serialize(&self, f: &mut FrrConfigBlob<'_>) -> fmt::Result {
-        match self {
-            Router::Openfabric(open_fabric_router) => open_fabric_router.serialize(f),
-            Router::Ospf(ospf_router) => ospf_router.serialize(f),
-        }
-    }
-}
-
-impl FrrSerializer for OpenfabricRouter {
-    fn serialize(&self, f: &mut FrrConfigBlob<'_>) -> fmt::Result {
-        writeln!(f, " net {}", self.net())?;
-        Ok(())
-    }
-}
-
-impl FrrSerializer for OspfRouter {
-    fn serialize(&self, f: &mut FrrConfigBlob<'_>) -> fmt::Result {
-        writeln!(f, " ospf router-id {}", self.router_id())?;
-        Ok(())
-    }
-}
-
-impl FrrSerializer for AccessList {
-    fn serialize(&self, f: &mut FrrConfigBlob<'_>) -> fmt::Result {
-        for i in &self.rules {
-            if i.network.is_ipv6() {
-                write!(f, "ipv6 ")?;
-            }
-            write!(f, "access-list {} ", self.name)?;
-            if let Some(seq) = i.seq {
-                write!(f, "seq {seq} ")?;
-            }
-            write!(f, "{} ", i.action)?;
-            writeln!(f, "{}", i.network)?;
-        }
-        writeln!(f, "!")?;
-        Ok(())
-    }
-}
-
-impl FrrSerializer for RouteMap {
-    fn serialize(&self, f: &mut FrrConfigBlob<'_>) -> fmt::Result {
-        writeln!(f, "route-map {} {} {}", self.name, self.action, self.seq)?;
-        for i in &self.matches {
-            writeln!(f, " {}", i)?;
-        }
-        for i in &self.sets {
-            writeln!(f, " {}", i)?;
-        }
-        writeln!(f, "exit")?;
-        writeln!(f, "!")
-    }
-}
-
-impl FrrSerializer for ProtocolRouteMap {
-    fn serialize(&self, f: &mut FrrConfigBlob<'_>) -> fmt::Result {
-        if self.is_ipv6 {
-            writeln!(
-                f,
-                "ipv6 protocol {} route-map {}",
-                self.protocol, self.routemap_name
-            )?;
-        } else {
-            writeln!(
-                f,
-                "ip protocol {} route-map {}",
-                self.protocol, self.routemap_name
-            )?;
-        }
-        writeln!(f, "!")?;
-        Ok(())
-    }
+    create_env()
+        .get_template("frr.conf.jinja")
+        .with_context(|| "could not obtain frr template from environment")?
+        .render(config)
+        .with_context(|| "could not render frr template")
+}
+
+/// Render the passed [`FrrConfig`] into the literal Frr config.
+///
+/// The Frr config is returned as lines stored in a Vec.
+pub fn to_raw_config(config: &FrrConfig) -> Result<Vec<String>, anyhow::Error> {
+    Ok(dump(config)?.lines().map(|line| line.to_owned()).collect())
 }
diff --git a/proxmox-sdn-types/src/net.rs b/proxmox-sdn-types/src/net.rs
index 3e523fb12d9b..21822bacab1c 100644
--- a/proxmox-sdn-types/src/net.rs
+++ b/proxmox-sdn-types/src/net.rs
@@ -139,7 +139,7 @@ impl Default for NetSelector {
 /// between fabrics on the same node. It contains the [`NetSystemId`] and the [`NetSelector`].
 /// e.g.: "1921.6800.1002.00"
 #[api]
-#[derive(Debug, Deserialize, Serialize, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)]
+#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)]
 pub struct Net {
     afi: NetAFI,
     area: NetArea,
@@ -147,6 +147,8 @@ pub struct Net {
     selector: NetSelector,
 }
 
+proxmox_serde::forward_serialize_to_display!(Net);
+proxmox_serde::forward_deserialize_to_from_str!(Net);
 
 impl UpdaterType for Net {
     type Updater = Option<Net>;
diff --git a/proxmox-ve-config/src/common/valid.rs b/proxmox-ve-config/src/common/valid.rs
index 1f92ef9bb409..f59ddd0a8806 100644
--- a/proxmox-ve-config/src/common/valid.rs
+++ b/proxmox-ve-config/src/common/valid.rs
@@ -1,5 +1,7 @@
 use std::ops::Deref;
 
+use serde::{Deserialize, Serialize};
+
 /// A wrapper type for validatable structs.
 ///
 /// It can only be constructed by implementing the [`Validatable`] type for a struct. Its contents
@@ -8,7 +10,7 @@ use std::ops::Deref;
 ///
 /// If you want to edit the content, this struct has to be unwrapped via [`Valid<T>::into_inner`].
 #[repr(transparent)]
-#[derive(Clone, Default, Debug)]
+#[derive(Clone, Default, Debug, Serialize, Deserialize)]
 pub struct Valid<T>(T);
 
 impl<T> Valid<T> {
diff --git a/proxmox-ve-config/src/sdn/fabric/frr.rs b/proxmox-ve-config/src/sdn/fabric/frr.rs
index 10025b3544b9..f2b7c7290c08 100644
--- a/proxmox-ve-config/src/sdn/fabric/frr.rs
+++ b/proxmox-ve-config/src/sdn/fabric/frr.rs
@@ -1,12 +1,20 @@
 use std::net::{IpAddr, Ipv4Addr};
+
 use tracing;
 
-use proxmox_frr::ser::{self};
+use proxmox_frr::ser::openfabric::{OpenfabricInterface, OpenfabricRouter, OpenfabricRouterName};
+use proxmox_frr::ser::ospf::{self, OspfInterface, OspfRouter};
+use proxmox_frr::ser::route_map::{
+    AccessAction, AccessListName, AccessListOrPrefixList, RouteMapEntry, RouteMapMatch,
+    RouteMapMatchInner, RouteMapName, RouteMapSet,
+};
+use proxmox_frr::ser::{
+    self, FrrConfig, FrrProtocol, FrrWord, Interface, InterfaceName, IpProtocolRouteMap,
+};
 use proxmox_network_types::ip_address::Cidr;
 use proxmox_sdn_types::net::Net;
 
 use crate::common::valid::Valid;
-
 use crate::sdn::fabric::section_config::protocol::{
     openfabric::{OpenfabricInterfaceProperties, OpenfabricProperties},
     ospf::OspfInterfaceProperties,
@@ -17,11 +25,11 @@ use crate::sdn::fabric::{FabricConfig, FabricEntry};
 /// Constructs the FRR config from the the passed [`Valid<FabricConfig>`].
 ///
 /// Iterates over the [`FabricConfig`] and constructs all the FRR routers, interfaces, route-maps,
-/// etc. which area all appended to the passed [`FrrConfig`].
+/// etc.
 pub fn build_fabric(
     current_node: NodeId,
     config: Valid<FabricConfig>,
-    frr_config: &mut ser::FrrConfig,
+    frr_config: &mut FrrConfig,
 ) -> Result<(), anyhow::Error> {
     let mut routemap_seq = 100;
     let mut current_router_id: Option<Ipv4Addr> = None;
@@ -48,7 +56,15 @@ pub fn build_fabric(
                     .as_ref()
                     .ok_or_else(|| anyhow::anyhow!("no IPv4 or IPv6 set for node"))?;
                 let (router_name, router_item) = build_openfabric_router(fabric_id, net.clone())?;
-                frr_config.router.insert(router_name, router_item);
+
+                if frr_config
+                    .openfabric
+                    .router
+                    .insert(router_name, router_item)
+                    .is_some()
+                {
+                    tracing::error!("duplicate OpenFabric router");
+                }
 
                 // Create dummy interface for fabric
                 let (interface, interface_name) = build_openfabric_dummy_interface(
@@ -58,6 +74,7 @@ pub fn build_fabric(
                 )?;
 
                 if frr_config
+                    .openfabric
                     .interfaces
                     .insert(interface_name, interface)
                     .is_some()
@@ -79,6 +96,7 @@ pub fn build_fabric(
                     )?;
 
                     if frr_config
+                        .openfabric
                         .interfaces
                         .insert(interface_name, interface)
                         .is_some()
@@ -91,70 +109,85 @@ pub fn build_fabric(
                     let rule = ser::route_map::AccessListRule {
                         action: ser::route_map::AccessAction::Permit,
                         network: Cidr::from(ipv4cidr),
+                        is_ipv6: false,
                         seq: None,
                     };
-                    let access_list_name = ser::route_map::AccessListName::new(format!(
-                        "pve_openfabric_{}_ips",
-                        fabric_id
-                    ));
-                    frr_config.access_lists.push(ser::route_map::AccessList {
-                        name: access_list_name,
-                        rules: vec![rule],
-                    });
+                    let access_list_name =
+                        AccessListName::new(format!("pve_openfabric_{}_ips", fabric_id));
+                    frr_config.access_lists.insert(access_list_name, vec![rule]);
                 }
                 if let Some(ipv6cidr) = fabric.ip6_prefix() {
                     let rule = ser::route_map::AccessListRule {
                         action: ser::route_map::AccessAction::Permit,
                         network: Cidr::from(ipv6cidr),
+                        is_ipv6: true,
                         seq: None,
                     };
-                    let access_list_name = ser::route_map::AccessListName::new(format!(
-                        "pve_openfabric_{}_ip6s",
-                        fabric_id
-                    ));
-                    frr_config.access_lists.push(ser::route_map::AccessList {
-                        name: access_list_name,
-                        rules: vec![rule],
-                    });
+                    let access_list_name =
+                        AccessListName::new(format!("pve_openfabric_{}_ip6s", fabric_id));
+                    frr_config.access_lists.insert(access_list_name, vec![rule]);
                 }
 
                 if let Some(ipv4) = node.ip() {
                     // create route-map
-                    frr_config.routemaps.push(build_openfabric_routemap(
-                        fabric_id,
-                        IpAddr::V4(ipv4),
-                        routemap_seq,
-                    ));
-                    routemap_seq += 10;
+                    let (routemap_name, routemap_rule) =
+                        build_openfabric_routemap(fabric_id, IpAddr::V4(ipv4), routemap_seq);
+
+                    if let Some(routemap) = frr_config.routemaps.get_mut(&routemap_name) {
+                        routemap.push(routemap_rule)
+                    } else {
+                        frr_config
+                            .routemaps
+                            .insert(routemap_name.clone(), vec![routemap_rule]);
+                    }
 
-                    let protocol_routemap = ser::route_map::ProtocolRouteMap {
-                        is_ipv6: false,
-                        protocol: ser::route_map::ProtocolType::Openfabric,
-                        routemap_name: ser::route_map::RouteMapName::new(
-                            "pve_openfabric".to_owned(),
-                        ),
-                    };
+                    routemap_seq += 10;
 
-                    frr_config.protocol_routemaps.insert(protocol_routemap);
+                    if let Some(routemap) = frr_config
+                        .protocol_routemaps
+                        .get_mut(&FrrProtocol::Openfabric)
+                    {
+                        routemap.v4 = Some(routemap_name);
+                    } else {
+                        frr_config.protocol_routemaps.insert(
+                            FrrProtocol::Openfabric,
+                            IpProtocolRouteMap {
+                                v4: Some(routemap_name),
+                                v6: None,
+                            },
+                        );
+                    }
                 }
+
                 if let Some(ipv6) = node.ip6() {
                     // create route-map
-                    frr_config.routemaps.push(build_openfabric_routemap(
-                        fabric_id,
-                        IpAddr::V6(ipv6),
-                        routemap_seq,
-                    ));
-                    routemap_seq += 10;
+                    let (routemap_name, routemap_rule) =
+                        build_openfabric_routemap(fabric_id, IpAddr::V6(ipv6), routemap_seq);
+
+                    if let Some(routemap) = frr_config.routemaps.get_mut(&routemap_name) {
+                        routemap.push(routemap_rule)
+                    } else {
+                        frr_config
+                            .routemaps
+                            .insert(routemap_name.clone(), vec![routemap_rule]);
+                    }
 
-                    let protocol_routemap = ser::route_map::ProtocolRouteMap {
-                        is_ipv6: true,
-                        protocol: ser::route_map::ProtocolType::Openfabric,
-                        routemap_name: ser::route_map::RouteMapName::new(
-                            "pve_openfabric6".to_owned(),
-                        ),
-                    };
+                    routemap_seq += 10;
 
-                    frr_config.protocol_routemaps.insert(protocol_routemap);
+                    if let Some(routemap) = frr_config
+                        .protocol_routemaps
+                        .get_mut(&FrrProtocol::Openfabric)
+                    {
+                        routemap.v6 = Some(routemap_name);
+                    } else {
+                        frr_config.protocol_routemaps.insert(
+                            FrrProtocol::Openfabric,
+                            IpProtocolRouteMap {
+                                v4: None,
+                                v6: Some(routemap_name),
+                            },
+                        );
+                    }
                 }
             }
             FabricEntry::Ospf(ospf_entry) => {
@@ -169,14 +202,17 @@ pub fn build_fabric(
 
                 let frr_word_area = ser::FrrWord::new(fabric.properties().area.to_string())?;
                 let frr_area = ser::ospf::Area::new(frr_word_area)?;
-                let (router_name, router_item) = build_ospf_router(*router_id)?;
-                frr_config.router.insert(router_name, router_item);
+
+                if frr_config.ospf.router.is_none() {
+                    frr_config.ospf.router = Some(build_ospf_router(*router_id)?);
+                }
 
                 // Add dummy interface
                 let (interface, interface_name) =
                     build_ospf_dummy_interface(fabric_id, frr_area.clone())?;
 
                 if frr_config
+                    .ospf
                     .interfaces
                     .insert(interface_name, interface)
                     .is_some()
@@ -191,11 +227,12 @@ pub fn build_fabric(
                         build_ospf_interface(frr_area.clone(), interface)?;
 
                     if frr_config
+                        .ospf
                         .interfaces
                         .insert(interface_name, interface)
                         .is_some()
                     {
-                        tracing::warn!("An interface cannot be in multiple openfabric fabrics");
+                        tracing::warn!("An interface cannot be in multiple ospf fabrics");
                     }
                 }
 
@@ -207,53 +244,59 @@ pub fn build_fabric(
                     network: Cidr::from(
                         fabric.ip_prefix().expect("fabric must have a ipv4 prefix"),
                     ),
+                    is_ipv6: false,
                     seq: None,
                 };
 
-                frr_config.access_lists.push(ser::route_map::AccessList {
-                    name: access_list_name,
-                    rules: vec![rule],
-                });
+                frr_config.access_lists.insert(access_list_name, vec![rule]);
 
-                let routemap = build_ospf_dummy_routemap(
+                let (routemap_name, routemap_rule) = build_ospf_dummy_routemap(
                     fabric_id,
                     node.ip().expect("node must have an ipv4 address"),
                     routemap_seq,
                 )?;
 
                 routemap_seq += 10;
-                frr_config.routemaps.push(routemap);
 
-                let protocol_routemap = ser::route_map::ProtocolRouteMap {
-                    is_ipv6: false,
-                    protocol: ser::route_map::ProtocolType::Ospf,
-                    routemap_name: ser::route_map::RouteMapName::new("pve_ospf".to_owned()),
-                };
+                if let Some(routemap) = frr_config.routemaps.get_mut(&routemap_name) {
+                    routemap.push(routemap_rule)
+                } else {
+                    frr_config
+                        .routemaps
+                        .insert(routemap_name.clone(), vec![routemap_rule]);
+                }
 
-                frr_config.protocol_routemaps.insert(protocol_routemap);
+                if let Some(routemap) = frr_config.protocol_routemaps.get_mut(&FrrProtocol::Ospf) {
+                    routemap.v4 = Some(routemap_name);
+                } else {
+                    frr_config.protocol_routemaps.insert(
+                        FrrProtocol::Ospf,
+                        IpProtocolRouteMap {
+                            v4: Some(routemap_name),
+                            v6: None,
+                        },
+                    );
+                }
             }
         }
     }
+
     Ok(())
 }
 
 /// Helper that builds a OSPF router with a the router_id.
-fn build_ospf_router(router_id: Ipv4Addr) -> Result<(ser::RouterName, ser::Router), anyhow::Error> {
-    let ospf_router = ser::ospf::OspfRouter { router_id };
-    let router_item = ser::Router::Ospf(ospf_router);
-    let router_name = ser::RouterName::Ospf(ser::ospf::OspfRouterName);
-    Ok((router_name, router_item))
+fn build_ospf_router(router_id: Ipv4Addr) -> Result<OspfRouter, anyhow::Error> {
+    Ok(ser::ospf::OspfRouter { router_id })
 }
 
 /// Helper that builds a OpenFabric router from a fabric_id and a [`Net`].
 fn build_openfabric_router(
     fabric_id: &FabricId,
     net: Net,
-) -> Result<(ser::RouterName, ser::Router), anyhow::Error> {
-    let ofr = ser::openfabric::OpenfabricRouter { net };
-    let router_item = ser::Router::Openfabric(ofr);
-    let frr_word_id = ser::FrrWord::new(fabric_id.to_string())?;
-    let router_name = ser::RouterName::Openfabric(frr_word_id.into());
+) -> Result<(OpenfabricRouterName, OpenfabricRouter), anyhow::Error> {
+    let router_item = ser::openfabric::OpenfabricRouter { net };
+    let frr_word_id = FrrWord::new(fabric_id.to_string())?;
+    let router_name = frr_word_id.into();
     Ok((router_name, router_item))
 }
 
@@ -261,10 +304,10 @@ fn build_openfabric_router(
 fn build_ospf_interface(
     area: ser::ospf::Area,
     interface: &OspfInterfaceProperties,
-) -> Result<(ser::Interface, ser::InterfaceName), anyhow::Error> {
+) -> Result<(Interface<OspfInterface>, InterfaceName), anyhow::Error> {
     let frr_interface = ser::ospf::OspfInterface {
         area,
-        // Interfaces are always none-passive
+        // Interfaces are always non-passive
         passive: None,
         network_type: if interface.ip.is_some() {
             None
@@ -273,21 +316,21 @@ fn build_ospf_interface(
         },
     };
 
-    let interface_name = ser::InterfaceName::Ospf(interface.name.as_str().try_into()?);
+    let interface_name = interface.name.as_ref().try_into()?;
     Ok((frr_interface.into(), interface_name))
 }
 
 /// Helper that builds the OSPF dummy interface using the [`FabricId`] and the [`ospf::Area`].
 fn build_ospf_dummy_interface(
     fabric_id: &FabricId,
-    area: ser::ospf::Area,
-) -> Result<(ser::Interface, ser::InterfaceName), anyhow::Error> {
+    area: ospf::Area,
+) -> Result<(Interface<OspfInterface>, InterfaceName), anyhow::Error> {
     let frr_interface = ser::ospf::OspfInterface {
         area,
         passive: Some(true),
         network_type: None,
     };
-    let interface_name = ser::InterfaceName::Openfabric(format!("dummy_{}", fabric_id).try_into()?);
+    let interface_name = format!("dummy_{}", fabric_id).try_into()?;
     Ok((frr_interface.into(), interface_name))
 }
 
@@ -301,8 +344,8 @@ fn build_openfabric_interface(
     fabric_config: &OpenfabricProperties,
     is_ipv4: bool,
     is_ipv6: bool,
-) -> Result<(ser::Interface, ser::InterfaceName), anyhow::Error> {
-    let frr_word = ser::FrrWord::new(fabric_id.to_string())?;
+) -> Result<(Interface<OpenfabricInterface>, InterfaceName), anyhow::Error> {
+    let frr_word = FrrWord::new(fabric_id.to_string())?;
     let mut frr_interface = ser::openfabric::OpenfabricInterface {
         fabric_id: frr_word.into(),
         // Every interface is not passive by default
@@ -319,7 +362,7 @@ fn build_openfabric_interface(
     if frr_interface.hello_interval.is_none() {
         frr_interface.hello_interval = fabric_config.hello_interval;
     }
-    let interface_name = ser::InterfaceName::Openfabric(interface.name.as_str().try_into()?);
+    let interface_name = interface.name.as_str().try_into()?;
     Ok((frr_interface.into(), interface_name))
 }
 
@@ -328,18 +371,18 @@ fn build_openfabric_dummy_interface(
     fabric_id: &FabricId,
     is_ipv4: bool,
     is_ipv6: bool,
-) -> Result<(ser::Interface, ser::InterfaceName), anyhow::Error> {
-    let frr_word = ser::FrrWord::new(fabric_id.to_string())?;
+) -> Result<(Interface<OpenfabricInterface>, InterfaceName), anyhow::Error> {
+    let frr_word = FrrWord::new(fabric_id.to_string())?;
     let frr_interface = ser::openfabric::OpenfabricInterface {
         fabric_id: frr_word.into(),
-        hello_interval: None,
         passive: Some(true),
-        csnp_interval: None,
-        hello_multiplier: None,
         is_ipv4,
         is_ipv6,
+        hello_interval: None,
+        csnp_interval: None,
+        hello_multiplier: None,
     };
-    let interface_name = ser::InterfaceName::Openfabric(format!("dummy_{}", fabric_id).try_into()?);
+    let interface_name = format!("dummy_{}", fabric_id).try_into()?;
     Ok((frr_interface.into(), interface_name))
 }
 
@@ -348,29 +391,32 @@ fn build_openfabric_routemap(
     fabric_id: &FabricId,
     router_ip: IpAddr,
     seq: u32,
-) -> ser::route_map::RouteMap {
+) -> (RouteMapName, RouteMapEntry) {
     let routemap_name = match router_ip {
         IpAddr::V4(_) => ser::route_map::RouteMapName::new("pve_openfabric".to_owned()),
         IpAddr::V6(_) => ser::route_map::RouteMapName::new("pve_openfabric6".to_owned()),
     };
-    ser::route_map::RouteMap {
-        name: routemap_name.clone(),
-        seq,
-        action: ser::route_map::AccessAction::Permit,
-        matches: vec![match router_ip {
-            IpAddr::V4(_) => {
-                ser::route_map::RouteMapMatch::V4(ser::route_map::RouteMapMatchInner::IpAddress(
-                    ser::route_map::AccessListName::new(format!("pve_openfabric_{fabric_id}_ips")),
-                ))
-            }
-            IpAddr::V6(_) => {
-                ser::route_map::RouteMapMatch::V6(ser::route_map::RouteMapMatchInner::IpAddress(
-                    ser::route_map::AccessListName::new(format!("pve_openfabric_{fabric_id}_ip6s")),
-                ))
-            }
-        }],
-        sets: vec![ser::route_map::RouteMapSet::IpSrc(router_ip)],
-    }
+    (
+        routemap_name,
+        RouteMapEntry {
+            seq,
+            action: ser::route_map::AccessAction::Permit,
+            matches: vec![match router_ip {
+                IpAddr::V4(_) => RouteMapMatch::V4(RouteMapMatchInner::Address(
+                    AccessListOrPrefixList::AccessList(AccessListName::new(format!(
+                        "pve_openfabric_{fabric_id}_ips"
+                    ))),
+                )),
+                IpAddr::V6(_) => RouteMapMatch::V6(RouteMapMatchInner::Address(
+                    AccessListOrPrefixList::AccessList(AccessListName::new(format!(
+                        "pve_openfabric_{fabric_id}_ip6s"
+                    ))),
+                )),
+            }],
+            sets: vec![RouteMapSet::Src(router_ip)],
+            custom_frr_config: Vec::new(),
+        },
+    )
 }
 
 /// Helper that builds a RouteMap for the OSPF protocol.
@@ -378,20 +424,20 @@ fn build_ospf_dummy_routemap(
     fabric_id: &FabricId,
     router_ip: Ipv4Addr,
     seq: u32,
-) -> Result<ser::route_map::RouteMap, anyhow::Error> {
+) -> Result<(RouteMapName, RouteMapEntry), anyhow::Error> {
     let routemap_name = ser::route_map::RouteMapName::new("pve_ospf".to_owned());
     // create route-map
-    let routemap = ser::route_map::RouteMap {
-        name: routemap_name.clone(),
+    let routemap = RouteMapEntry {
         seq,
-        action: ser::route_map::AccessAction::Permit,
-        matches: vec![ser::route_map::RouteMapMatch::V4(
-            ser::route_map::RouteMapMatchInner::IpAddress(ser::route_map::AccessListName::new(
-                format!("pve_ospf_{fabric_id}_ips"),
-            )),
-        )],
-        sets: vec![ser::route_map::RouteMapSet::IpSrc(IpAddr::from(router_ip))],
+        action: AccessAction::Permit,
+        matches: vec![RouteMapMatch::V4(RouteMapMatchInner::Address(
+            AccessListOrPrefixList::AccessList(AccessListName::new(format!(
+                "pve_ospf_{fabric_id}_ips"
+            ))),
+        ))],
+        sets: vec![RouteMapSet::Src(IpAddr::from(router_ip))],
+        custom_frr_config: Vec::new(),
     };
 
-    Ok(routemap)
+    Ok((routemap_name, routemap))
 }
diff --git a/proxmox-ve-config/tests/fabric/snapshots/fabric__openfabric_default_pve.snap b/proxmox-ve-config/tests/fabric/snapshots/fabric__openfabric_default_pve.snap
index 98eb50415e36..052a3f7e501c 100644
--- a/proxmox-ve-config/tests/fabric/snapshots/fabric__openfabric_default_pve.snap
+++ b/proxmox-ve-config/tests/fabric/snapshots/fabric__openfabric_default_pve.snap
@@ -3,6 +3,7 @@ source: proxmox-ve-config/tests/fabric/main.rs
 expression: output
 snapshot_kind: text
 ---
+!
 router openfabric uwu
  net 49.0001.1921.6800.2008.00
 exit
@@ -31,4 +32,3 @@ route-map pve_openfabric permit 100
 exit
 !
 ip protocol openfabric route-map pve_openfabric
-!
diff --git a/proxmox-ve-config/tests/fabric/snapshots/fabric__openfabric_default_pve1.snap b/proxmox-ve-config/tests/fabric/snapshots/fabric__openfabric_default_pve1.snap
index 4453ac49377f..f456e819098a 100644
--- a/proxmox-ve-config/tests/fabric/snapshots/fabric__openfabric_default_pve1.snap
+++ b/proxmox-ve-config/tests/fabric/snapshots/fabric__openfabric_default_pve1.snap
@@ -3,6 +3,7 @@ source: proxmox-ve-config/tests/fabric/main.rs
 expression: output
 snapshot_kind: text
 ---
+!
 router openfabric uwu
  net 49.0001.1921.6800.2009.00
 exit
@@ -30,4 +31,3 @@ route-map pve_openfabric permit 100
 exit
 !
 ip protocol openfabric route-map pve_openfabric
-!
diff --git a/proxmox-ve-config/tests/fabric/snapshots/fabric__openfabric_dualstack_pve.snap b/proxmox-ve-config/tests/fabric/snapshots/fabric__openfabric_dualstack_pve.snap
index 48ac9092045e..ae81da3a4a00 100644
--- a/proxmox-ve-config/tests/fabric/snapshots/fabric__openfabric_dualstack_pve.snap
+++ b/proxmox-ve-config/tests/fabric/snapshots/fabric__openfabric_dualstack_pve.snap
@@ -1,35 +1,35 @@
 ---
 source: proxmox-ve-config/tests/fabric/main.rs
 expression: output
-snapshot_kind: text
 ---
+!
 router openfabric uwu
  net 49.0001.1921.6800.2008.00
 exit
 !
 interface dummy_uwu
- ipv6 router openfabric uwu
  ip router openfabric uwu
+ ipv6 router openfabric uwu
  openfabric passive
 exit
 !
 interface ens19
- ipv6 router openfabric uwu
  ip router openfabric uwu
+ ipv6 router openfabric uwu
  openfabric hello-interval 4
 exit
 !
 interface ens20
- ipv6 router openfabric uwu
  ip router openfabric uwu
+ ipv6 router openfabric uwu
  openfabric hello-interval 4
  openfabric hello-multiplier 50
 exit
 !
-access-list pve_openfabric_uwu_ips permit 192.168.2.0/24
-!
 ipv6 access-list pve_openfabric_uwu_ip6s permit 2001:db8::/64
 !
+access-list pve_openfabric_uwu_ips permit 192.168.2.0/24
+!
 route-map pve_openfabric permit 100
  match ip address pve_openfabric_uwu_ips
  set src 192.168.2.8
@@ -43,4 +43,3 @@ exit
 ip protocol openfabric route-map pve_openfabric
 !
 ipv6 protocol openfabric route-map pve_openfabric6
-!
diff --git a/proxmox-ve-config/tests/fabric/snapshots/fabric__openfabric_ipv6_only_pve.snap b/proxmox-ve-config/tests/fabric/snapshots/fabric__openfabric_ipv6_only_pve.snap
index d7ab1d7e2a61..21c75e4f5861 100644
--- a/proxmox-ve-config/tests/fabric/snapshots/fabric__openfabric_ipv6_only_pve.snap
+++ b/proxmox-ve-config/tests/fabric/snapshots/fabric__openfabric_ipv6_only_pve.snap
@@ -1,8 +1,8 @@
 ---
 source: proxmox-ve-config/tests/fabric/main.rs
 expression: output
-snapshot_kind: text
 ---
+!
 router openfabric uwu
  net 49.0001.0000.0000.000a.00
 exit
@@ -30,5 +30,5 @@ route-map pve_openfabric6 permit 100
  set src a:b::a
 exit
 !
-ipv6 protocol openfabric route-map pve_openfabric6
 !
+ipv6 protocol openfabric route-map pve_openfabric6
diff --git a/proxmox-ve-config/tests/fabric/snapshots/fabric__openfabric_multi_fabric_pve1.snap b/proxmox-ve-config/tests/fabric/snapshots/fabric__openfabric_multi_fabric_pve1.snap
index ad6c6db8eb8b..5e0c18eb2493 100644
--- a/proxmox-ve-config/tests/fabric/snapshots/fabric__openfabric_multi_fabric_pve1.snap
+++ b/proxmox-ve-config/tests/fabric/snapshots/fabric__openfabric_multi_fabric_pve1.snap
@@ -3,6 +3,7 @@ source: proxmox-ve-config/tests/fabric/main.rs
 expression: output
 snapshot_kind: text
 ---
+!
 router openfabric test1
  net 49.0001.1921.6800.2009.00
 exit
@@ -46,4 +47,3 @@ route-map pve_openfabric permit 110
 exit
 !
 ip protocol openfabric route-map pve_openfabric
-!
diff --git a/proxmox-ve-config/tests/fabric/snapshots/fabric__ospf_default_pve.snap b/proxmox-ve-config/tests/fabric/snapshots/fabric__ospf_default_pve.snap
index a303f31f3d1a..ee47866edd67 100644
--- a/proxmox-ve-config/tests/fabric/snapshots/fabric__ospf_default_pve.snap
+++ b/proxmox-ve-config/tests/fabric/snapshots/fabric__ospf_default_pve.snap
@@ -3,6 +3,7 @@ source: proxmox-ve-config/tests/fabric/main.rs
 expression: output
 snapshot_kind: text
 ---
+!
 router ospf
  ospf router-id 10.10.10.1
 exit
@@ -29,4 +30,3 @@ route-map pve_ospf permit 100
 exit
 !
 ip protocol ospf route-map pve_ospf
-!
diff --git a/proxmox-ve-config/tests/fabric/snapshots/fabric__ospf_default_pve1.snap b/proxmox-ve-config/tests/fabric/snapshots/fabric__ospf_default_pve1.snap
index 46c30b22abdf..209f3406757b 100644
--- a/proxmox-ve-config/tests/fabric/snapshots/fabric__ospf_default_pve1.snap
+++ b/proxmox-ve-config/tests/fabric/snapshots/fabric__ospf_default_pve1.snap
@@ -3,6 +3,7 @@ source: proxmox-ve-config/tests/fabric/main.rs
 expression: output
 snapshot_kind: text
 ---
+!
 router ospf
  ospf router-id 10.10.10.2
 exit
@@ -25,4 +26,3 @@ route-map pve_ospf permit 100
 exit
 !
 ip protocol ospf route-map pve_ospf
-!
diff --git a/proxmox-ve-config/tests/fabric/snapshots/fabric__ospf_multi_fabric_pve1.snap b/proxmox-ve-config/tests/fabric/snapshots/fabric__ospf_multi_fabric_pve1.snap
index 1d2a7c3c272d..225d60bf7edb 100644
--- a/proxmox-ve-config/tests/fabric/snapshots/fabric__ospf_multi_fabric_pve1.snap
+++ b/proxmox-ve-config/tests/fabric/snapshots/fabric__ospf_multi_fabric_pve1.snap
@@ -3,6 +3,7 @@ source: proxmox-ve-config/tests/fabric/main.rs
 expression: output
 snapshot_kind: text
 ---
+!
 router ospf
  ospf router-id 192.168.1.9
 exit
@@ -42,4 +43,3 @@ route-map pve_ospf permit 110
 exit
 !
 ip protocol ospf route-map pve_ospf
-!
-- 
2.47.3





  parent reply	other threads:[~2026-03-02 12:57 UTC|newest]

Thread overview: 21+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-03-02 12:55 [PATCH manager/network/proxmox{-ve-rs,-perl-rs} v2 00/19] Generate frr config using jinja templates and rust types Gabriel Goller
2026-03-02 12:55 ` [PATCH proxmox-ve-rs v2 1/8] ve-config: firewall: cargo fmt Gabriel Goller
2026-03-02 12:55 ` [PATCH proxmox-ve-rs v2 2/8] frr: add proxmox-frr-templates package that contains templates Gabriel Goller
2026-03-02 12:55 ` [PATCH proxmox-ve-rs v2 3/8] ve-config: remove FrrConfigBuilder struct Gabriel Goller
2026-03-02 12:55 ` [PATCH proxmox-ve-rs v2 4/8] sdn-types: support variable-length NET identifier Gabriel Goller
2026-03-02 12:55 ` Gabriel Goller [this message]
2026-03-02 12:55 ` [PATCH proxmox-ve-rs v2 6/8] frr: add isis configuration and templates Gabriel Goller
2026-03-02 12:55 ` [PATCH proxmox-ve-rs v2 7/8] frr: support custom frr configuration lines Gabriel Goller
2026-03-02 12:55 ` [PATCH proxmox-ve-rs v2 8/8] frr: add bgp support with templates and serialization Gabriel Goller
2026-03-02 12:55 ` [PATCH proxmox-perl-rs v2 1/1] sdn: add function to generate the frr config for all daemons Gabriel Goller
2026-03-02 12:55 ` [PATCH pve-network v2 1/9] sdn: remove duplicate comment line '!' in frr config Gabriel Goller
2026-03-02 12:55 ` [PATCH pve-network v2 2/9] sdn: tests: add missing comment " Gabriel Goller
2026-03-02 12:55 ` [PATCH pve-network v2 3/9] tests: use Test::Differences to make test assertions Gabriel Goller
2026-03-02 12:55 ` [PATCH pve-network v2 4/9] sdn: write structured frr config that can be rendered using templates Gabriel Goller
2026-03-02 12:55 ` [PATCH pve-network v2 5/9] tests: rearrange some statements in the frr config Gabriel Goller
2026-03-02 12:55 ` [PATCH pve-network v2 6/9] sdn: adjust frr.conf.local merging to rust template types Gabriel Goller
2026-03-03 15:19   ` Stefan Hanreich
2026-03-02 12:55 ` [PATCH pve-network v2 7/9] api: add dry-run endpoint for sdn apply to preview changes Gabriel Goller
2026-03-02 12:55 ` [PATCH pve-network v2 8/9] test: add test for frr.conf.local merging Gabriel Goller
2026-03-02 12:55 ` [PATCH pve-network v2 9/9] test: bgp: add some various integration tests Gabriel Goller
2026-03-02 12:55 ` [PATCH pve-manager v2 1/1] sdn: add dry-run diff view for sdn apply Gabriel Goller

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20260302125701.196916-6-g.goller@proxmox.com \
    --to=g.goller@proxmox.com \
    --cc=pve-devel@lists.proxmox.com \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal