public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
* [PATCH docs/manager/network/proxmox{-ve-rs,-perl-rs} 00/23] Generate frr config using jinja templates and rust types
@ 2026-02-03 16:01 Gabriel Goller
  2026-02-03 16:01 ` [PATCH proxmox-ve-rs 1/9] ve-config: firewall: cargo fmt Gabriel Goller
                   ` (22 more replies)
  0 siblings, 23 replies; 24+ messages in thread
From: Gabriel Goller @ 2026-02-03 16:01 UTC (permalink / raw)
  To: pve-devel

Previously we generated the frr config using one big perl hash, where every
controller-plugin and zone-plugin would push their stuff. This is not pretty
and also tricky to unite with our new rust-based fabrics. Furthermore the only
way to edit or override the frr config is currently the frr.conf.local file,
which is merged with the perl-hash in a very janky manner, which has sprouted
numerous forum threads. The main problem with the frr.conf.local is the limited
control which the user has as to where the override or additional config gets
placed. There are also a few config overrides or additions to frr.conf.local
that are currently impossible or generate invalid frr config.

To improve this we now ship templates, which we use to generate the frr config.
This is the way it is done in e.g. sonic and vyos. These jinja2 templates are
then populated using rust-structs. We changed the perl code to generate
bgp/evpn and isis config that can be deserialized by the rust types and then
rendered into a frr configuration using the templates.

# Versioning

The templates are in the proxmox-frr-templates debian package which, when
installed, copies the template into `/usr/share/proxmox-frr/templates`, where
they are read from using `include_str!`. This means the proxmox-frr-templates
package is only used for development and to version the templates. The user
only gets them in the binary of proxmox-frr (which, by extension, is in the
perl-rs shared library).

# User Override

In order to extract these templates from the binary we introduce a new cli
tool: pvesdn. Using pvesdn the user can show the currently packaged template
file, automatically create an override file `/etc/proxmox-frr/templates/`, show
the difference between the override file and the packaged file and reset the
override files.

libpve-network (pve-network) also has an additional debian/postinst script,
which registers the override files with `ucf` and makes a three-way-merge with
the override-file, the old packaged file and the updated packaged file. This
way, when the templates are updated the user can choose "edit", "maintainer's
version" or "my version".

# frr.conf.local

The frr.conf.local merging code has been adjusted so that the frr.conf.local
still works as before.


Also thanks to Stefan Hanreich as always :)

proxmox-ve-rs:

Gabriel Goller (9):
  ve-config: firewall: cargo fmt
  frr: add proxmox-frr-templates package that contains templates
  ve-config: remove FrrConfigBuilder struct
  sdn-types: support variable-length NET identifier
  frr: add template serializer and serialize fabrics using templates
  frr: add isis configuration and templates
  frr: support custom frr configuration lines
  frr: add bgp support with templates and serialization
  frr: store frr template content as a const map

 Makefile                                      |   8 +
 proxmox-frr-templates/.gitignore              |   1 +
 proxmox-frr-templates/Makefile                |  50 +++
 proxmox-frr-templates/debian/changelog        |   5 +
 proxmox-frr-templates/debian/control          |  17 +
 proxmox-frr-templates/debian/copyright        |  18 ++
 .../debian/proxmox-frr-templates.install      |   1 +
 proxmox-frr-templates/debian/rules            |   5 +
 .../templates/access_list.jinja               |   6 +
 .../templates/access_lists.jinja              |   6 +
 .../templates/bgp_router.jinja                | 118 +++++++
 proxmox-frr-templates/templates/bgpd.jinja    |  35 ++
 proxmox-frr-templates/templates/fabricd.jinja |  29 ++
 .../templates/frr.conf.jinja                  |  12 +
 .../templates/interface.jinja                 |   9 +
 .../templates/ip_routes.jinja                 |   8 +
 proxmox-frr-templates/templates/isisd.jinja   |  32 ++
 proxmox-frr-templates/templates/ospfd.jinja   |  18 ++
 .../templates/prefix_lists.jinja              |   6 +
 .../templates/protocol_routemaps.jinja        |  10 +
 .../templates/route_maps.jinja                |  20 ++
 proxmox-frr/Cargo.toml                        |   4 +
 proxmox-frr/debian/control                    |  14 +
 proxmox-frr/src/ser/bgp.rs                    | 184 +++++++++++
 proxmox-frr/src/ser/isis.rs                   |  49 +++
 proxmox-frr/src/ser/mod.rs                    | 294 ++++++++---------
 proxmox-frr/src/ser/openfabric.rs             |  26 +-
 proxmox-frr/src/ser/ospf.rs                   |  56 +---
 proxmox-frr/src/ser/route_map.rs              | 175 ++++------
 proxmox-frr/src/ser/serializer.rs             | 242 +++-----------
 proxmox-sdn-types/src/net.rs                  | 140 +++++++-
 proxmox-ve-config/src/common/valid.rs         |   4 +-
 proxmox-ve-config/src/firewall/cluster.rs     |   3 +-
 proxmox-ve-config/src/firewall/types/ipset.rs |   2 +-
 proxmox-ve-config/src/sdn/fabric/frr.rs       | 302 ++++++++++--------
 proxmox-ve-config/src/sdn/frr.rs              |  42 ---
 proxmox-ve-config/src/sdn/mod.rs              |   2 -
 proxmox-ve-config/tests/fabric/main.rs        | 101 +++---
 .../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 +-
 46 files changed, 1339 insertions(+), 744 deletions(-)
 create mode 100644 proxmox-frr-templates/.gitignore
 create mode 100644 proxmox-frr-templates/Makefile
 create mode 100644 proxmox-frr-templates/debian/changelog
 create mode 100644 proxmox-frr-templates/debian/control
 create mode 100644 proxmox-frr-templates/debian/copyright
 create mode 100644 proxmox-frr-templates/debian/proxmox-frr-templates.install
 create mode 100755 proxmox-frr-templates/debian/rules
 create mode 100644 proxmox-frr-templates/templates/access_list.jinja
 create mode 100644 proxmox-frr-templates/templates/access_lists.jinja
 create mode 100644 proxmox-frr-templates/templates/bgp_router.jinja
 create mode 100644 proxmox-frr-templates/templates/bgpd.jinja
 create mode 100644 proxmox-frr-templates/templates/fabricd.jinja
 create mode 100644 proxmox-frr-templates/templates/frr.conf.jinja
 create mode 100644 proxmox-frr-templates/templates/interface.jinja
 create mode 100644 proxmox-frr-templates/templates/ip_routes.jinja
 create mode 100644 proxmox-frr-templates/templates/isisd.jinja
 create mode 100644 proxmox-frr-templates/templates/ospfd.jinja
 create mode 100644 proxmox-frr-templates/templates/prefix_lists.jinja
 create mode 100644 proxmox-frr-templates/templates/protocol_routemaps.jinja
 create mode 100644 proxmox-frr-templates/templates/route_maps.jinja
 create mode 100644 proxmox-frr/src/ser/bgp.rs
 create mode 100644 proxmox-frr/src/ser/isis.rs
 delete mode 100644 proxmox-ve-config/src/sdn/frr.rs


proxmox-perl-rs:

Gabriel Goller (2):
  sdn: add function to generate the frr config for all daemons
  sdn: add method to get a frr template

 pve-rs/Makefile                    |  1 +
 pve-rs/src/bindings/sdn/fabrics.rs | 25 +++------------------
 pve-rs/src/bindings/sdn/mod.rs     | 35 ++++++++++++++++++++++++++++++
 3 files changed, 39 insertions(+), 22 deletions(-)


pve-network:

Gabriel Goller (10):
  sdn: remove duplicate comment line '!' in frr config
  sdn: tests: add missing comment '!' in frr config
  tests: use Test::Differences to make test assertions
  sdn: write structured frr config that can be rendered using templates
  tests: rearrange some statements in the frr config
  sdn: adjust frr.conf.local merging to rust template types
  cli: add pvesdn cli tool for managing frr template overrides
  debian: handle user modifications to FRR templates via ucf
  api: add dry-run endpoint for sdn apply to preview changes
  test: add test for frr.conf.local merging

 debian/control                                |   2 +
 debian/libpve-network-api-perl.install        |   1 +
 debian/libpve-network-perl.install            |   4 +
 debian/libpve-network-perl.postinst           |  34 +-
 debian/libpve-network-perl.postrm             |  33 ++
 src/Makefile                                  |   2 +-
 src/PVE/API2/Network/SDN.pm                   |  67 ++++
 src/PVE/CLI/Makefile                          |   7 +
 src/PVE/CLI/pvesdn.pm                         | 252 ++++++++++++
 src/PVE/Makefile                              |   1 +
 src/PVE/Network/SDN.pm                        |  20 +-
 src/PVE/Network/SDN/Controllers/BgpPlugin.pm  | 104 ++---
 src/PVE/Network/SDN/Controllers/EvpnPlugin.pm | 372 +++++++++---------
 src/PVE/Network/SDN/Controllers/IsisPlugin.pm |  28 +-
 src/PVE/Network/SDN/Fabrics.pm                |  14 +-
 src/PVE/Network/SDN/Frr.pm                    | 366 +++++++++--------
 src/bin/Makefile                              |  69 ++++
 src/bin/pvesdn                                |   8 +
 src/test/run_test_dns.pl                      |  15 +-
 src/test/run_test_ipams.pl                    |  13 +-
 src/test/run_test_subnets.pl                  |  31 +-
 src/test/run_test_vnets_blackbox.pl           |  23 +-
 src/test/run_test_zones.pl                    |  21 +-
 .../expected_controller_config                |   1 -
 .../expected_controller_config                |   1 -
 .../evpn/ebgp/expected_controller_config      |   1 -
 .../ebgp_loopback/expected_controller_config  |   3 +-
 .../evpn/exitnode/expected_controller_config  |   1 -
 .../expected_controller_config                |   1 -
 .../expected_controller_config                |   1 -
 .../exitnode_snat/expected_controller_config  |   1 -
 .../expected_controller_config                |   1 -
 .../expected_controller_config                |  61 +++
 .../frr_local_merge/expected_sdn_interfaces   |  42 ++
 .../zones/evpn/frr_local_merge/frr.conf.local |  30 ++
 .../zones/evpn/frr_local_merge/interfaces     |   7 +
 .../zones/evpn/frr_local_merge/sdn_config     |  24 ++
 .../evpn/ipv4/expected_controller_config      |   1 -
 .../evpn/ipv4ipv6/expected_controller_config  |   1 -
 .../expected_controller_config                |   1 -
 .../evpn/ipv6/expected_controller_config      |   1 -
 .../ipv6underlay/expected_controller_config   |   1 -
 .../evpn/isis/expected_controller_config      |  15 +-
 .../isis_loopback/expected_controller_config  |  15 +-
 .../expected_controller_config                |  13 +-
 .../expected_controller_config                |   3 +-
 .../multiplezones/expected_controller_config  |   1 -
 .../expected_controller_config                |  13 +-
 .../ospf_fabric/expected_controller_config    |  13 +-
 .../evpn/rt_import/expected_controller_config |   1 -
 .../evpn/vxlanport/expected_controller_config |   1 -
 51 files changed, 1192 insertions(+), 550 deletions(-)
 create mode 100644 debian/libpve-network-perl.postrm
 create mode 100644 src/PVE/CLI/Makefile
 create mode 100644 src/PVE/CLI/pvesdn.pm
 create mode 100644 src/bin/Makefile
 create mode 100755 src/bin/pvesdn
 create mode 100644 src/test/zones/evpn/frr_local_merge/expected_controller_config
 create mode 100644 src/test/zones/evpn/frr_local_merge/expected_sdn_interfaces
 create mode 100644 src/test/zones/evpn/frr_local_merge/frr.conf.local
 create mode 100644 src/test/zones/evpn/frr_local_merge/interfaces
 create mode 100644 src/test/zones/evpn/frr_local_merge/sdn_config


pve-manager:

Gabriel Goller (1):
  sdn: add dry-run view for sdn apply

 www/manager6/Makefile           |   1 +
 www/manager6/sdn/SdnDiffView.js | 123 ++++++++++++++++++++++++++++++++
 www/manager6/sdn/StatusView.js  |   8 +++
 3 files changed, 132 insertions(+)
 create mode 100644 www/manager6/sdn/SdnDiffView.js


pve-docs:

Gabriel Goller (1):
  docs: add man page for the `pvesdn` cli

 pvesdn.1-synopsis.adoc | 39 +++++++++++++++++++++++++++++++++++++++
 pvesdn.adoc            | 24 +++++++++++++++++++++++-
 2 files changed, 62 insertions(+), 1 deletion(-)
 create mode 100644 pvesdn.1-synopsis.adoc


Summary over all repositories:
  105 files changed, 2764 insertions(+), 1317 deletions(-)

-- 
Generated by git-murpp 0.8.0




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

* [PATCH proxmox-ve-rs 1/9] ve-config: firewall: cargo fmt
  2026-02-03 16:01 [PATCH docs/manager/network/proxmox{-ve-rs,-perl-rs} 00/23] Generate frr config using jinja templates and rust types Gabriel Goller
@ 2026-02-03 16:01 ` Gabriel Goller
  2026-02-03 16:01 ` [PATCH proxmox-ve-rs 2/9] frr: add proxmox-frr-templates package that contains templates Gabriel Goller
                   ` (21 subsequent siblings)
  22 siblings, 0 replies; 24+ messages in thread
From: Gabriel Goller @ 2026-02-03 16:01 UTC (permalink / raw)
  To: pve-devel

Signed-off-by: Gabriel Goller <g.goller@proxmox.com>
---
 proxmox-ve-config/src/firewall/cluster.rs     | 3 ++-
 proxmox-ve-config/src/firewall/types/ipset.rs | 2 +-
 2 files changed, 3 insertions(+), 2 deletions(-)

diff --git a/proxmox-ve-config/src/firewall/cluster.rs b/proxmox-ve-config/src/firewall/cluster.rs
index 69d3bcd6d9cd..d379fe611772 100644
--- a/proxmox-ve-config/src/firewall/cluster.rs
+++ b/proxmox-ve-config/src/firewall/cluster.rs
@@ -147,7 +147,8 @@ mod tests {
         log::{LogLevel, LogRateLimitTimescale},
         rule::{Kind, RuleGroup},
         rule_match::{
-            Icmpv6, Icmpv6Code, Icmpv6Type, IpAddrMatch, IpMatch, Ports, Protocol, RuleMatch, Tcp, Udp
+            Icmpv6, Icmpv6Code, Icmpv6Type, IpAddrMatch, IpMatch, Ports, Protocol, RuleMatch, Tcp,
+            Udp,
         },
     };
 
diff --git a/proxmox-ve-config/src/firewall/types/ipset.rs b/proxmox-ve-config/src/firewall/types/ipset.rs
index 3f93bb28423a..f09b8d82d6aa 100644
--- a/proxmox-ve-config/src/firewall/types/ipset.rs
+++ b/proxmox-ve-config/src/firewall/types/ipset.rs
@@ -313,7 +313,7 @@ impl Ipset {
         Ok(())
     }
 
-    pub fn ipfilter(&self) -> Option<Ipfilter> {
+    pub fn ipfilter(&self) -> Option<Ipfilter<'_>> {
         if self.name.scope() != IpsetScope::Guest {
             return None;
         }
-- 
2.47.3





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

* [PATCH proxmox-ve-rs 2/9] frr: add proxmox-frr-templates package that contains templates
  2026-02-03 16:01 [PATCH docs/manager/network/proxmox{-ve-rs,-perl-rs} 00/23] Generate frr config using jinja templates and rust types Gabriel Goller
  2026-02-03 16:01 ` [PATCH proxmox-ve-rs 1/9] ve-config: firewall: cargo fmt Gabriel Goller
@ 2026-02-03 16:01 ` Gabriel Goller
  2026-02-03 16:01 ` [PATCH proxmox-ve-rs 3/9] ve-config: remove FrrConfigBuilder struct Gabriel Goller
                   ` (20 subsequent siblings)
  22 siblings, 0 replies; 24+ messages in thread
From: Gabriel Goller @ 2026-02-03 16:01 UTC (permalink / raw)
  To: pve-devel

This debian package contains the jinja template files used to generate
the frr config. Currently only the fabrics template files are here, the
rest will be added in later commits. When installing this package the
templates will be installed to `/usr/share/proxmox-frr/templates/`.
`proxmox-frr` will then use `include_str!` to embed the templates into
the binary.

This package will only be published to the `devel` channel.

We will be able to retrieve the templates using the `pvesdn` cli, which
will make a call to perl-rs.

Co-authored-by: Stefan Hanreich <s.hanreich@proxmox.com>
Signed-off-by: Gabriel Goller <g.goller@proxmox.com>
---
 Makefile                                      |  8 +++
 proxmox-frr-templates/.gitignore              |  1 +
 proxmox-frr-templates/Makefile                | 50 +++++++++++++++++++
 proxmox-frr-templates/debian/changelog        |  5 ++
 proxmox-frr-templates/debian/control          | 17 +++++++
 proxmox-frr-templates/debian/copyright        | 18 +++++++
 .../debian/proxmox-frr-templates.install      |  1 +
 proxmox-frr-templates/debian/rules            |  5 ++
 .../templates/access_list.jinja               |  6 +++
 .../templates/access_lists.jinja              |  6 +++
 proxmox-frr-templates/templates/fabricd.jinja | 29 +++++++++++
 .../templates/frr.conf.jinja                  |  5 ++
 .../templates/interface.jinja                 |  9 ++++
 proxmox-frr-templates/templates/ospfd.jinja   | 18 +++++++
 .../templates/protocol_routemaps.jinja        | 10 ++++
 .../templates/route_maps.jinja                | 20 ++++++++
 16 files changed, 208 insertions(+)
 create mode 100644 proxmox-frr-templates/.gitignore
 create mode 100644 proxmox-frr-templates/Makefile
 create mode 100644 proxmox-frr-templates/debian/changelog
 create mode 100644 proxmox-frr-templates/debian/control
 create mode 100644 proxmox-frr-templates/debian/copyright
 create mode 100644 proxmox-frr-templates/debian/proxmox-frr-templates.install
 create mode 100755 proxmox-frr-templates/debian/rules
 create mode 100644 proxmox-frr-templates/templates/access_list.jinja
 create mode 100644 proxmox-frr-templates/templates/access_lists.jinja
 create mode 100644 proxmox-frr-templates/templates/fabricd.jinja
 create mode 100644 proxmox-frr-templates/templates/frr.conf.jinja
 create mode 100644 proxmox-frr-templates/templates/interface.jinja
 create mode 100644 proxmox-frr-templates/templates/ospfd.jinja
 create mode 100644 proxmox-frr-templates/templates/protocol_routemaps.jinja
 create mode 100644 proxmox-frr-templates/templates/route_maps.jinja

diff --git a/Makefile b/Makefile
index 812bc8e691bf..58e4475161a1 100644
--- a/Makefile
+++ b/Makefile
@@ -2,6 +2,8 @@
 
 CRATES != echo proxmox-*/Cargo.toml | sed -e 's|/Cargo.toml||g'
 
+BUILDDIR = build
+
 # By default we just run checks:
 .PHONY: all
 all: check
@@ -25,6 +27,12 @@ dinstall:
 	$(MAKE) deb
 	sudo -k dpkg -i build/librust-*.deb
 
+proxmox-frr-templates-deb:
+	mkdir -p "${BUILDDIR}/proxmox-frr-templates"
+	cp -r proxmox-frr-templates/* "${BUILDDIR}/proxmox-frr-templates"
+	cd "${BUILDDIR}/proxmox-frr-templates"; dpkg-buildpackage -b -uc -us
+	touch $@
+
 %-deb:
 	./build.sh $*
 	touch $@
diff --git a/proxmox-frr-templates/.gitignore b/proxmox-frr-templates/.gitignore
new file mode 100644
index 000000000000..796b96d1c402
--- /dev/null
+++ b/proxmox-frr-templates/.gitignore
@@ -0,0 +1 @@
+/build
diff --git a/proxmox-frr-templates/Makefile b/proxmox-frr-templates/Makefile
new file mode 100644
index 000000000000..0b010ed71e7a
--- /dev/null
+++ b/proxmox-frr-templates/Makefile
@@ -0,0 +1,50 @@
+include /usr/share/dpkg/default.mk
+
+PACKAGE=proxmox-frr-templates
+
+GITVERSION:=$(shell git rev-parse HEAD)
+
+BUILDDIR ?= build/$(PACKAGE)-$(DEB_VERSION)
+DSC=$(PACKAGE)_$(DEB_VERSION).dsc
+
+PVE_DEB=$(PACKAGE)_$(DEB_VERSION_UPSTREAM_REVISION)_all.deb
+
+DEBS=$(PVE_DEB)
+
+all: deb
+deb: $(DEBS)
+
+$(BUILDDIR):
+	rm -rf $(BUILDDIR)
+	mkdir -p $(BUILDDIR)
+	cp -a debian $(BUILDDIR)/
+	cp -a templates $(BUILDDIR)/ || true
+	echo "git clone git://git.proxmox.com/git/proxmox-ve-rs.git\\ngit checkout $(GITVERSION)" > $(BUILDDIR)/debian/SOURCE
+
+$(PVE_DEB): $(BUILDDIR)
+	cd $(BUILDDIR); dpkg-buildpackage -b -uc -us
+	lintian build/$(DEBS)
+
+dsc: $(DSC)
+	$(MAKE) clean
+	$(MAKE) $(DSC)
+	lintian build/$(DSC)
+
+$(DSC): $(BUILDDIR)
+	cd $(BUILDDIR); dpkg-buildpackage -S -uc -us
+
+sbuild: $(DSC)
+	sbuild build/$(DSC)
+
+.PHONY: upload
+upload: UPLOAD_DIST ?= $(DEB_DISTRIBUTION)
+upload: $(DEBS)
+	tar cf - build/$(DEBS)|ssh repoman@repo.proxmox.com -- upload --product devel --dist $(UPLOAD_DIST)
+
+.PHONY: distclean
+distclean: clean
+
+.PHONY: clean
+clean:
+	rm -rf build
+
diff --git a/proxmox-frr-templates/debian/changelog b/proxmox-frr-templates/debian/changelog
new file mode 100644
index 000000000000..e70d0e12dfa8
--- /dev/null
+++ b/proxmox-frr-templates/debian/changelog
@@ -0,0 +1,5 @@
+proxmox-frr-templates (0.1.0-1) trixie; urgency=medium
+
+  * Initial release.
+
+ -- Proxmox Support Team <support@proxmox.com>  Wed, 21 Jan 2026 15:53:51 +0100
diff --git a/proxmox-frr-templates/debian/control b/proxmox-frr-templates/debian/control
new file mode 100644
index 000000000000..d144d3f5f6b0
--- /dev/null
+++ b/proxmox-frr-templates/debian/control
@@ -0,0 +1,17 @@
+Source: proxmox-frr-templates
+Section: admin
+Priority: important
+Maintainer: Proxmox Support Team <support@proxmox.com>
+Standards-Version: 4.7.2
+Rules-Requires-Root: no
+Build-Depends: debhelper-compat (= 13)
+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
+
+Package: proxmox-frr-templates
+Architecture: all
+Depends: ${misc:Depends}
+Description: Provides template files which are used by the Proxmox VE SDN stack
+ to generate FRR config files.
+
diff --git a/proxmox-frr-templates/debian/copyright b/proxmox-frr-templates/debian/copyright
new file mode 100644
index 000000000000..1ea8a56b4f58
--- /dev/null
+++ b/proxmox-frr-templates/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-frr-templates/debian/proxmox-frr-templates.install b/proxmox-frr-templates/debian/proxmox-frr-templates.install
new file mode 100644
index 000000000000..a8e3df9d7260
--- /dev/null
+++ b/proxmox-frr-templates/debian/proxmox-frr-templates.install
@@ -0,0 +1 @@
+templates/* /usr/share/proxmox-frr/templates
diff --git a/proxmox-frr-templates/debian/rules b/proxmox-frr-templates/debian/rules
new file mode 100755
index 000000000000..abde6ef2247c
--- /dev/null
+++ b/proxmox-frr-templates/debian/rules
@@ -0,0 +1,5 @@
+#!/usr/bin/make -f
+
+%:
+	dh $@
+
diff --git a/proxmox-frr-templates/templates/access_list.jinja b/proxmox-frr-templates/templates/access_list.jinja
new file mode 100644
index 000000000000..5572fb9b0304
--- /dev/null
+++ b/proxmox-frr-templates/templates/access_list.jinja
@@ -0,0 +1,6 @@
+{% for access_list in access_lists %}
+!
+{%  for rule in access_list.rules %}
+{{ "ipv6 " if rule.is_ipv6 }}access-list {{ access_list.name }} {{ ("seq " ~ rule.seq ~ " ") if rule.seq }}{{ rule.action }} {{ rule.network }}
+{%  endfor%}
+{% endfor %}
diff --git a/proxmox-frr-templates/templates/access_lists.jinja b/proxmox-frr-templates/templates/access_lists.jinja
new file mode 100644
index 000000000000..25f27a293529
--- /dev/null
+++ b/proxmox-frr-templates/templates/access_lists.jinja
@@ -0,0 +1,6 @@
+{% for access_list_name, access_list in access_lists | items %}
+!
+{%  for rule in access_list %}
+{{ "ipv6 " if rule.is_ipv6 }}access-list {{ access_list_name }} {{ ("seq " ~ rule.seq ~ " ") if rule.seq }}{{ rule.action }} {{ rule.network }}
+{%  endfor%}
+{% endfor %}
diff --git a/proxmox-frr-templates/templates/fabricd.jinja b/proxmox-frr-templates/templates/fabricd.jinja
new file mode 100644
index 000000000000..4857a24ebab4
--- /dev/null
+++ b/proxmox-frr-templates/templates/fabricd.jinja
@@ -0,0 +1,29 @@
+{% from "interface.jinja" import interface %}
+{% for router_name, router_config in openfabric.router|items %}
+!
+router openfabric {{ router_name }}
+ net {{ router_config.net }}
+exit
+{% endfor %}
+{% for interface_name, interface_config in openfabric.interfaces|items %}
+{% call interface(interface_name, interface_config.addresses) %}
+{% if interface_config.fabric_id and interface_config.is_ipv4 %}
+ ip router openfabric {{ interface_config.fabric_id }}
+{% endif %}
+{% if interface_config.fabric_id and interface_config.is_ipv6 %}
+ ipv6 router openfabric {{ interface_config.fabric_id }}
+{% endif %}
+{% if interface_config.passive %}
+ openfabric passive
+{% endif %}
+{% if interface_config.hello_interval %}
+ openfabric hello-interval {{ interface_config.hello_interval}}
+{% endif %}
+{% if interface_config.hello_multiplier %}
+ openfabric hello-multiplier {{ interface_config.hello_multiplier}}
+{% endif %}
+{% if interface_config.csnp_interval %}
+ openfabric csnp-interval {{ interface_config.csnp_interval}}
+{% endif %}
+{%- endcall %}
+{%- endfor %}
diff --git a/proxmox-frr-templates/templates/frr.conf.jinja b/proxmox-frr-templates/templates/frr.conf.jinja
new file mode 100644
index 000000000000..8458c6a61112
--- /dev/null
+++ b/proxmox-frr-templates/templates/frr.conf.jinja
@@ -0,0 +1,5 @@
+{% include "fabricd.jinja" %}
+{% include "ospfd.jinja" %}
+{% include "access_lists.jinja" %}
+{% include "route_maps.jinja" %}
+{% include "protocol_routemaps.jinja" %}
diff --git a/proxmox-frr-templates/templates/interface.jinja b/proxmox-frr-templates/templates/interface.jinja
new file mode 100644
index 000000000000..c3c3b6c3526f
--- /dev/null
+++ b/proxmox-frr-templates/templates/interface.jinja
@@ -0,0 +1,9 @@
+{% macro interface(name, addresses) %}
+!
+interface {{ name }}
+{% for address in addresses %}
+ ip address {{address}}
+{% endfor %}
+{{ caller() -}}
+exit
+{% endmacro %}
diff --git a/proxmox-frr-templates/templates/ospfd.jinja b/proxmox-frr-templates/templates/ospfd.jinja
new file mode 100644
index 000000000000..8f9e76390f75
--- /dev/null
+++ b/proxmox-frr-templates/templates/ospfd.jinja
@@ -0,0 +1,18 @@
+{% from "interface.jinja" import interface %}
+{% if ospf.router %}
+!
+router ospf
+ ospf router-id {{ ospf.router.router_id }}
+exit
+{% endif %}
+{% for interface_name, interface_config in ospf.interfaces|items %}
+{% call interface(interface_name, interface_config.addresses) %}
+ ip ospf area {{ interface_config.area }}
+{% if interface_config.passive %}
+ ip ospf passive
+{% endif %}
+{% if interface_config.network_type %}
+ ip ospf network {{ interface_config.network_type }}
+{% endif %}
+{% endcall %}
+{% endfor %}
diff --git a/proxmox-frr-templates/templates/protocol_routemaps.jinja b/proxmox-frr-templates/templates/protocol_routemaps.jinja
new file mode 100644
index 000000000000..38c00b6407ee
--- /dev/null
+++ b/proxmox-frr-templates/templates/protocol_routemaps.jinja
@@ -0,0 +1,10 @@
+{% for protocol_name, protocol_routemap in protocol_routemaps | items %}
+!
+{% if protocol_routemap.v4 %}
+ip protocol {{ protocol_name }} route-map {{ protocol_routemap.v4 }}
+{% endif %}
+{% if protocol_routemap.v6 %}
+!
+ipv6 protocol {{ protocol_name }} route-map {{ protocol_routemap.v6 }}
+{% endif %}
+{% endfor %}
diff --git a/proxmox-frr-templates/templates/route_maps.jinja b/proxmox-frr-templates/templates/route_maps.jinja
new file mode 100644
index 000000000000..61fbf3256a19
--- /dev/null
+++ b/proxmox-frr-templates/templates/route_maps.jinja
@@ -0,0 +1,20 @@
+{% for name, routemap_list in routemaps | items %}
+{% for routemap in routemap_list %}
+!
+route-map {{ name }} {{ routemap.action }} {{ routemap.seq }}
+{%  for match in routemap.matches %}
+{%   if match.value.list_type == "prefixlist" %}
+ match {{ match.protocol_type }} {{ match.match_type }} prefix-list {{ match.value.list_name }}
+{%   elif match.value.list_type == "accesslist" %}
+ match {{ match.protocol_type }} {{ match.match_type }} {{ match.value.list_name }}
+{%   endif %}
+{%  endfor %}
+{%  for set in routemap.sets %}
+ set {{ set.set_type }} {{ set.value }}
+{%  endfor %}
+{% for line in routemap.custom_frr_config %}
+{{ line }}
+{% endfor %}
+exit
+{% endfor %}
+{% endfor %}
-- 
2.47.3





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

* [PATCH proxmox-ve-rs 3/9] ve-config: remove FrrConfigBuilder struct
  2026-02-03 16:01 [PATCH docs/manager/network/proxmox{-ve-rs,-perl-rs} 00/23] Generate frr config using jinja templates and rust types Gabriel Goller
  2026-02-03 16:01 ` [PATCH proxmox-ve-rs 1/9] ve-config: firewall: cargo fmt Gabriel Goller
  2026-02-03 16:01 ` [PATCH proxmox-ve-rs 2/9] frr: add proxmox-frr-templates package that contains templates Gabriel Goller
@ 2026-02-03 16:01 ` Gabriel Goller
  2026-02-03 16:01 ` [PATCH proxmox-ve-rs 4/9] sdn-types: support variable-length NET identifier Gabriel Goller
                   ` (19 subsequent siblings)
  22 siblings, 0 replies; 24+ messages in thread
From: Gabriel Goller @ 2026-02-03 16:01 UTC (permalink / raw)
  To: pve-devel

Instead of using the FrrConfigBuilder, derive bon::Builder on FrrConfig
and directly build the FrrConfig there. Update the tests accordingly.

Signed-off-by: Gabriel Goller <g.goller@proxmox.com>
---
 proxmox-ve-config/src/sdn/frr.rs       |  42 ----------
 proxmox-ve-config/src/sdn/mod.rs       |   2 -
 proxmox-ve-config/tests/fabric/main.rs | 101 +++++++++++++++----------
 3 files changed, 61 insertions(+), 84 deletions(-)
 delete mode 100644 proxmox-ve-config/src/sdn/frr.rs

diff --git a/proxmox-ve-config/src/sdn/frr.rs b/proxmox-ve-config/src/sdn/frr.rs
deleted file mode 100644
index 5d4e4b2ebdbd..000000000000
--- a/proxmox-ve-config/src/sdn/frr.rs
+++ /dev/null
@@ -1,42 +0,0 @@
-use std::collections::{BTreeMap, BTreeSet};
-
-use proxmox_frr::ser::FrrConfig;
-
-use crate::common::valid::Valid;
-use crate::sdn::fabric::{section_config::node::NodeId, FabricConfig};
-
-/// Builder that helps constructing the FrrConfig.
-///
-/// The goal is to have one struct collect all the rust-based configurations and then construct the
-/// [`FrrConfig`] from it using the build method. In the future the controller configuration will
-/// be added here as well.
-#[derive(Default)]
-pub struct FrrConfigBuilder {
-    fabrics: Valid<FabricConfig>,
-}
-
-impl FrrConfigBuilder {
-    /// Add fabric configuration to the builder
-    pub fn add_fabrics(mut self, fabric: Valid<FabricConfig>) -> FrrConfigBuilder {
-        self.fabrics = fabric;
-        self
-    }
-
-    /// Build the complete [`FrrConfig`] from this builder configuration given the hostname of the
-    /// node for which we want to build the config. We also inject the common fabric-level options
-    /// into the interfaces here. (e.g. the fabric-level "hello-interval" gets added to every
-    /// interface if there isn't a more specific one.)
-    pub fn build(self, current_node: NodeId) -> Result<FrrConfig, anyhow::Error> {
-        let mut frr_config = FrrConfig {
-            router: BTreeMap::new(),
-            interfaces: BTreeMap::new(),
-            access_lists: Vec::new(),
-            routemaps: Vec::new(),
-            protocol_routemaps: BTreeSet::new(),
-        };
-
-        crate::sdn::fabric::frr::build_fabric(current_node, self.fabrics, &mut frr_config)?;
-
-        Ok(frr_config)
-    }
-}
diff --git a/proxmox-ve-config/src/sdn/mod.rs b/proxmox-ve-config/src/sdn/mod.rs
index 4586c56358ef..86dcd938c92c 100644
--- a/proxmox-ve-config/src/sdn/mod.rs
+++ b/proxmox-ve-config/src/sdn/mod.rs
@@ -1,7 +1,5 @@
 pub mod config;
 pub mod fabric;
-#[cfg(feature = "frr")]
-pub mod frr;
 pub mod ipam;
 
 use std::{error::Error, fmt::Display, str::FromStr};
diff --git a/proxmox-ve-config/tests/fabric/main.rs b/proxmox-ve-config/tests/fabric/main.rs
index 09629d406449..755592ff7482 100644
--- a/proxmox-ve-config/tests/fabric/main.rs
+++ b/proxmox-ve-config/tests/fabric/main.rs
@@ -1,8 +1,7 @@
 #![cfg(feature = "frr")]
-use proxmox_frr::ser::serializer::dump;
-use proxmox_ve_config::sdn::{
-    fabric::{section_config::node::NodeId, FabricConfig},
-    frr::FrrConfigBuilder,
+use proxmox_frr::ser::{serializer::dump, FrrConfig};
+use proxmox_ve_config::sdn::fabric::{
+    frr::build_fabric, section_config::node::NodeId, FabricConfig,
 };
 
 mod helper;
@@ -17,20 +16,25 @@ mod helper;
 #[test]
 fn openfabric_default() {
     let config = FabricConfig::parse_section_config(helper::get_fabrics_config!()).unwrap();
-
-    let mut frr_config = FrrConfigBuilder::default()
-        .add_fabrics(config.clone())
-        .build(NodeId::from_string("pve".to_owned()).expect("invalid nodeid"))
-        .expect("error building frr config");
+    let mut frr_config = FrrConfig::default();
+    build_fabric(
+        NodeId::from_string("pve".to_owned()).expect("invalid nodeid"),
+        config.clone(),
+        &mut frr_config,
+    )
+    .unwrap();
 
     let mut output = dump(&frr_config).expect("error dumping stuff");
 
     insta::assert_snapshot!(helper::reference_name!("pve"), output);
 
-    frr_config = FrrConfigBuilder::default()
-        .add_fabrics(config.clone())
-        .build(NodeId::from_string("pve1".to_owned()).expect("invalid nodeid"))
-        .expect("error building frr config");
+    frr_config = FrrConfig::default();
+    build_fabric(
+        NodeId::from_string("pve1".to_owned()).expect("invalid nodeid"),
+        config,
+        &mut frr_config,
+    )
+    .unwrap();
 
     output = dump(&frr_config).expect("error dumping stuff");
 
@@ -40,20 +44,26 @@ fn openfabric_default() {
 #[test]
 fn ospf_default() {
     let config = FabricConfig::parse_section_config(helper::get_fabrics_config!()).unwrap();
+    let mut frr_config = FrrConfig::default();
 
-    let mut frr_config = FrrConfigBuilder::default()
-        .add_fabrics(config.clone())
-        .build(NodeId::from_string("pve".to_owned()).expect("invalid nodeid"))
-        .expect("error building frr config");
+    build_fabric(
+        NodeId::from_string("pve".to_owned()).expect("invalid nodeid"),
+        config.clone(),
+        &mut frr_config,
+    )
+    .unwrap();
 
     let mut output = dump(&frr_config).expect("error dumping stuff");
 
     insta::assert_snapshot!(helper::reference_name!("pve"), output);
 
-    frr_config = FrrConfigBuilder::default()
-        .add_fabrics(config)
-        .build(NodeId::from_string("pve1".to_owned()).expect("invalid nodeid"))
-        .expect("error building frr config");
+    frr_config = FrrConfig::default();
+    build_fabric(
+        NodeId::from_string("pve1".to_owned()).expect("invalid nodeid"),
+        config,
+        &mut frr_config,
+    )
+    .unwrap();
 
     output = dump(&frr_config).expect("error dumping stuff");
 
@@ -87,11 +97,14 @@ fn ospf_loopback_prefix_fail() {
 #[test]
 fn openfabric_multi_fabric() {
     let config = FabricConfig::parse_section_config(helper::get_fabrics_config!()).unwrap();
+    let mut frr_config = FrrConfig::default();
 
-    let frr_config = FrrConfigBuilder::default()
-        .add_fabrics(config)
-        .build(NodeId::from_string("pve1".to_owned()).expect("invalid nodeid"))
-        .expect("error building frr config");
+    build_fabric(
+        NodeId::from_string("pve1".to_owned()).expect("invalid nodeid"),
+        config,
+        &mut frr_config,
+    )
+    .unwrap();
 
     let output = dump(&frr_config).expect("error dumping stuff");
 
@@ -101,12 +114,14 @@ fn openfabric_multi_fabric() {
 #[test]
 fn ospf_multi_fabric() {
     let config = FabricConfig::parse_section_config(helper::get_fabrics_config!()).unwrap();
-
-    let frr_config = FrrConfigBuilder::default()
-        .add_fabrics(config)
-        .build(NodeId::from_string("pve1".to_owned()).expect("invalid nodeid"))
-        .expect("error building frr config");
-
+    let mut frr_config = FrrConfig::default();
+
+    build_fabric(
+        NodeId::from_string("pve1".to_owned()).expect("invalid nodeid"),
+        config,
+        &mut frr_config,
+    )
+    .unwrap();
     let output = dump(&frr_config).expect("error dumping stuff");
 
     insta::assert_snapshot!(helper::reference_name!("pve1"), output);
@@ -115,11 +130,14 @@ fn ospf_multi_fabric() {
 #[test]
 fn openfabric_dualstack() {
     let config = FabricConfig::parse_section_config(helper::get_fabrics_config!()).unwrap();
+    let mut frr_config = FrrConfig::default();
 
-    let frr_config = FrrConfigBuilder::default()
-        .add_fabrics(config)
-        .build(NodeId::from_string("pve".to_owned()).expect("invalid nodeid"))
-        .expect("error building frr config");
+    build_fabric(
+        NodeId::from_string("pve".to_owned()).expect("invalid nodeid"),
+        config,
+        &mut frr_config,
+    )
+    .unwrap();
 
     let output = dump(&frr_config).expect("error dumping stuff");
 
@@ -129,11 +147,14 @@ fn openfabric_dualstack() {
 #[test]
 fn openfabric_ipv6_only() {
     let config = FabricConfig::parse_section_config(helper::get_fabrics_config!()).unwrap();
-
-    let frr_config = FrrConfigBuilder::default()
-        .add_fabrics(config)
-        .build(NodeId::from_string("pve".to_owned()).expect("invalid nodeid"))
-        .expect("error building frr config");
+    let mut frr_config = FrrConfig::default();
+
+    build_fabric(
+        NodeId::from_string("pve".to_owned()).expect("invalid nodeid"),
+        config,
+        &mut frr_config,
+    )
+    .unwrap();
 
     let output = dump(&frr_config).expect("error dumping stuff");
 
-- 
2.47.3





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

* [PATCH proxmox-ve-rs 4/9] sdn-types: support variable-length NET identifier
  2026-02-03 16:01 [PATCH docs/manager/network/proxmox{-ve-rs,-perl-rs} 00/23] Generate frr config using jinja templates and rust types Gabriel Goller
                   ` (2 preceding siblings ...)
  2026-02-03 16:01 ` [PATCH proxmox-ve-rs 3/9] ve-config: remove FrrConfigBuilder struct Gabriel Goller
@ 2026-02-03 16:01 ` Gabriel Goller
  2026-02-03 16:01 ` [PATCH proxmox-ve-rs 5/9] frr: add template serializer and serialize fabrics using templates Gabriel Goller
                   ` (18 subsequent siblings)
  22 siblings, 0 replies; 24+ messages in thread
From: Gabriel Goller @ 2026-02-03 16:01 UTC (permalink / raw)
  To: pve-devel

The NET (Network Entity Title) can actually be variable-length. We only
use the minimum length one (which corresponds to an ipv4 address) in the
fabrics, but in the ISIS tests we also use a longer NET. Support the
longer NET as well.

This is because in the perl-frr-generation we support variable-length
NETs (this is also covered in the tests).

Signed-off-by: Gabriel Goller <g.goller@proxmox.com>
---
 proxmox-sdn-types/src/net.rs | 136 +++++++++++++++++++++++++++++++----
 1 file changed, 121 insertions(+), 15 deletions(-)

diff --git a/proxmox-sdn-types/src/net.rs b/proxmox-sdn-types/src/net.rs
index 3cd1e4f80ed7..3e523fb12d9b 100644
--- a/proxmox-sdn-types/src/net.rs
+++ b/proxmox-sdn-types/src/net.rs
@@ -10,7 +10,8 @@ use proxmox_schema::{api, api_string_type, const_regex, ApiStringFormat, Updater
 
 const_regex! {
     NET_AFI_REGEX = r"^(?:[a-fA-F0-9]{2})$";
-    NET_AREA_REGEX = r"^(?:[a-fA-F0-9]{4})$";
+    // Variable length area: 0 to 13 bytes (0 to 26 hex digits) according to ISO 10589
+    NET_AREA_REGEX = r"^(?:[a-fA-F0-9]{0,26})$";
     NET_SYSTEM_ID_REGEX = r"^(?:[a-fA-F0-9]{4})\.(?:[a-fA-F0-9]{4})\.(?:[a-fA-F0-9]{4})$";
     NET_SELECTOR_REGEX = r"^(?:[a-fA-F0-9]{2})$";
 }
@@ -39,9 +40,9 @@ impl UpdaterType for NetAFI {
 }
 
 api_string_type! {
-    /// Area identifier: 0001 IS-IS area number (numerical area 1)
-    /// The second part (system) of the `net` identifier. Every node has to have a different system
-    /// number.
+    /// Area identifier: Variable length (0-13 bytes / 0-26 hex digits) according to ISO 10589
+    /// IS-IS area number that identifies the routing domain. All routers in the same area must
+    /// have the same area identifier. Can be empty or up to 26 hex digits.
     #[api(format: &NET_AREA_FORMAT)]
     #[derive(Debug, Deserialize, Serialize, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)]
     struct NetArea(String);
@@ -146,6 +147,7 @@ pub struct Net {
     selector: NetSelector,
 }
 
+
 impl UpdaterType for Net {
     type Updater = Option<Net>;
 }
@@ -156,27 +158,58 @@ impl std::str::FromStr for Net {
     fn from_str(s: &str) -> Result<Self, Self::Err> {
         let parts: Vec<&str> = s.split(".").collect();
 
-        if parts.len() != 6 {
-            bail!("invalid NET format: {s}")
+        // Minimum: AFI.SystemID(3 parts).Selector = 5 parts
+        // With area: AFI.Area.SystemID(3 parts).Selector = 6+ parts
+        if parts.len() < 5 {
+            bail!("invalid NET format: {s} (expected at least AFI.SystemID.Selector)")
         }
 
-        let system = format!("{}.{}.{}", parts[2], parts[3], parts[4],);
+        // Last part is selector (2 hex digits)
+        let selector_idx = parts.len() - 1;
+        let selector = parts[selector_idx];
+
+        // Three parts before selector are system ID (xxxx.xxxx.xxxx)
+        let system_id_parts = &parts[selector_idx - 3..selector_idx];
+        let system = format!(
+            "{}.{}.{}",
+            system_id_parts[0], system_id_parts[1], system_id_parts[2]
+        );
+
+        // First part is AFI (2 hex digits)
+        let afi = parts[0];
+
+        // Everything between AFI and system ID is the area (can be empty)
+        let area_parts = &parts[1..selector_idx - 3];
+        let area = area_parts.join("");
 
         Ok(Self {
-            afi: NetAFI::from_string(parts[0].to_string())?,
-            area: NetArea::from_string(parts[1].to_string())?,
-            system: NetSystemId::from_string(system.to_string())?,
-            selector: NetSelector::from_string(parts[5].to_string())?,
+            afi: NetAFI::from_string(afi.to_string())?,
+            area: NetArea::from_string(area)?,
+            system: NetSystemId::from_string(system)?,
+            selector: NetSelector::from_string(selector.to_string())?,
         })
     }
 }
 
 impl Display for Net {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        // Format area with dots every 4 hex digits for readability
+        let area_str = self.area.0.as_str();
+        let area_formatted = if area_str.is_empty() {
+            String::new()
+        } else {
+            let chunks: Vec<&str> = area_str
+                .as_bytes()
+                .chunks(4)
+                .map(|chunk| std::str::from_utf8(chunk).unwrap())
+                .collect();
+            format!(".{}", chunks.join("."))
+        };
+
         write!(
             f,
-            "{}.{}.{}.{}",
-            self.afi, self.area, self.system, self.selector
+            "{}{}.{}.{}",
+            self.afi, area_formatted, self.system, self.selector
         )
     }
 }
@@ -258,8 +291,16 @@ mod tests {
         let input = "409.0001.1921.6800.1002.00";
         input.parse::<Net>().expect_err("invalid AFI");
 
-        let input = "49.00001.1921.6800.1002.00";
-        input.parse::<Net>().expect_err("invalid area");
+        // Area can now be variable length (0-26 hex digits), so 5 digits is valid
+        // but 27 digits would be invalid
+        let input = "49.0123.4567.8901.2345.6789.0123.4569.1921.6800.1002.00";
+        input
+            .parse::<Net>()
+            .expect_err("area too long (>26 hex digits)");
+
+        // Too few parts
+        let input = "49.1921.6800.00";
+        input.parse::<Net>().expect_err("not enough parts");
     }
 
     #[test]
@@ -320,4 +361,69 @@ mod tests {
         let net4: Net = ip4.into();
         assert_eq!(format!("{net4}"), "49.0001.0000.0000.0000.00");
     }
+
+    #[test]
+    fn test_net_variable_length_area() {
+        // Test with no area (just AFI)
+        let input = "49.1921.6800.1002.00";
+        let net = input.parse::<Net>().expect("should parse NET with no area");
+        assert_eq!(net.afi, NetAFI("49".to_owned()));
+        assert_eq!(net.area, NetArea("".to_owned()));
+        assert_eq!(net.system, NetSystemId("1921.6800.1002".to_owned()));
+        assert_eq!(net.selector, NetSelector("00".to_owned()));
+        assert_eq!(format!("{net}"), "49.1921.6800.1002.00");
+
+        // Test with 2 hex digit area
+        let input = "49.01.1921.6800.1002.00";
+        let net = input
+            .parse::<Net>()
+            .expect("should parse NET with 2-digit area");
+        assert_eq!(net.area, NetArea("01".to_owned()));
+        assert_eq!(format!("{net}"), "49.01.1921.6800.1002.00");
+
+        // Test with 4 hex digit area (standard)
+        let input = "49.0001.1921.6800.1002.00";
+        let net = input
+            .parse::<Net>()
+            .expect("should parse NET with 4-digit area");
+        assert_eq!(net.area, NetArea("0001".to_owned()));
+        assert_eq!(format!("{net}"), "49.0001.1921.6800.1002.00");
+
+        // Test with 8 hex digit area (formatted with dots every 4 digits)
+        let input = "49.0001.0002.1921.6800.1002.00";
+        let net = input
+            .parse::<Net>()
+            .expect("should parse NET with 8-digit area");
+        assert_eq!(net.area, NetArea("00010002".to_owned()));
+        // Should be formatted with dots every 4 hex digits
+        assert_eq!(format!("{net}"), "49.0001.0002.1921.6800.1002.00");
+
+        // Test with 12 hex digit area
+        let input = "49.0001.0002.0003.1921.6800.1002.00";
+        let net = input
+            .parse::<Net>()
+            .expect("should parse NET with 12-digit area");
+        assert_eq!(net.area, NetArea("000100020003".to_owned()));
+        assert_eq!(format!("{net}"), "49.0001.0002.0003.1921.6800.1002.00");
+
+        // Test with odd-length area (5 hex digits)
+        let input = "49.12345.1921.6800.1002.00";
+        let net = input
+            .parse::<Net>()
+            .expect("should parse NET with 5-digit area");
+        assert_eq!(net.area, NetArea("12345".to_owned()));
+        // Should be formatted with dots every 4 digits, last chunk has 1 digit
+        assert_eq!(format!("{net}"), "49.1234.5.1921.6800.1002.00");
+
+        // Test with maximum length area (26 hex digits = 13 bytes)
+        let input = "49.0123.4567.89ab.cdef.0123.4567.1921.6800.1002.00";
+        let net = input
+            .parse::<Net>()
+            .expect("should parse NET with 26-digit area");
+        assert_eq!(net.area, NetArea("0123456789abcdef01234567".to_owned()));
+        assert_eq!(
+            format!("{net}"),
+            "49.0123.4567.89ab.cdef.0123.4567.1921.6800.1002.00"
+        );
+    }
 }
-- 
2.47.3





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

* [PATCH proxmox-ve-rs 5/9] frr: add template serializer and serialize fabrics using templates
  2026-02-03 16:01 [PATCH docs/manager/network/proxmox{-ve-rs,-perl-rs} 00/23] Generate frr config using jinja templates and rust types Gabriel Goller
                   ` (3 preceding siblings ...)
  2026-02-03 16:01 ` [PATCH proxmox-ve-rs 4/9] sdn-types: support variable-length NET identifier Gabriel Goller
@ 2026-02-03 16:01 ` Gabriel Goller
  2026-02-03 16:01 ` [PATCH proxmox-ve-rs 6/9] frr: add isis configuration and templates Gabriel Goller
                   ` (17 subsequent siblings)
  22 siblings, 0 replies; 24+ messages in thread
From: Gabriel Goller @ 2026-02-03 16:01 UTC (permalink / raw)
  To: pve-devel

Add a new serializer which uses templates in
`/etc/proxmox-frr/templates` or 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.

Signed-off-by: Gabriel Goller <g.goller@proxmox.com>
---
 proxmox-frr/Cargo.toml                        |   3 +
 proxmox-frr/debian/control                    |  10 +
 proxmox-frr/src/ser/mod.rs                    | 247 ++++++--------
 proxmox-frr/src/ser/openfabric.rs             |  26 +-
 proxmox-frr/src/ser/ospf.rs                   |  56 +---
 proxmox-frr/src/ser/route_map.rs              | 175 ++++------
 proxmox-frr/src/ser/serializer.rs             | 259 ++++-----------
 proxmox-sdn-types/src/net.rs                  |   4 +-
 proxmox-ve-config/src/common/valid.rs         |   4 +-
 proxmox-ve-config/src/sdn/fabric/frr.rs       | 302 ++++++++++--------
 .../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, 468 insertions(+), 647 deletions(-)

diff --git a/proxmox-frr/Cargo.toml b/proxmox-frr/Cargo.toml
index 31e93395cd20..560a04b42980 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" ] }
+bon = "3.7"
 
 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 6336ec362b45..10651e640ba3 100644
--- a/proxmox-frr/debian/control
+++ b/proxmox-frr/debian/control
@@ -7,8 +7,13 @@ Build-Depends-Arch: cargo:native <!nocheck>,
  rustc:native (>= 1.82) <!nocheck>,
  libstd-rust-dev <!nocheck>,
  librust-anyhow-1+default-dev <!nocheck>,
+ librust-bon-3+default-dev (>= 3.7-~~) <!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-proxmox-network-types-0.1+default-dev (>= 0.1.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 +32,13 @@ Multi-Arch: same
 Depends:
  ${misc:Depends},
  librust-anyhow-1+default-dev,
+ librust-bon-3+default-dev (>= 3.7-~~),
+ librust-minijinja-2+default-dev (>= 2.5-~~),
+ librust-minijinja-2+loader-dev (>= 2.5-~~),
+ librust-minijinja-2+multi-template-dev (>= 2.5-~~),
  librust-proxmox-network-types-0.1+default-dev (>= 0.1.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..666845ecab74 100644
--- a/proxmox-frr/src/ser/mod.rs
+++ b/proxmox-frr/src/ser/mod.rs
@@ -3,104 +3,17 @@ 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 bon::Builder;
+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 +26,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 +57,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 +64,7 @@ impl AsRef<str> for FrrWord {
 }
 
 #[derive(Error, Debug)]
-pub enum CommonInterfaceNameError {
+pub enum InterfaceNameError {
     #[error("interface name too long")]
     TooLong,
 }
@@ -166,76 +73,128 @@ 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)
     }
 }
 
-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)
     }
 }
 
-impl CommonInterfaceName {
-    pub fn new<T: AsRef<str> + Into<String>>(s: T) -> Result<Self, CommonInterfaceNameError> {
+impl InterfaceName {
+    pub fn new<T: AsRef<str> + Into<String>>(s: T) -> Result<Self, InterfaceNameError> {
         if s.as_ref().len() <= 15 {
             Ok(Self(s.into()))
         } else {
-            Err(CommonInterfaceNameError::TooLong)
+            Err(InterfaceNameError::TooLong)
         }
     }
 }
 
-impl Display for CommonInterfaceName {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        self.0.fmt(f)
+#[derive(Debug, Clone, Serialize, PartialEq, Eq, Deserialize)]
+pub struct Interface<T> {
+    #[serde(default, skip_serializing_if = "Vec::is_empty")]
+    pub addresses: Vec<Cidr>,
+
+    #[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,
+        }
     }
 }
 
-/// 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>,
+impl From<ospf::OspfInterface> for Interface<ospf::OspfInterface> {
+    fn from(value: ospf::OspfInterface) -> Self {
+        Interface {
+            addresses: Vec::new(),
+            properties: value,
+        }
+    }
 }
 
-impl FrrConfig {
-    pub fn new() -> Self {
-        Self::default()
-    }
+#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
+#[serde(untagged)]
+pub enum IpOrInterface {
+    Ip(IpAddr),
+    Interface(InterfaceName),
+}
 
-    pub fn router(&self) -> impl Iterator<Item = (&RouterName, &Router)> + '_ {
-        self.router.iter()
-    }
+#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Builder, 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 interfaces(&self) -> impl Iterator<Item = (&InterfaceName, &Interface)> + '_ {
-        self.interfaces.iter()
-    }
+#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum FrrProtocol {
+    Ospf,
+    Openfabric,
+    Bgp,
+}
 
-    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 IpProtocolRouteMap {
+    pub v4: Option<RouteMapName>,
+    pub v6: Option<RouteMapName>,
+}
 
-    pub fn protocol_routemaps(&self) -> impl Iterator<Item = &ProtocolRouteMap> + '_ {
-        self.protocol_routemaps.iter()
-    }
+/// 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, Builder, Serialize, Deserialize)]
+pub struct FrrConfig {
+    #[builder(default)]
+    #[serde(default)]
+    pub openfabric: OpenfabricFrrConfig,
+    #[builder(default)]
+    #[serde(default)]
+    pub ospf: OspfFrrConfig,
+    #[builder(default)]
+    #[serde(default)]
+    pub protocol_routemaps: BTreeMap<FrrProtocol, IpProtocolRouteMap>,
+
+    #[builder(default)]
+    #[serde(default)]
+    pub routemaps: BTreeMap<RouteMapName, Vec<RouteMapEntry>>,
+    #[builder(default)]
+    #[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..8c82c60b0de5 100644
--- a/proxmox-frr/src/ser/openfabric.rs
+++ b/proxmox-frr/src/ser/openfabric.rs
@@ -1,15 +1,17 @@
 use std::fmt::Debug;
-use std::fmt::Display;
 
+use bon::Builder;
 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 +26,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,
@@ -67,15 +69,25 @@ impl OpenfabricRouter {
 ///
 /// 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, Builder)]
 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..8b26f42e2e46 100644
--- a/proxmox-frr/src/ser/ospf.rs
+++ b/proxmox-frr/src/ser/ospf.rs
@@ -1,32 +1,12 @@
 use std::fmt::Debug;
-use std::fmt::Display;
 use std::net::Ipv4Addr;
 
+use bon::Builder;
+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 +24,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,12 +45,6 @@ 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
@@ -84,7 +58,7 @@ impl Display for Area {
 /// 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,
 }
@@ -119,7 +93,8 @@ pub enum OspfInterfaceError {
 /// ! 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,17 +107,6 @@ 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>`
@@ -156,10 +120,16 @@ impl Display for NetworkType {
 ///  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, Builder)]
 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..995e21655cd0 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
@@ -40,29 +30,29 @@ impl fmt::Display for AccessAction {
 /// ! 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
@@ -75,12 +65,29 @@ impl Display for AccessListName {
 /// 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
@@ -98,66 +105,58 @@ pub struct AccessList {
 /// ! 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,12 +165,6 @@ 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
@@ -186,48 +179,14 @@ impl Display for RouteMapName {
 ///  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..da7a31b7cbf6 100644
--- a/proxmox-frr/src/ser/serializer.rs
+++ b/proxmox-frr/src/ser/serializer.rs
@@ -1,203 +1,66 @@
-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,
-};
-
-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)
-    }
-}
-
-pub trait FrrSerializer {
-    fn serialize(&self, f: &mut FrrConfigBlob<'_>) -> fmt::Result;
-}
-
-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)?;
-
-    Ok(out.as_str().lines().map(String::from).collect())
-}
-
-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)?,
+use std::{fs, path::PathBuf};
+
+use anyhow::Context;
+use minijinja::Environment;
+
+use crate::ser::FrrConfig;
+
+fn create_env<'a>() -> Environment<'a> {
+    let mut env = Environment::new();
+
+    // avoid unnecessary additional newlines
+    env.set_trim_blocks(true);
+    env.set_lstrip_blocks(true);
+
+    env.set_loader(move |name| {
+        let override_path = PathBuf::from(format!("/etc/proxmox-frr/templates/{name}"));
+        // first read the override template:
+        match fs::read_to_string(override_path) {
+            Ok(template_content) => Ok(Some(template_content)),
+            // if that fails, read the vendored template:
+            Err(_) => match name {
+                "fabricd.jinja" => Ok(Some(
+                    include_str!("/usr/share/proxmox-frr/templates/fabricd.jinja").to_owned(),
+                )),
+                "ospfd.jinja" => Ok(Some(
+                    include_str!("/usr/share/proxmox-frr/templates/ospfd.jinja").to_owned(),
+                )),
+                "interface.jinja" => Ok(Some(
+                    include_str!("/usr/share/proxmox-frr/templates/interface.jinja").to_owned(),
+                )),
+                "access_lists.jinja" => Ok(Some(
+                    include_str!("/usr/share/proxmox-frr/templates/access_lists.jinja").to_owned(),
+                )),
+                "route_maps.jinja" => Ok(Some(
+                    include_str!("/usr/share/proxmox-frr/templates/route_maps.jinja").to_owned(),
+                )),
+                "protocol_routemaps.jinja" => Ok(Some(
+                    include_str!("/usr/share/proxmox-frr/templates/protocol_routemaps.jinja")
+                        .to_owned(),
+                )),
+                "frr.conf.jinja" => Ok(Some(
+                    include_str!("/usr/share/proxmox-frr/templates/frr.conf.jinja").to_owned(),
+                )),
+                _ => Ok(None),
+            },
         }
-        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(())
-    }
+    env
 }
 
-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(())
-    }
+/// Render the passed [`FrrConfig`] into a single string containing the whole config.
+pub fn dump(config: &FrrConfig) -> Result<String, anyhow::Error> {
+    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..ac5e88e905a3 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,20 @@ fn build_ospf_interface(
         },
     };
 
-    let interface_name = ser::InterfaceName::Ospf(interface.name.as_str().try_into()?);
+    let interface_name = interface.name.as_str().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> {
-    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()?);
+    area: ospf::Area,
+) -> Result<(Interface<OspfInterface>, InterfaceName), anyhow::Error> {
+    let frr_interface = ser::ospf::OspfInterface::builder()
+        .area(area)
+        .passive(true)
+        .build();
+    let interface_name = format!("dummy_{}", fabric_id).try_into()?;
     Ok((frr_interface.into(), interface_name))
 }
 
@@ -301,8 +343,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 +361,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 +370,15 @@ 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())?;
-    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,
-    };
-    let interface_name = ser::InterfaceName::Openfabric(format!("dummy_{}", fabric_id).try_into()?);
+) -> Result<(Interface<OpenfabricInterface>, InterfaceName), anyhow::Error> {
+    let frr_word = FrrWord::new(fabric_id.to_string())?;
+    let frr_interface = ser::openfabric::OpenfabricInterface::builder()
+        .fabric_id(frr_word.into())
+        .passive(true)
+        .is_ipv4(is_ipv4)
+        .is_ipv6(is_ipv6)
+        .build();
+    let interface_name = format!("dummy_{}", fabric_id).try_into()?;
     Ok((frr_interface.into(), interface_name))
 }
 
@@ -348,29 +387,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 +420,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





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

* [PATCH proxmox-ve-rs 6/9] frr: add isis configuration and templates
  2026-02-03 16:01 [PATCH docs/manager/network/proxmox{-ve-rs,-perl-rs} 00/23] Generate frr config using jinja templates and rust types Gabriel Goller
                   ` (4 preceding siblings ...)
  2026-02-03 16:01 ` [PATCH proxmox-ve-rs 5/9] frr: add template serializer and serialize fabrics using templates Gabriel Goller
@ 2026-02-03 16:01 ` Gabriel Goller
  2026-02-03 16:01 ` [PATCH proxmox-ve-rs 7/9] frr: support custom frr configuration lines Gabriel Goller
                   ` (16 subsequent siblings)
  22 siblings, 0 replies; 24+ messages in thread
From: Gabriel Goller @ 2026-02-03 16:01 UTC (permalink / raw)
  To: pve-devel

Add templates and rust configuration types to render configuration for
the isis daemon. This allows us to generate the config in perl, then
pass it to rust and then render the template in rust.

Co-authored-by: Stefan Hanreich <s.hanreich@proxmox.com>
Signed-off-by: Gabriel Goller <g.goller@proxmox.com>
---
 .../templates/frr.conf.jinja                  |  1 +
 proxmox-frr-templates/templates/isisd.jinja   | 32 ++++++++++++
 proxmox-frr/src/ser/isis.rs                   | 49 +++++++++++++++++++
 proxmox-frr/src/ser/mod.rs                    | 14 +++++-
 proxmox-frr/src/ser/serializer.rs             |  3 ++
 5 files changed, 98 insertions(+), 1 deletion(-)
 create mode 100644 proxmox-frr-templates/templates/isisd.jinja
 create mode 100644 proxmox-frr/src/ser/isis.rs

diff --git a/proxmox-frr-templates/templates/frr.conf.jinja b/proxmox-frr-templates/templates/frr.conf.jinja
index 8458c6a61112..c8495b417990 100644
--- a/proxmox-frr-templates/templates/frr.conf.jinja
+++ b/proxmox-frr-templates/templates/frr.conf.jinja
@@ -1,4 +1,5 @@
 {% include "fabricd.jinja" %}
+{% include "isisd.jinja" %}
 {% include "ospfd.jinja" %}
 {% include "access_lists.jinja" %}
 {% include "route_maps.jinja" %}
diff --git a/proxmox-frr-templates/templates/isisd.jinja b/proxmox-frr-templates/templates/isisd.jinja
new file mode 100644
index 000000000000..75f066166ad2
--- /dev/null
+++ b/proxmox-frr-templates/templates/isisd.jinja
@@ -0,0 +1,32 @@
+{% from "interface.jinja" import interface %}
+{% for router_name, router_config in isis.router|items %}
+!
+router isis {{ router_name }}
+ net {{ router_config.net }}
+{% if router_config.redistribute.ipv4_connected %}
+ redistribute ipv4 connected {{ router_config.redistribute.ipv4_connected }}
+{% endif %}
+{% if router_config.redistribute.ipv6_connected %}
+ redistribute ipv6 connected {{ router_config.redistribute.ipv6_connected }}
+{% endif %}
+{% if router_config.log_adjacency_changes %}
+ log-adjacency-changes
+{% endif %}
+{% for line in router_config.custom_frr_config %}
+{{ line }}
+{% endfor %}
+exit
+{% endfor %}
+{% for interface_name, interface_config in isis.interfaces|items %}
+{% call interface(interface_name, interface_config.addresses) %}
+{% if interface_config.domain and interface_config.is_ipv4 %}
+ ip router isis {{ interface_config.domain }}
+{% endif %}
+{% if interface_config.domain and interface_config.is_ipv6 %}
+ ipv6 router isis {{ interface_config.domain }}
+{% endif %}
+{% for line in interface_config.custom_frr_config %}
+{{ line }}
+{% endfor %}
+{% endcall %}
+{% endfor %}
diff --git a/proxmox-frr/src/ser/isis.rs b/proxmox-frr/src/ser/isis.rs
new file mode 100644
index 000000000000..2a38a8310fb5
--- /dev/null
+++ b/proxmox-frr/src/ser/isis.rs
@@ -0,0 +1,49 @@
+use std::fmt::Debug;
+
+use bon::Builder;
+
+use proxmox_sdn_types::net::Net;
+use serde::Deserialize;
+use serde::Serialize;
+
+use crate::ser::FrrWord;
+
+#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
+pub struct IsisRouterName(FrrWord);
+
+#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
+pub enum IsisLevel {
+    #[serde(rename = "level-1")]
+    Level1,
+    #[serde(rename = "level-2")]
+    Level2,
+    #[serde(rename = "level-1-2")]
+    Level12,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
+pub struct Redistribute {
+    ipv4_connected: IsisLevel,
+    ipv6_connected: IsisLevel,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
+pub struct IsisRouter {
+    pub net: Net,
+    #[serde(default, deserialize_with = "proxmox_serde::perl::deserialize_bool")]
+    pub log_adjacency_changes: Option<bool>,
+    pub redistribute: Option<Redistribute>,
+    #[serde(default)]
+    pub custom_frr_config: Vec<String>,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, Builder)]
+pub struct IsisInterface {
+    pub domain: IsisRouterName,
+    #[serde(deserialize_with = "proxmox_serde::perl::deserialize_bool")]
+    pub is_ipv4: bool,
+    #[serde(deserialize_with = "proxmox_serde::perl::deserialize_bool")]
+    pub is_ipv6: bool,
+    #[serde(default)]
+    pub custom_frr_config: Vec<String>,
+}
diff --git a/proxmox-frr/src/ser/mod.rs b/proxmox-frr/src/ser/mod.rs
index 666845ecab74..9aaee74d7af0 100644
--- a/proxmox-frr/src/ser/mod.rs
+++ b/proxmox-frr/src/ser/mod.rs
@@ -1,3 +1,4 @@
+pub mod isis;
 pub mod openfabric;
 pub mod ospf;
 pub mod route_map;
@@ -173,8 +174,11 @@ pub struct FrrConfig {
     pub ospf: OspfFrrConfig,
     #[builder(default)]
     #[serde(default)]
-    pub protocol_routemaps: BTreeMap<FrrProtocol, IpProtocolRouteMap>,
+    pub isis: IsisFrrConfig,
 
+    #[builder(default)]
+    #[serde(default)]
+    pub protocol_routemaps: BTreeMap<FrrProtocol, IpProtocolRouteMap>,
     #[builder(default)]
     #[serde(default)]
     pub routemaps: BTreeMap<RouteMapName, Vec<RouteMapEntry>>,
@@ -191,6 +195,14 @@ pub struct OpenfabricFrrConfig {
     pub interfaces: BTreeMap<InterfaceName, Interface<openfabric::OpenfabricInterface>>,
 }
 
+#[derive(Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
+pub struct IsisFrrConfig {
+    #[serde(default)]
+    pub router: BTreeMap<isis::IsisRouterName, isis::IsisRouter>,
+    #[serde(default)]
+    pub interfaces: BTreeMap<InterfaceName, Interface<isis::IsisInterface>>,
+}
+
 #[derive(Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
 pub struct OspfFrrConfig {
     #[serde(default)]
diff --git a/proxmox-frr/src/ser/serializer.rs b/proxmox-frr/src/ser/serializer.rs
index da7a31b7cbf6..646b81ab6044 100644
--- a/proxmox-frr/src/ser/serializer.rs
+++ b/proxmox-frr/src/ser/serializer.rs
@@ -22,6 +22,9 @@ fn create_env<'a>() -> Environment<'a> {
                 "fabricd.jinja" => Ok(Some(
                     include_str!("/usr/share/proxmox-frr/templates/fabricd.jinja").to_owned(),
                 )),
+                "isisd.jinja" => Ok(Some(
+                    include_str!("/usr/share/proxmox-frr/templates/isisd.jinja").to_owned(),
+                )),
                 "ospfd.jinja" => Ok(Some(
                     include_str!("/usr/share/proxmox-frr/templates/ospfd.jinja").to_owned(),
                 )),
-- 
2.47.3





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

* [PATCH proxmox-ve-rs 7/9] frr: support custom frr configuration lines
  2026-02-03 16:01 [PATCH docs/manager/network/proxmox{-ve-rs,-perl-rs} 00/23] Generate frr config using jinja templates and rust types Gabriel Goller
                   ` (5 preceding siblings ...)
  2026-02-03 16:01 ` [PATCH proxmox-ve-rs 6/9] frr: add isis configuration and templates Gabriel Goller
@ 2026-02-03 16:01 ` Gabriel Goller
  2026-02-03 16:01 ` [PATCH proxmox-ve-rs 8/9] frr: add bgp support with templates and serialization Gabriel Goller
                   ` (15 subsequent siblings)
  22 siblings, 0 replies; 24+ messages in thread
From: Gabriel Goller @ 2026-02-03 16:01 UTC (permalink / raw)
  To: pve-devel

When merging the frr.conf.local with the frr.conf, some lines cannot be
merged and we need to add custom frr config lines to the rust
configuration. Add the vec of lines and just dump them into the
template.

Co-authored-by: Stefan Hanreich <s.hanreich@proxmox.com>
Signed-off-by: Gabriel Goller <g.goller@proxmox.com>
---
 proxmox-frr-templates/templates/frr.conf.jinja | 3 +++
 proxmox-frr/src/ser/mod.rs                     | 3 +++
 2 files changed, 6 insertions(+)

diff --git a/proxmox-frr-templates/templates/frr.conf.jinja b/proxmox-frr-templates/templates/frr.conf.jinja
index c8495b417990..6d60ad2a4c4c 100644
--- a/proxmox-frr-templates/templates/frr.conf.jinja
+++ b/proxmox-frr-templates/templates/frr.conf.jinja
@@ -4,3 +4,6 @@
 {% include "access_lists.jinja" %}
 {% include "route_maps.jinja" %}
 {% include "protocol_routemaps.jinja" %}
+{% for line in custom_frr_config %}
+{{ line }}
+{% endfor %}
diff --git a/proxmox-frr/src/ser/mod.rs b/proxmox-frr/src/ser/mod.rs
index 9aaee74d7af0..3baa0a318fb0 100644
--- a/proxmox-frr/src/ser/mod.rs
+++ b/proxmox-frr/src/ser/mod.rs
@@ -185,6 +185,9 @@ pub struct FrrConfig {
     #[builder(default)]
     #[serde(default)]
     pub access_lists: BTreeMap<AccessListName, Vec<AccessListRule>>,
+    #[builder(default)]
+    #[serde(default)]
+    pub custom_frr_config: Vec<String>,
 }
 
 #[derive(Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
-- 
2.47.3





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

* [PATCH proxmox-ve-rs 8/9] frr: add bgp support with templates and serialization
  2026-02-03 16:01 [PATCH docs/manager/network/proxmox{-ve-rs,-perl-rs} 00/23] Generate frr config using jinja templates and rust types Gabriel Goller
                   ` (6 preceding siblings ...)
  2026-02-03 16:01 ` [PATCH proxmox-ve-rs 7/9] frr: support custom frr configuration lines Gabriel Goller
@ 2026-02-03 16:01 ` Gabriel Goller
  2026-02-03 16:01 ` [PATCH proxmox-ve-rs 9/9] frr: store frr template content as a const map Gabriel Goller
                   ` (14 subsequent siblings)
  22 siblings, 0 replies; 24+ messages in thread
From: Gabriel Goller @ 2026-02-03 16:01 UTC (permalink / raw)
  To: pve-devel

Implements bgp routing configuration (rust types and templates)
including routers, neighbor groups, address families (IPv4/IPv6 unicast,
L2VPN EVPN), VRFs, route redistribution, and prefix lists.

Co-authored-by: Stefan Hanreich <s.hanreich@proxmox.com>
Signed-off-by: Gabriel Goller <g.goller@proxmox.com>
---
 .../templates/bgp_router.jinja                | 118 +++++++++++
 proxmox-frr-templates/templates/bgpd.jinja    |  35 ++++
 .../templates/frr.conf.jinja                  |   3 +
 .../templates/ip_routes.jinja                 |   8 +
 .../templates/prefix_lists.jinja              |   6 +
 proxmox-frr/src/ser/bgp.rs                    | 184 ++++++++++++++++++
 proxmox-frr/src/ser/mod.rs                    |  34 +++-
 proxmox-frr/src/ser/serializer.rs             |  12 ++
 8 files changed, 399 insertions(+), 1 deletion(-)
 create mode 100644 proxmox-frr-templates/templates/bgp_router.jinja
 create mode 100644 proxmox-frr-templates/templates/bgpd.jinja
 create mode 100644 proxmox-frr-templates/templates/ip_routes.jinja
 create mode 100644 proxmox-frr-templates/templates/prefix_lists.jinja
 create mode 100644 proxmox-frr/src/ser/bgp.rs

diff --git a/proxmox-frr-templates/templates/bgp_router.jinja b/proxmox-frr-templates/templates/bgp_router.jinja
new file mode 100644
index 000000000000..6f48d6ca17a4
--- /dev/null
+++ b/proxmox-frr-templates/templates/bgp_router.jinja
@@ -0,0 +1,118 @@
+{% macro address_family_common(common_address_family) -%}
+{% for vrf in common_address_family.import_vrf %}
+  import vrf {{ vrf }}
+{% endfor %}
+{% for neighbor in common_address_family.neighbors %}
+  neighbor {{ neighbor.name }} activate
+  {% if neighbor.soft_reconfiguration_inbound %}
+  neighbor {{ neighbor.name }} soft-reconfiguration inbound
+  {% endif %}
+  {% if neighbor.route_map_in %}
+  neighbor {{ neighbor.name }} route-map {{ neighbor.route_map_in }} in
+  {% endif %}
+  {% if neighbor.route_map_out %}
+  neighbor {{ neighbor.name }} route-map {{ neighbor.route_map_out }} out
+  {% endif %}
+{% endfor -%}
+{% for line in common_address_family.custom_frr_config %}
+{{ line }}
+{% endfor -%}
+{% endmacro -%}
+{% macro bgp_router(router_config) %}
+ bgp router-id {{ router_config.router_id }}
+ no bgp hard-administrative-reset
+{% if router_config.default_ipv4_unicast == false %}
+ no bgp default ipv4-unicast
+{% endif %}
+{% if router_config.coalesce_time %}
+ coalesce-time {{ router_config.coalesce_time }}
+{% endif %}
+ no bgp graceful-restart notification
+{% if router_config.disable_ebgp_connected_route_check %}
+ bgp disable-ebgp-connected-route-check
+{% endif %}
+{% if router_config.bestpath_as_path_multipath_relax %}
+ bgp bestpath as-path multipath-relax
+{% endif %}
+{% for neighbor_group in router_config.neighbor_groups %}
+ neighbor {{ neighbor_group.name }} peer-group
+ neighbor {{ neighbor_group.name }} remote-as {{ neighbor_group.remote_as }}
+{% if neighbor_group.bfd %}
+ neighbor {{ neighbor_group.name }} bfd
+{% endif %}
+{% if neighbor_group.ebgp_multihop %}
+ neighbor {{ neighbor_group.name }} ebgp-multihop {{ neighbor_group.ebgp_multihop }}
+{% endif %}
+{% if neighbor_group.update_source %}
+ neighbor {{ neighbor_group.name }} update-source {{ neighbor_group.update_source }}
+{% endif %}
+{% for ip in neighbor_group.ips %}
+ neighbor {{ ip }} peer-group {{ neighbor_group.name }}
+{% endfor %}
+{% for interface in neighbor_group.interfaces %}
+ neighbor {{ interface }} interface peer-group {{ neighbor_group.name }}
+{% endfor %}
+{% endfor %}
+{% for line in router_config.custom_frr_config %}
+{{ line }}
+{% endfor %}
+{% if router_config.address_families.ipv4_unicast %}
+ !
+ address-family ipv4 unicast
+{% for network in router_config.address_families.ipv4_unicast.networks %}
+  network {{ network }}
+{% endfor %}
+{{ address_family_common(router_config.address_families.ipv4_unicast) -}}
+{% for redistribute in router_config.address_families.ipv4_unicast.redistribute %}
+  redistribute {{ redistribute.protocol }}{{ (" metric " ~ redistribute.metric) if redistribute.metric }}{{ (" route-map " ~ redistribute.route_map) if redistribute.route_map }}
+{% endfor %}
+ exit-address-family
+{% endif %}
+{% if router_config.address_families.ipv6_unicast %}
+ !
+ address-family ipv6 unicast
+{% for network in router_config.address_families.ipv6_unicast.networks %}
+  network {{ network }}
+{% endfor %}
+{{ address_family_common(router_config.address_families.ipv6_unicast) -}}
+{% for redistribute in router_config.address_families.ipv6_unicast.redistribute %}
+  redistribute {{ redistribute.protocol }}{{ (" metric " ~ redistribute.metric) if redistribute.metric }}{{ (" route-map " ~ redistribute.route_map) if redistribute.route_map }}
+{% endfor %}
+ exit-address-family
+{% endif %}
+{% if router_config.address_families.l2vpn_evpn %}
+ !
+ address-family l2vpn evpn
+{{ address_family_common(router_config.address_families.l2vpn_evpn) -}}
+{% if router_config.address_families.l2vpn_evpn.advertise_all_vni %}
+  advertise-all-vni
+{% endif %}
+{% if router_config.address_families.l2vpn_evpn.advertise_default_gw %}
+  advertise-default-gw
+{% endif %}
+{% if router_config.address_families.l2vpn_evpn.autort_as %}
+  autort as {{ router_config.address_families.l2vpn_evpn.autort_as }}
+{% endif %}
+{% for default_originate in router_config.address_families.l2vpn_evpn.default_originate %}
+  default-originate {{ default_originate }}
+{% endfor %}
+{% if router_config.address_families.l2vpn_evpn.advertise_ipv4_unicast %}
+  advertise ipv4 unicast
+{% endif %}
+{% if router_config.address_families.l2vpn_evpn.advertise_ipv6_unicast %}
+  advertise ipv6 unicast
+{% endif %}
+{% if router_config.address_families.l2vpn_evpn.route_targets %}
+{% for import in router_config.address_families.l2vpn_evpn.route_targets.import %}
+  route-target import {{ import }}
+{% endfor %}
+{% for export in router_config.address_families.l2vpn_evpn.route_targets.export %}
+  route-target export {{ export }}
+{% endfor %}
+{% for both in router_config.address_families.l2vpn_evpn.route_targets.both %}
+  route-target both {{ both }}
+{% endfor %}
+{% endif %}
+ exit-address-family
+{% endif %}
+{% endmacro -%}
diff --git a/proxmox-frr-templates/templates/bgpd.jinja b/proxmox-frr-templates/templates/bgpd.jinja
new file mode 100644
index 000000000000..cdd0a50abf8c
--- /dev/null
+++ b/proxmox-frr-templates/templates/bgpd.jinja
@@ -0,0 +1,35 @@
+{% from "bgp_router.jinja" import bgp_router %}
+{% for vrf_name, vrf in bgp.vrfs|items %}
+!
+vrf {{ vrf_name }}
+{% if vrf.vni %}
+ vni {{ vrf.vni }}
+{% for ip_route in vrf.ip_routes %}
+{% if ip_route.vrf %}
+ {{ "ipv6" if ip_route.is_ipv6 else "ip" }} route {{ ip_route.prefix }} {{ ip_route.via }} {{ ip_route.vrf }}
+{% else %}
+ {{ "ipv6" if ip_route.is_ipv6 else "ip" }} route {{ ip_route.prefix }} {{ ip_route.via }}
+{% endif %}
+{% endfor %}
+{% endif %}
+{% for line in vrf.custom_frr_config %}
+{{ line }}
+{% endfor %}
+exit-vrf
+{% endfor %}
+{% for vrf_name, router_config in bgp.vrf_router|items %}
+!
+{% if vrf_name == "default" %}
+router bgp {{ router_config.asn }}
+{% else %}
+router bgp {{ router_config.asn }} vrf {{ vrf_name }}
+{% endif %}
+{{ bgp_router(router_config) -}}
+exit
+{% endfor %}
+{% for view_id, router_config in bgp.view_router|items %}
+!
+router bgp {{ router_config.asn }} view {{ view_id }}
+{{ bgp_router(router_config) -}}
+exit
+{% endfor %}
diff --git a/proxmox-frr-templates/templates/frr.conf.jinja b/proxmox-frr-templates/templates/frr.conf.jinja
index 6d60ad2a4c4c..f9ca85890710 100644
--- a/proxmox-frr-templates/templates/frr.conf.jinja
+++ b/proxmox-frr-templates/templates/frr.conf.jinja
@@ -1,8 +1,11 @@
+{% include "bgpd.jinja" %}
 {% include "fabricd.jinja" %}
 {% include "isisd.jinja" %}
 {% include "ospfd.jinja" %}
 {% include "access_lists.jinja" %}
+{% include "prefix_lists.jinja" %}
 {% include "route_maps.jinja" %}
+{% include "ip_routes.jinja" %}
 {% include "protocol_routemaps.jinja" %}
 {% for line in custom_frr_config %}
 {{ line }}
diff --git a/proxmox-frr-templates/templates/ip_routes.jinja b/proxmox-frr-templates/templates/ip_routes.jinja
new file mode 100644
index 000000000000..3e33a709e821
--- /dev/null
+++ b/proxmox-frr-templates/templates/ip_routes.jinja
@@ -0,0 +1,8 @@
+{% for ip_route in ip_routes %}
+!
+{% if ip_route.vrf %}
+{{ "ipv6" if ip_route.is_ipv6 else "ip" }} route {{ ip_route.prefix }} {{ ip_route.via }} {{ ip_route.vrf }}
+{% else %}
+{{ "ipv6" if ip_route.is_ipv6 else "ip" }} route {{ ip_route.prefix }} {{ ip_route.via }}
+{% endif %}
+{% endfor %}
diff --git a/proxmox-frr-templates/templates/prefix_lists.jinja b/proxmox-frr-templates/templates/prefix_lists.jinja
new file mode 100644
index 000000000000..f431958af354
--- /dev/null
+++ b/proxmox-frr-templates/templates/prefix_lists.jinja
@@ -0,0 +1,6 @@
+{% for name, prefix_list in prefix_lists | items %}
+!
+{%  for rule in prefix_list %}
+{{ "ipv6" if rule.is_ipv6 else "ip" }} prefix-list {{ name }} {{ ("seq " ~ rule.seq ~ " ") if rule.seq }}{{ rule.action }} {{ rule.network }}{{ (" le " ~ rule.le) if rule.le }}{{ (" ge " ~ rule.ge) if rule.ge }}
+{% endfor %}
+{% endfor %}
diff --git a/proxmox-frr/src/ser/bgp.rs b/proxmox-frr/src/ser/bgp.rs
new file mode 100644
index 000000000000..3ac014686339
--- /dev/null
+++ b/proxmox-frr/src/ser/bgp.rs
@@ -0,0 +1,184 @@
+use std::net::{IpAddr, Ipv4Addr};
+
+use bon::Builder;
+use proxmox_network_types::ip_address::{Ipv4Cidr, Ipv6Cidr};
+use serde::{Deserialize, Serialize};
+
+use crate::ser::route_map::RouteMapName;
+use crate::ser::{FrrWord, InterfaceName, IpRoute};
+
+#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
+pub struct BgpRouterName {
+    asn: u32,
+    vrf: Option<FrrWord>,
+}
+
+impl BgpRouterName {
+    pub fn new(asn: u32, vrf: Option<FrrWord>) -> Self {
+        Self { asn, vrf }
+    }
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum NeighborRemoteAs {
+    Internal,
+    External,
+    #[serde(untagged)]
+    Asn(#[serde(deserialize_with = "proxmox_serde::perl::deserialize_u32")] u32),
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Builder, Deserialize)]
+pub struct NeighborGroup {
+    pub name: FrrWord,
+    #[serde(deserialize_with = "proxmox_serde::perl::deserialize_bool")]
+    pub bfd: bool,
+    pub local_as: Option<u32>,
+    pub remote_as: NeighborRemoteAs,
+    #[serde(default)]
+    pub ips: Vec<IpAddr>,
+    #[serde(default)]
+    pub interfaces: Vec<InterfaceName>,
+    pub ebgp_multihop: Option<i32>,
+    pub update_source: Option<InterfaceName>,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Builder, Deserialize)]
+pub struct Ipv4UnicastAF {
+    #[serde(flatten)]
+    pub common_options: CommonAddressFamilyOptions,
+    #[serde(default)]
+    pub networks: Vec<Ipv4Cidr>,
+    #[serde(default)]
+    pub redistribute: Vec<Redistribution>,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Builder, Deserialize)]
+pub struct Ipv6UnicastAF {
+    #[serde(flatten)]
+    pub common_options: CommonAddressFamilyOptions,
+    #[serde(default)]
+    pub networks: Vec<Ipv6Cidr>,
+    #[serde(default)]
+    pub redistribute: Vec<Redistribution>,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Builder, Deserialize)]
+pub struct L2vpnEvpnAF {
+    #[serde(flatten)]
+    pub common_options: CommonAddressFamilyOptions,
+    #[serde(default, deserialize_with = "proxmox_serde::perl::deserialize_bool")]
+    pub advertise_all_vni: Option<bool>,
+    #[serde(default, deserialize_with = "proxmox_serde::perl::deserialize_bool")]
+    pub advertise_default_gw: Option<bool>,
+    #[serde(default)]
+    pub default_originate: Vec<DefaultOriginate>,
+    #[serde(default, deserialize_with = "proxmox_serde::perl::deserialize_bool")]
+    pub advertise_ipv4_unicast: Option<bool>,
+    #[serde(default, deserialize_with = "proxmox_serde::perl::deserialize_bool")]
+    pub advertise_ipv6_unicast: Option<bool>,
+    pub autort_as: Option<i32>,
+    pub route_targets: Option<RouteTargets>,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum DefaultOriginate {
+    Ipv4,
+    Ipv6,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum RedistributeProtocol {
+    Connected,
+    Static,
+    Ospf,
+    Kernel,
+    Isis,
+    Ospf6,
+    Openfabric,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Builder, Deserialize)]
+pub struct Redistribution {
+    pub protocol: RedistributeProtocol,
+    pub metric: Option<u32>,
+    pub route_map: Option<RouteMapName>,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Builder, Deserialize)]
+pub struct RouteTargets {
+    #[serde(default)]
+    import: Vec<FrrWord>,
+    #[serde(default)]
+    export: Vec<FrrWord>,
+    #[serde(default)]
+    both: Vec<FrrWord>,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Builder, Deserialize)]
+pub struct AddressFamilyNeighbor {
+    pub name: String,
+    #[serde(
+        default,
+        skip_serializing_if = "Option::is_none",
+        deserialize_with = "proxmox_serde::perl::deserialize_bool"
+    )]
+    pub soft_reconfiguration_inbound: Option<bool>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub route_map_in: Option<RouteMapName>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub route_map_out: Option<RouteMapName>,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Builder, Deserialize)]
+pub struct CommonAddressFamilyOptions {
+    #[serde(default)]
+    pub import_vrf: Vec<FrrWord>,
+    #[serde(default)]
+    pub neighbors: Vec<AddressFamilyNeighbor>,
+    #[serde(default)]
+    pub custom_frr_config: Vec<String>,
+}
+
+#[derive(
+    Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Builder, Deserialize, Default,
+)]
+pub struct AddressFamilies {
+    #[serde(skip_serializing_if = "Option::is_none")]
+    ipv4_unicast: Option<Ipv4UnicastAF>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    ipv6_unicast: Option<Ipv6UnicastAF>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    l2vpn_evpn: Option<L2vpnEvpnAF>,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Builder, Deserialize)]
+pub struct Vrf {
+    pub vni: Option<u32>,
+    #[serde(default)]
+    pub ip_routes: Vec<IpRoute>,
+    #[serde(default)]
+    pub custom_frr_config: Vec<String>,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Builder, Deserialize)]
+pub struct BgpRouter {
+    pub asn: u32,
+    pub router_id: Ipv4Addr,
+    #[serde(default)]
+    pub coalesce_time: Option<u32>,
+    #[serde(default, deserialize_with = "proxmox_serde::perl::deserialize_bool")]
+    pub default_ipv4_unicast: Option<bool>,
+    #[serde(default, deserialize_with = "proxmox_serde::perl::deserialize_bool")]
+    pub disable_ebgp_connected_route_check: Option<bool>,
+    #[serde(default, deserialize_with = "proxmox_serde::perl::deserialize_bool")]
+    pub bestpath_as_path_multipath_relax: Option<bool>,
+    #[serde(default)]
+    pub neighbor_groups: Vec<NeighborGroup>,
+    #[serde(default)]
+    pub address_families: AddressFamilies,
+    #[serde(default)]
+    pub custom_frr_config: Vec<String>,
+}
diff --git a/proxmox-frr/src/ser/mod.rs b/proxmox-frr/src/ser/mod.rs
index 3baa0a318fb0..f3578ef1323a 100644
--- a/proxmox-frr/src/ser/mod.rs
+++ b/proxmox-frr/src/ser/mod.rs
@@ -1,3 +1,4 @@
+pub mod bgp;
 pub mod isis;
 pub mod openfabric;
 pub mod ospf;
@@ -8,7 +9,9 @@ use std::collections::BTreeMap;
 use std::net::IpAddr;
 use std::str::FromStr;
 
-use crate::ser::route_map::{AccessListName, AccessListRule, RouteMapEntry, RouteMapName};
+use crate::ser::route_map::{
+    AccessListName, AccessListRule, PrefixListName, PrefixListRule, RouteMapEntry, RouteMapName,
+};
 
 use bon::Builder;
 use proxmox_network_types::ip_address::Cidr;
@@ -159,6 +162,14 @@ pub struct IpProtocolRouteMap {
     pub v6: Option<RouteMapName>,
 }
 
+#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
+pub enum VrfName {
+    #[serde(rename = "default")]
+    Default,
+    #[serde(untagged)]
+    Custom(String),
+}
+
 /// Main FRR config.
 ///
 /// Contains the two main frr building blocks: routers and interfaces. It also holds other
@@ -174,8 +185,14 @@ pub struct FrrConfig {
     pub ospf: OspfFrrConfig,
     #[builder(default)]
     #[serde(default)]
+    pub bgp: BgpFrrConfig,
+    #[builder(default)]
+    #[serde(default)]
     pub isis: IsisFrrConfig,
 
+    #[builder(default)]
+    #[serde(default)]
+    pub ip_routes: Vec<IpRoute>,
     #[builder(default)]
     #[serde(default)]
     pub protocol_routemaps: BTreeMap<FrrProtocol, IpProtocolRouteMap>,
@@ -185,6 +202,10 @@ pub struct FrrConfig {
     #[builder(default)]
     #[serde(default)]
     pub access_lists: BTreeMap<AccessListName, Vec<AccessListRule>>,
+    #[builder(default)]
+    #[serde(default)]
+    pub prefix_lists: BTreeMap<PrefixListName, Vec<PrefixListRule>>,
+
     #[builder(default)]
     #[serde(default)]
     pub custom_frr_config: Vec<String>,
@@ -213,3 +234,14 @@ pub struct OspfFrrConfig {
     #[serde(default)]
     pub interfaces: BTreeMap<InterfaceName, Interface<ospf::OspfInterface>>,
 }
+
+#[derive(Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
+pub struct BgpFrrConfig {
+    #[serde(default)]
+    pub vrf_router: BTreeMap<VrfName, bgp::BgpRouter>,
+    #[serde(default)]
+    pub view_router: BTreeMap<u32, bgp::BgpRouter>,
+
+    #[serde(default)]
+    pub vrfs: BTreeMap<InterfaceName, bgp::Vrf>,
+}
diff --git a/proxmox-frr/src/ser/serializer.rs b/proxmox-frr/src/ser/serializer.rs
index 646b81ab6044..12e4190744eb 100644
--- a/proxmox-frr/src/ser/serializer.rs
+++ b/proxmox-frr/src/ser/serializer.rs
@@ -22,21 +22,33 @@ fn create_env<'a>() -> Environment<'a> {
                 "fabricd.jinja" => Ok(Some(
                     include_str!("/usr/share/proxmox-frr/templates/fabricd.jinja").to_owned(),
                 )),
+                "bgpd.jinja" => Ok(Some(
+                    include_str!("/usr/share/proxmox-frr/templates/bgpd.jinja").to_owned(),
+                )),
                 "isisd.jinja" => Ok(Some(
                     include_str!("/usr/share/proxmox-frr/templates/isisd.jinja").to_owned(),
                 )),
                 "ospfd.jinja" => Ok(Some(
                     include_str!("/usr/share/proxmox-frr/templates/ospfd.jinja").to_owned(),
                 )),
+                "bgp_router.jinja" => Ok(Some(
+                    include_str!("/usr/share/proxmox-frr/templates/bgp_router.jinja").to_owned(),
+                )),
                 "interface.jinja" => Ok(Some(
                     include_str!("/usr/share/proxmox-frr/templates/interface.jinja").to_owned(),
                 )),
                 "access_lists.jinja" => Ok(Some(
                     include_str!("/usr/share/proxmox-frr/templates/access_lists.jinja").to_owned(),
                 )),
+                "prefix_lists.jinja" => Ok(Some(
+                    include_str!("/usr/share/proxmox-frr/templates/prefix_lists.jinja").to_owned(),
+                )),
                 "route_maps.jinja" => Ok(Some(
                     include_str!("/usr/share/proxmox-frr/templates/route_maps.jinja").to_owned(),
                 )),
+                "ip_routes.jinja" => Ok(Some(
+                    include_str!("/usr/share/proxmox-frr/templates/ip_routes.jinja").to_owned(),
+                )),
                 "protocol_routemaps.jinja" => Ok(Some(
                     include_str!("/usr/share/proxmox-frr/templates/protocol_routemaps.jinja")
                         .to_owned(),
-- 
2.47.3





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

* [PATCH proxmox-ve-rs 9/9] frr: store frr template content as a const map
  2026-02-03 16:01 [PATCH docs/manager/network/proxmox{-ve-rs,-perl-rs} 00/23] Generate frr config using jinja templates and rust types Gabriel Goller
                   ` (7 preceding siblings ...)
  2026-02-03 16:01 ` [PATCH proxmox-ve-rs 8/9] frr: add bgp support with templates and serialization Gabriel Goller
@ 2026-02-03 16:01 ` Gabriel Goller
  2026-02-03 16:01 ` [PATCH proxmox-perl-rs 1/2] sdn: add function to generate the frr config for all daemons Gabriel Goller
                   ` (13 subsequent siblings)
  22 siblings, 0 replies; 24+ messages in thread
From: Gabriel Goller @ 2026-02-03 16:01 UTC (permalink / raw)
  To: pve-devel

We need to retrieve the template content from pve-network (for the
pvesdn cli), so we need to make these public. Use the `phf` crate to
store them in a const map.

Signed-off-by: Gabriel Goller <g.goller@proxmox.com>
---
 proxmox-frr/Cargo.toml            |  1 +
 proxmox-frr/debian/control        |  4 +++
 proxmox-frr/src/ser/serializer.rs | 56 +++++++++----------------------
 3 files changed, 21 insertions(+), 40 deletions(-)

diff --git a/proxmox-frr/Cargo.toml b/proxmox-frr/Cargo.toml
index 560a04b42980..173d8dfc8924 100644
--- a/proxmox-frr/Cargo.toml
+++ b/proxmox-frr/Cargo.toml
@@ -17,6 +17,7 @@ serde = { workspace = true, features = [ "derive" ] }
 serde_repr = "0.1"
 minijinja = { version = "2.5", features = [ "multi_template", "loader" ] }
 bon = "3.7"
+phf = { version = "0.11.2", features = ["macros"] }
 
 proxmox-network-types = { workspace = true }
 proxmox-sdn-types = { workspace = true }
diff --git a/proxmox-frr/debian/control b/proxmox-frr/debian/control
index 10651e640ba3..fb0960c4c75d 100644
--- a/proxmox-frr/debian/control
+++ b/proxmox-frr/debian/control
@@ -11,6 +11,8 @@ Build-Depends-Arch: cargo:native <!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-0.1+default-dev (>= 0.1.1-~~) <!nocheck>,
  librust-proxmox-sdn-types-0.1+default-dev <!nocheck>,
  librust-proxmox-serde-1+default-dev <!nocheck>,
@@ -36,6 +38,8 @@ Depends:
  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-0.1+default-dev (>= 0.1.1-~~),
  librust-proxmox-sdn-types-0.1+default-dev,
  librust-proxmox-serde-1+default-dev,
diff --git a/proxmox-frr/src/ser/serializer.rs b/proxmox-frr/src/ser/serializer.rs
index 12e4190744eb..67cc36562e9b 100644
--- a/proxmox-frr/src/ser/serializer.rs
+++ b/proxmox-frr/src/ser/serializer.rs
@@ -5,6 +5,21 @@ 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"),
+        "bgpd.jinja" => include_str!("/usr/share/proxmox-frr/templates/bgpd.jinja"),
+        "isisd.jinja" => include_str!("/usr/share/proxmox-frr/templates/isisd.jinja"),
+        "ospfd.jinja" => include_str!("/usr/share/proxmox-frr/templates/ospfd.jinja"),
+        "bgp_router.jinja" => include_str!("/usr/share/proxmox-frr/templates/bgp_router.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"),
+        "prefix_lists.jinja" => include_str!("/usr/share/proxmox-frr/templates/prefix_lists.jinja"),
+        "route_maps.jinja" => include_str!("/usr/share/proxmox-frr/templates/route_maps.jinja"),
+        "ip_routes.jinja" => include_str!("/usr/share/proxmox-frr/templates/ip_routes.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"),
+};
+
 fn create_env<'a>() -> Environment<'a> {
     let mut env = Environment::new();
 
@@ -18,46 +33,7 @@ fn create_env<'a>() -> Environment<'a> {
         match fs::read_to_string(override_path) {
             Ok(template_content) => Ok(Some(template_content)),
             // if that fails, read the vendored template:
-            Err(_) => match name {
-                "fabricd.jinja" => Ok(Some(
-                    include_str!("/usr/share/proxmox-frr/templates/fabricd.jinja").to_owned(),
-                )),
-                "bgpd.jinja" => Ok(Some(
-                    include_str!("/usr/share/proxmox-frr/templates/bgpd.jinja").to_owned(),
-                )),
-                "isisd.jinja" => Ok(Some(
-                    include_str!("/usr/share/proxmox-frr/templates/isisd.jinja").to_owned(),
-                )),
-                "ospfd.jinja" => Ok(Some(
-                    include_str!("/usr/share/proxmox-frr/templates/ospfd.jinja").to_owned(),
-                )),
-                "bgp_router.jinja" => Ok(Some(
-                    include_str!("/usr/share/proxmox-frr/templates/bgp_router.jinja").to_owned(),
-                )),
-                "interface.jinja" => Ok(Some(
-                    include_str!("/usr/share/proxmox-frr/templates/interface.jinja").to_owned(),
-                )),
-                "access_lists.jinja" => Ok(Some(
-                    include_str!("/usr/share/proxmox-frr/templates/access_lists.jinja").to_owned(),
-                )),
-                "prefix_lists.jinja" => Ok(Some(
-                    include_str!("/usr/share/proxmox-frr/templates/prefix_lists.jinja").to_owned(),
-                )),
-                "route_maps.jinja" => Ok(Some(
-                    include_str!("/usr/share/proxmox-frr/templates/route_maps.jinja").to_owned(),
-                )),
-                "ip_routes.jinja" => Ok(Some(
-                    include_str!("/usr/share/proxmox-frr/templates/ip_routes.jinja").to_owned(),
-                )),
-                "protocol_routemaps.jinja" => Ok(Some(
-                    include_str!("/usr/share/proxmox-frr/templates/protocol_routemaps.jinja")
-                        .to_owned(),
-                )),
-                "frr.conf.jinja" => Ok(Some(
-                    include_str!("/usr/share/proxmox-frr/templates/frr.conf.jinja").to_owned(),
-                )),
-                _ => Ok(None),
-            },
+            Err(_) => Ok(TEMPLATES.get(name).map(|template| (*template).to_owned())),
         }
     });
 
-- 
2.47.3





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

* [PATCH proxmox-perl-rs 1/2] sdn: add function to generate the frr config for all daemons
  2026-02-03 16:01 [PATCH docs/manager/network/proxmox{-ve-rs,-perl-rs} 00/23] Generate frr config using jinja templates and rust types Gabriel Goller
                   ` (8 preceding siblings ...)
  2026-02-03 16:01 ` [PATCH proxmox-ve-rs 9/9] frr: store frr template content as a const map Gabriel Goller
@ 2026-02-03 16:01 ` Gabriel Goller
  2026-02-03 16:01 ` [PATCH proxmox-perl-rs 2/2] sdn: add method to get a frr template Gabriel Goller
                   ` (12 subsequent siblings)
  22 siblings, 0 replies; 24+ messages in thread
From: Gabriel Goller @ 2026-02-03 16:01 UTC (permalink / raw)
  To: pve-devel

Add a function to generate the raw frr configuration for all the
protocols. This means we need a single function which takes the
fabric-config and the bgp+isis configuration. This function takes the
FrrConfig, which is partially filled with the bgp+isis config, and then
converts the fabrics to frr-rust-types and adds them to the FrrConfig.
Finally the FrrConfig is converted to literal frr-config-lines and
returned.

Co-authored-by: Stefan Hanreich <s.hanreich@proxmox.com>
Signed-off-by: Gabriel Goller <g.goller@proxmox.com>
---
 pve-rs/Makefile                    |  1 +
 pve-rs/src/bindings/sdn/fabrics.rs | 25 +++----------------------
 pve-rs/src/bindings/sdn/mod.rs     | 28 ++++++++++++++++++++++++++++
 3 files changed, 32 insertions(+), 22 deletions(-)

diff --git a/pve-rs/Makefile b/pve-rs/Makefile
index aa7181efc78b..a4603365e3f7 100644
--- a/pve-rs/Makefile
+++ b/pve-rs/Makefile
@@ -31,6 +31,7 @@ PERLMOD_PACKAGES := \
 	  PVE::RS::OpenId \
 	  PVE::RS::ResourceScheduling::Static \
 	  PVE::RS::SDN::Fabrics \
+	  PVE::RS::SDN \
 	  PVE::RS::TFA
 
 PERLMOD_PACKAGE_FILES := $(addsuffix .pm,$(subst ::,/,$(PERLMOD_PACKAGES)))
diff --git a/pve-rs/src/bindings/sdn/fabrics.rs b/pve-rs/src/bindings/sdn/fabrics.rs
index 54606e7b6bb9..18848c405201 100644
--- a/pve-rs/src/bindings/sdn/fabrics.rs
+++ b/pve-rs/src/bindings/sdn/fabrics.rs
@@ -14,10 +14,11 @@ pub mod pve_rs_sdn_fabrics {
 
     use anyhow::{Context, Error, format_err};
     use openssl::hash::{MessageDigest, hash};
+    use proxmox_ve_config::sdn::fabric::section_config::node::api::{Node, NodeUpdater};
     use serde::{Deserialize, Serialize};
 
     use perlmod::Value;
-    use proxmox_frr::ser::serializer::to_raw_config;
+
     use proxmox_network_types::ip_address::{Cidr, Ipv4Cidr, Ipv6Cidr};
     use proxmox_section_config::typed::SectionConfigData;
     use proxmox_ve_config::common::valid::{Valid, Validatable};
@@ -29,12 +30,8 @@ pub mod pve_rs_sdn_fabrics {
         api::{Fabric, FabricUpdater},
     };
     use proxmox_ve_config::sdn::fabric::section_config::interface::InterfaceName;
-    use proxmox_ve_config::sdn::fabric::section_config::node::{
-        Node as ConfigNode, NodeId,
-        api::{Node, NodeUpdater},
-    };
+    use proxmox_ve_config::sdn::fabric::section_config::node::{Node as ConfigNode, NodeId};
     use proxmox_ve_config::sdn::fabric::{FabricConfig, FabricEntry};
-    use proxmox_ve_config::sdn::frr::FrrConfigBuilder;
 
     use crate::sdn::status::{self, RunningConfig};
 
@@ -462,22 +459,6 @@ pub mod pve_rs_sdn_fabrics {
         daemons.into_iter().map(String::from).collect()
     }
 
-    /// Method: Return the FRR configuration for this config instance, as an array of
-    /// strings, where each line represents a line in the FRR configuration.
-    #[export]
-    pub fn get_frr_raw_config(
-        #[try_from_ref] this: &PerlFabricConfig,
-        node_id: NodeId,
-    ) -> Result<Vec<String>, Error> {
-        let config = this.fabric_config.lock().unwrap();
-
-        let frr_config = FrrConfigBuilder::default()
-            .add_fabrics(config.clone().into_valid()?)
-            .build(node_id)?;
-
-        to_raw_config(&frr_config)
-    }
-
     /// Helper function to generate the default `/etc/network/interfaces` config for a given CIDR.
     fn render_interface(name: &str, cidr: Cidr, is_dummy: bool) -> Result<String, Error> {
         let mut interface = String::new();
diff --git a/pve-rs/src/bindings/sdn/mod.rs b/pve-rs/src/bindings/sdn/mod.rs
index 0ec7009cc788..fde3138d55f7 100644
--- a/pve-rs/src/bindings/sdn/mod.rs
+++ b/pve-rs/src/bindings/sdn/mod.rs
@@ -1 +1,29 @@
 pub(crate) mod fabrics;
+
+#[perlmod::package(name = "PVE::RS::SDN", lib = "pve_rs")]
+pub mod pve_rs_sdn {
+    //! The `PVE::RS::SDN` package.
+    //!
+    //! This provides general methods for generating the frr config.
+
+    use anyhow::Error;
+    use proxmox_frr::ser::{FrrConfig, serializer::to_raw_config};
+
+    use proxmox_ve_config::common::valid::Validatable;
+    use proxmox_ve_config::sdn::fabric::section_config::node::NodeId;
+
+    use crate::bindings::pve_rs_sdn_fabrics::PerlFabricConfig;
+
+    /// Return the FRR configuration for the passed FrrConfig and the FabricsConfig as an array of
+    /// strings, where each line represents a line in the FRR configuration.
+    #[export]
+    pub fn get_frr_raw_config(
+        mut frr_config: FrrConfig,
+        #[try_from_ref] cfg: &PerlFabricConfig,
+        node_id: NodeId,
+    ) -> Result<Vec<String>, Error> {
+        let fabric_config = cfg.fabric_config.lock().unwrap().clone().into_valid()?;
+        proxmox_ve_config::sdn::fabric::frr::build_fabric(node_id, fabric_config, &mut frr_config)?;
+        to_raw_config(&frr_config)
+    }
+}
-- 
2.47.3





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

* [PATCH proxmox-perl-rs 2/2] sdn: add method to get a frr template
  2026-02-03 16:01 [PATCH docs/manager/network/proxmox{-ve-rs,-perl-rs} 00/23] Generate frr config using jinja templates and rust types Gabriel Goller
                   ` (9 preceding siblings ...)
  2026-02-03 16:01 ` [PATCH proxmox-perl-rs 1/2] sdn: add function to generate the frr config for all daemons Gabriel Goller
@ 2026-02-03 16:01 ` Gabriel Goller
  2026-02-03 16:01 ` [PATCH pve-network 01/10] sdn: remove duplicate comment line '!' in frr config Gabriel Goller
                   ` (11 subsequent siblings)
  22 siblings, 0 replies; 24+ messages in thread
From: Gabriel Goller @ 2026-02-03 16:01 UTC (permalink / raw)
  To: pve-devel

The templates are now stored inside of the proxmox-frr binary because we
use `include_str!`. In pve-network we need to use these templates to
check if they have changed and compare them to the override templates
(in the postinst). We also need the templates for the pvesdn cli so that
the user can create overrides.

Signed-off-by: Gabriel Goller <g.goller@proxmox.com>
---
 pve-rs/src/bindings/sdn/mod.rs | 7 +++++++
 1 file changed, 7 insertions(+)

diff --git a/pve-rs/src/bindings/sdn/mod.rs b/pve-rs/src/bindings/sdn/mod.rs
index fde3138d55f7..f5d3ea89e06d 100644
--- a/pve-rs/src/bindings/sdn/mod.rs
+++ b/pve-rs/src/bindings/sdn/mod.rs
@@ -9,6 +9,7 @@ pub mod pve_rs_sdn {
     use anyhow::Error;
     use proxmox_frr::ser::{FrrConfig, serializer::to_raw_config};
 
+    use proxmox_frr::ser;
     use proxmox_ve_config::common::valid::Validatable;
     use proxmox_ve_config::sdn::fabric::section_config::node::NodeId;
 
@@ -26,4 +27,10 @@ pub mod pve_rs_sdn {
         proxmox_ve_config::sdn::fabric::frr::build_fabric(node_id, fabric_config, &mut frr_config)?;
         to_raw_config(&frr_config)
     }
+
+    /// Return the FRR template with the passed name.
+    #[export]
+    pub fn get_template(template: &str) -> Option<&'static str> {
+        ser::serializer::TEMPLATES.get(template).copied()
+    }
 }
-- 
2.47.3





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

* [PATCH pve-network 01/10] sdn: remove duplicate comment line '!' in frr config
  2026-02-03 16:01 [PATCH docs/manager/network/proxmox{-ve-rs,-perl-rs} 00/23] Generate frr config using jinja templates and rust types Gabriel Goller
                   ` (10 preceding siblings ...)
  2026-02-03 16:01 ` [PATCH proxmox-perl-rs 2/2] sdn: add method to get a frr template Gabriel Goller
@ 2026-02-03 16:01 ` Gabriel Goller
  2026-02-03 16:01 ` [PATCH pve-network 02/10] sdn: tests: add missing comment " Gabriel Goller
                   ` (10 subsequent siblings)
  22 siblings, 0 replies; 24+ messages in thread
From: Gabriel Goller @ 2026-02-03 16:01 UTC (permalink / raw)
  To: pve-devel

The '!' symbol in frr is just a separator/comment and doesn't do
anything. We usually add the '!' and the start of a block, so we remove
it after the start block.

Signed-off-by: Gabriel Goller <g.goller@proxmox.com>
---
 src/PVE/Network/SDN/Frr.pm                                       | 1 -
 src/test/zones/evpn/advertise_subnets/expected_controller_config | 1 -
 .../evpn/disable_arp_nd_suppression/expected_controller_config   | 1 -
 src/test/zones/evpn/ebgp/expected_controller_config              | 1 -
 src/test/zones/evpn/ebgp_loopback/expected_controller_config     | 1 -
 src/test/zones/evpn/exitnode/expected_controller_config          | 1 -
 .../zones/evpn/exitnode_local_routing/expected_controller_config | 1 -
 src/test/zones/evpn/exitnode_primary/expected_controller_config  | 1 -
 src/test/zones/evpn/exitnode_snat/expected_controller_config     | 1 -
 src/test/zones/evpn/exitnodenullroute/expected_controller_config | 1 -
 src/test/zones/evpn/ipv4/expected_controller_config              | 1 -
 src/test/zones/evpn/ipv4ipv6/expected_controller_config          | 1 -
 src/test/zones/evpn/ipv4ipv6nogateway/expected_controller_config | 1 -
 src/test/zones/evpn/ipv6/expected_controller_config              | 1 -
 src/test/zones/evpn/ipv6underlay/expected_controller_config      | 1 -
 src/test/zones/evpn/isis/expected_controller_config              | 1 -
 src/test/zones/evpn/isis_loopback/expected_controller_config     | 1 -
 src/test/zones/evpn/isis_standalone/expected_controller_config   | 1 -
 src/test/zones/evpn/multipath_relax/expected_controller_config   | 1 -
 src/test/zones/evpn/multiplezones/expected_controller_config     | 1 -
 src/test/zones/evpn/openfabric_fabric/expected_controller_config | 1 -
 src/test/zones/evpn/ospf_fabric/expected_controller_config       | 1 -
 src/test/zones/evpn/rt_import/expected_controller_config         | 1 -
 src/test/zones/evpn/vxlanport/expected_controller_config         | 1 -
 24 files changed, 24 deletions(-)

diff --git a/src/PVE/Network/SDN/Frr.pm b/src/PVE/Network/SDN/Frr.pm
index 6d5f43084412..9f81369c079b 100644
--- a/src/PVE/Network/SDN/Frr.pm
+++ b/src/PVE/Network/SDN/Frr.pm
@@ -230,7 +230,6 @@ sub raw_config_to_string {
         "hostname $nodename",
         "log syslog informational",
         "service integrated-vtysh-config",
-        "!",
     );
 
     push @final_config, @$raw_config;
diff --git a/src/test/zones/evpn/advertise_subnets/expected_controller_config b/src/test/zones/evpn/advertise_subnets/expected_controller_config
index 10a601eb8d09..1b6145601b08 100644
--- a/src/test/zones/evpn/advertise_subnets/expected_controller_config
+++ b/src/test/zones/evpn/advertise_subnets/expected_controller_config
@@ -4,7 +4,6 @@ hostname localhost
 log syslog informational
 service integrated-vtysh-config
 !
-!
 vrf vrf_myzone
  vni 1000
 exit-vrf
diff --git a/src/test/zones/evpn/disable_arp_nd_suppression/expected_controller_config b/src/test/zones/evpn/disable_arp_nd_suppression/expected_controller_config
index d67552a9ed6b..9ee41a9cd637 100644
--- a/src/test/zones/evpn/disable_arp_nd_suppression/expected_controller_config
+++ b/src/test/zones/evpn/disable_arp_nd_suppression/expected_controller_config
@@ -4,7 +4,6 @@ hostname localhost
 log syslog informational
 service integrated-vtysh-config
 !
-!
 vrf vrf_myzone
  vni 1000
 exit-vrf
diff --git a/src/test/zones/evpn/ebgp/expected_controller_config b/src/test/zones/evpn/ebgp/expected_controller_config
index ad9753919e6f..f9472b314b3e 100644
--- a/src/test/zones/evpn/ebgp/expected_controller_config
+++ b/src/test/zones/evpn/ebgp/expected_controller_config
@@ -4,7 +4,6 @@ hostname localhost
 log syslog informational
 service integrated-vtysh-config
 !
-!
 vrf vrf_myzone
  vni 1000
 exit-vrf
diff --git a/src/test/zones/evpn/ebgp_loopback/expected_controller_config b/src/test/zones/evpn/ebgp_loopback/expected_controller_config
index 22f1b10fa906..38f62d06540c 100644
--- a/src/test/zones/evpn/ebgp_loopback/expected_controller_config
+++ b/src/test/zones/evpn/ebgp_loopback/expected_controller_config
@@ -4,7 +4,6 @@ hostname localhost
 log syslog informational
 service integrated-vtysh-config
 !
-!
 vrf vrf_myzone
  vni 1000
 exit-vrf
diff --git a/src/test/zones/evpn/exitnode/expected_controller_config b/src/test/zones/evpn/exitnode/expected_controller_config
index 160a0a0528ff..cc3ae9c4b3ae 100644
--- a/src/test/zones/evpn/exitnode/expected_controller_config
+++ b/src/test/zones/evpn/exitnode/expected_controller_config
@@ -4,7 +4,6 @@ hostname localhost
 log syslog informational
 service integrated-vtysh-config
 !
-!
 vrf vrf_myzone
  vni 1000
 exit-vrf
diff --git a/src/test/zones/evpn/exitnode_local_routing/expected_controller_config b/src/test/zones/evpn/exitnode_local_routing/expected_controller_config
index 50f226b51e98..8dc8c6b044c9 100644
--- a/src/test/zones/evpn/exitnode_local_routing/expected_controller_config
+++ b/src/test/zones/evpn/exitnode_local_routing/expected_controller_config
@@ -4,7 +4,6 @@ hostname localhost
 log syslog informational
 service integrated-vtysh-config
 !
-!
 vrf vrf_myzone
  vni 1000
 exit-vrf
diff --git a/src/test/zones/evpn/exitnode_primary/expected_controller_config b/src/test/zones/evpn/exitnode_primary/expected_controller_config
index 1397160af3d1..f11e3ba0258e 100644
--- a/src/test/zones/evpn/exitnode_primary/expected_controller_config
+++ b/src/test/zones/evpn/exitnode_primary/expected_controller_config
@@ -4,7 +4,6 @@ hostname localhost
 log syslog informational
 service integrated-vtysh-config
 !
-!
 vrf vrf_myzone
  vni 1000
 exit-vrf
diff --git a/src/test/zones/evpn/exitnode_snat/expected_controller_config b/src/test/zones/evpn/exitnode_snat/expected_controller_config
index 160a0a0528ff..cc3ae9c4b3ae 100644
--- a/src/test/zones/evpn/exitnode_snat/expected_controller_config
+++ b/src/test/zones/evpn/exitnode_snat/expected_controller_config
@@ -4,7 +4,6 @@ hostname localhost
 log syslog informational
 service integrated-vtysh-config
 !
-!
 vrf vrf_myzone
  vni 1000
 exit-vrf
diff --git a/src/test/zones/evpn/exitnodenullroute/expected_controller_config b/src/test/zones/evpn/exitnodenullroute/expected_controller_config
index 781e6e21a8e9..3e95d77b7984 100644
--- a/src/test/zones/evpn/exitnodenullroute/expected_controller_config
+++ b/src/test/zones/evpn/exitnodenullroute/expected_controller_config
@@ -4,7 +4,6 @@ hostname localhost
 log syslog informational
 service integrated-vtysh-config
 !
-!
 vrf vrf_myzone
  vni 1000
  ip route 10.0.0.0/24 null0
diff --git a/src/test/zones/evpn/ipv4/expected_controller_config b/src/test/zones/evpn/ipv4/expected_controller_config
index d67552a9ed6b..9ee41a9cd637 100644
--- a/src/test/zones/evpn/ipv4/expected_controller_config
+++ b/src/test/zones/evpn/ipv4/expected_controller_config
@@ -4,7 +4,6 @@ hostname localhost
 log syslog informational
 service integrated-vtysh-config
 !
-!
 vrf vrf_myzone
  vni 1000
 exit-vrf
diff --git a/src/test/zones/evpn/ipv4ipv6/expected_controller_config b/src/test/zones/evpn/ipv4ipv6/expected_controller_config
index d67552a9ed6b..9ee41a9cd637 100644
--- a/src/test/zones/evpn/ipv4ipv6/expected_controller_config
+++ b/src/test/zones/evpn/ipv4ipv6/expected_controller_config
@@ -4,7 +4,6 @@ hostname localhost
 log syslog informational
 service integrated-vtysh-config
 !
-!
 vrf vrf_myzone
  vni 1000
 exit-vrf
diff --git a/src/test/zones/evpn/ipv4ipv6nogateway/expected_controller_config b/src/test/zones/evpn/ipv4ipv6nogateway/expected_controller_config
index d67552a9ed6b..9ee41a9cd637 100644
--- a/src/test/zones/evpn/ipv4ipv6nogateway/expected_controller_config
+++ b/src/test/zones/evpn/ipv4ipv6nogateway/expected_controller_config
@@ -4,7 +4,6 @@ hostname localhost
 log syslog informational
 service integrated-vtysh-config
 !
-!
 vrf vrf_myzone
  vni 1000
 exit-vrf
diff --git a/src/test/zones/evpn/ipv6/expected_controller_config b/src/test/zones/evpn/ipv6/expected_controller_config
index d67552a9ed6b..9ee41a9cd637 100644
--- a/src/test/zones/evpn/ipv6/expected_controller_config
+++ b/src/test/zones/evpn/ipv6/expected_controller_config
@@ -4,7 +4,6 @@ hostname localhost
 log syslog informational
 service integrated-vtysh-config
 !
-!
 vrf vrf_myzone
  vni 1000
 exit-vrf
diff --git a/src/test/zones/evpn/ipv6underlay/expected_controller_config b/src/test/zones/evpn/ipv6underlay/expected_controller_config
index 6f453be54723..ddc5338f02b0 100644
--- a/src/test/zones/evpn/ipv6underlay/expected_controller_config
+++ b/src/test/zones/evpn/ipv6underlay/expected_controller_config
@@ -4,7 +4,6 @@ hostname localhost
 log syslog informational
 service integrated-vtysh-config
 !
-!
 vrf vrf_myzone
  vni 1000
 exit-vrf
diff --git a/src/test/zones/evpn/isis/expected_controller_config b/src/test/zones/evpn/isis/expected_controller_config
index e47afaa6d556..2361879e0cc2 100644
--- a/src/test/zones/evpn/isis/expected_controller_config
+++ b/src/test/zones/evpn/isis/expected_controller_config
@@ -4,7 +4,6 @@ hostname localhost
 log syslog informational
 service integrated-vtysh-config
 !
-!
 vrf vrf_myzone
  vni 1000
 exit-vrf
diff --git a/src/test/zones/evpn/isis_loopback/expected_controller_config b/src/test/zones/evpn/isis_loopback/expected_controller_config
index 487ca86b8d8b..f4a6e353377b 100644
--- a/src/test/zones/evpn/isis_loopback/expected_controller_config
+++ b/src/test/zones/evpn/isis_loopback/expected_controller_config
@@ -4,7 +4,6 @@ hostname localhost
 log syslog informational
 service integrated-vtysh-config
 !
-!
 vrf vrf_myzone
  vni 1000
 exit-vrf
diff --git a/src/test/zones/evpn/isis_standalone/expected_controller_config b/src/test/zones/evpn/isis_standalone/expected_controller_config
index 92a6f3843646..2cf4dcbf359a 100644
--- a/src/test/zones/evpn/isis_standalone/expected_controller_config
+++ b/src/test/zones/evpn/isis_standalone/expected_controller_config
@@ -4,7 +4,6 @@ hostname localhost
 log syslog informational
 service integrated-vtysh-config
 !
-!
 interface eth0
  ip router isis isis1
 !
diff --git a/src/test/zones/evpn/multipath_relax/expected_controller_config b/src/test/zones/evpn/multipath_relax/expected_controller_config
index 4a4617bdd1d5..783d05cf24d5 100644
--- a/src/test/zones/evpn/multipath_relax/expected_controller_config
+++ b/src/test/zones/evpn/multipath_relax/expected_controller_config
@@ -4,7 +4,6 @@ hostname localhost
 log syslog informational
 service integrated-vtysh-config
 !
-!
 vrf vrf_myzone
  vni 1000
 exit-vrf
diff --git a/src/test/zones/evpn/multiplezones/expected_controller_config b/src/test/zones/evpn/multiplezones/expected_controller_config
index ab3d85142315..6096221b2eef 100644
--- a/src/test/zones/evpn/multiplezones/expected_controller_config
+++ b/src/test/zones/evpn/multiplezones/expected_controller_config
@@ -4,7 +4,6 @@ hostname localhost
 log syslog informational
 service integrated-vtysh-config
 !
-!
 vrf vrf_myzone
  vni 1000
 exit-vrf
diff --git a/src/test/zones/evpn/openfabric_fabric/expected_controller_config b/src/test/zones/evpn/openfabric_fabric/expected_controller_config
index 397655e34722..a96adc329c5f 100644
--- a/src/test/zones/evpn/openfabric_fabric/expected_controller_config
+++ b/src/test/zones/evpn/openfabric_fabric/expected_controller_config
@@ -4,7 +4,6 @@ hostname localhost
 log syslog informational
 service integrated-vtysh-config
 !
-!
 vrf vrf_evpn
  vni 100
 exit-vrf
diff --git a/src/test/zones/evpn/ospf_fabric/expected_controller_config b/src/test/zones/evpn/ospf_fabric/expected_controller_config
index dd0e9d8a5573..bb9313244d53 100644
--- a/src/test/zones/evpn/ospf_fabric/expected_controller_config
+++ b/src/test/zones/evpn/ospf_fabric/expected_controller_config
@@ -4,7 +4,6 @@ hostname localhost
 log syslog informational
 service integrated-vtysh-config
 !
-!
 vrf vrf_evpn
  vni 100
 exit-vrf
diff --git a/src/test/zones/evpn/rt_import/expected_controller_config b/src/test/zones/evpn/rt_import/expected_controller_config
index c5b65724b9b6..367aaf3d2671 100644
--- a/src/test/zones/evpn/rt_import/expected_controller_config
+++ b/src/test/zones/evpn/rt_import/expected_controller_config
@@ -4,7 +4,6 @@ hostname localhost
 log syslog informational
 service integrated-vtysh-config
 !
-!
 vrf vrf_myzone
  vni 1000
 exit-vrf
diff --git a/src/test/zones/evpn/vxlanport/expected_controller_config b/src/test/zones/evpn/vxlanport/expected_controller_config
index d67552a9ed6b..9ee41a9cd637 100644
--- a/src/test/zones/evpn/vxlanport/expected_controller_config
+++ b/src/test/zones/evpn/vxlanport/expected_controller_config
@@ -4,7 +4,6 @@ hostname localhost
 log syslog informational
 service integrated-vtysh-config
 !
-!
 vrf vrf_myzone
  vni 1000
 exit-vrf
-- 
2.47.3





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

* [PATCH pve-network 02/10] sdn: tests: add missing comment '!' in frr config
  2026-02-03 16:01 [PATCH docs/manager/network/proxmox{-ve-rs,-perl-rs} 00/23] Generate frr config using jinja templates and rust types Gabriel Goller
                   ` (11 preceding siblings ...)
  2026-02-03 16:01 ` [PATCH pve-network 01/10] sdn: remove duplicate comment line '!' in frr config Gabriel Goller
@ 2026-02-03 16:01 ` Gabriel Goller
  2026-02-03 16:01 ` [PATCH pve-network 03/10] tests: use Test::Differences to make test assertions Gabriel Goller
                   ` (9 subsequent siblings)
  22 siblings, 0 replies; 24+ messages in thread
From: Gabriel Goller @ 2026-02-03 16:01 UTC (permalink / raw)
  To: pve-devel

Previous patch in proxmox-frr fixed a few serializing issues, it also
adds a missing '!' (frr comment) inbetween the fabrics config and the
controller config.

Signed-off-by: Gabriel Goller <g.goller@proxmox.com>
---
 .../zones/evpn/openfabric_fabric/expected_controller_config     | 2 +-
 src/test/zones/evpn/ospf_fabric/expected_controller_config      | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/src/test/zones/evpn/openfabric_fabric/expected_controller_config b/src/test/zones/evpn/openfabric_fabric/expected_controller_config
index a96adc329c5f..f3d82ef242be 100644
--- a/src/test/zones/evpn/openfabric_fabric/expected_controller_config
+++ b/src/test/zones/evpn/openfabric_fabric/expected_controller_config
@@ -40,6 +40,7 @@ exit
 !
 route-map MAP_VTEP_OUT permit 1
 exit
+!
 router openfabric test
  net 49.0001.1720.2000.3001.00
 exit
@@ -68,6 +69,5 @@ exit
 !
 ip protocol openfabric route-map pve_openfabric
 !
-!
 line vty
 !
diff --git a/src/test/zones/evpn/ospf_fabric/expected_controller_config b/src/test/zones/evpn/ospf_fabric/expected_controller_config
index bb9313244d53..3e41a56eeb9d 100644
--- a/src/test/zones/evpn/ospf_fabric/expected_controller_config
+++ b/src/test/zones/evpn/ospf_fabric/expected_controller_config
@@ -40,6 +40,7 @@ exit
 !
 route-map MAP_VTEP_OUT permit 1
 exit
+!
 router ospf
  ospf router-id 172.20.30.1
 exit
@@ -62,6 +63,5 @@ exit
 !
 ip protocol ospf route-map pve_ospf
 !
-!
 line vty
 !
-- 
2.47.3





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

* [PATCH pve-network 03/10] tests: use Test::Differences to make test assertions
  2026-02-03 16:01 [PATCH docs/manager/network/proxmox{-ve-rs,-perl-rs} 00/23] Generate frr config using jinja templates and rust types Gabriel Goller
                   ` (12 preceding siblings ...)
  2026-02-03 16:01 ` [PATCH pve-network 02/10] sdn: tests: add missing comment " Gabriel Goller
@ 2026-02-03 16:01 ` Gabriel Goller
  2026-02-03 16:01 ` [PATCH pve-network 04/10] sdn: write structured frr config that can be rendered using templates Gabriel Goller
                   ` (8 subsequent siblings)
  22 siblings, 0 replies; 24+ messages in thread
From: Gabriel Goller @ 2026-02-03 16:01 UTC (permalink / raw)
  To: pve-devel

Test::Differences provides a much better output. Instead of dumping the
whole file, eq_or_diff shows a pretty diff table with the changes
side-by-side. It also shows a small context and not the whole
expected/got file. This is very useful when especially when tests fails
on bigger config files.

Signed-off-by: Gabriel Goller <g.goller@proxmox.com>
---
 debian/control                      |  1 +
 src/test/run_test_dns.pl            | 15 +++++++-------
 src/test/run_test_ipams.pl          | 13 ++++++------
 src/test/run_test_subnets.pl        | 31 +++++++++++++++--------------
 src/test/run_test_vnets_blackbox.pl | 23 +++++++++++++--------
 src/test/run_test_zones.pl          |  5 +++--
 6 files changed, 50 insertions(+), 38 deletions(-)

diff --git a/debian/control b/debian/control
index ee7b2d40dfe2..6fe98822e64c 100644
--- a/debian/control
+++ b/debian/control
@@ -9,6 +9,7 @@ Build-Depends: debhelper-compat (= 13),
                libnet-subnet-perl,
                libpve-rs-perl (>= 0.11.1),
                libtest-mockmodule-perl,
+               libtest-differences-perl,
                perl,
                pve-cluster (>= 9.0.1),
                pve-firewall (>= 5.1.0~),
diff --git a/src/test/run_test_dns.pl b/src/test/run_test_dns.pl
index 26fbfaf035d2..f099da04a777 100755
--- a/src/test/run_test_dns.pl
+++ b/src/test/run_test_dns.pl
@@ -9,6 +9,7 @@ use Net::IP;
 
 use Test::More;
 use Test::MockModule;
+use Test::Differences;
 
 use PVE::Network::SDN;
 use PVE::Network::SDN::Zones;
@@ -101,7 +102,7 @@ foreach my $path (@plugins) {
         $plugin->add_a_record($plugin_config, $zone, $hostname, $ip, 1);
 
         if ($@) {
-            is($@, $expected, $name);
+            eq_or_diff($@, $expected, $name);
         } else {
             fail($name);
         }
@@ -114,7 +115,7 @@ foreach my $path (@plugins) {
         $plugin->add_ptr_record($plugin_config, $zone, $hostname, $ip, 1);
 
         if ($@) {
-            is($@, $expected, $name);
+            eq_or_diff($@, $expected, $name);
         } else {
             fail($name);
         }
@@ -127,7 +128,7 @@ foreach my $path (@plugins) {
         $plugin->del_ptr_record($plugin_config, $zone, $ip, 1);
 
         if ($@) {
-            is($@, $expected, $name);
+            eq_or_diff($@, $expected, $name);
         } else {
             fail($name);
         }
@@ -167,7 +168,7 @@ foreach my $path (@plugins) {
         $plugin->del_a_record($plugin_config, $zone, $hostname, $ip, 1);
 
         if ($@) {
-            is($@, $expected, $name);
+            eq_or_diff($@, $expected, $name);
         } else {
             fail($name);
         }
@@ -212,7 +213,7 @@ foreach my $path (@plugins) {
         $plugin->del_a_record($plugin_config, $zone, $hostname, $ip, 1);
 
         if ($@) {
-            is($@, $expected, $name);
+            eq_or_diff($@, $expected, $name);
         } else {
             fail($name);
         }
@@ -250,7 +251,7 @@ foreach my $path (@plugins) {
         $plugin->add_a_record($plugin_config, $zone, $hostname, $ip, 1);
 
         if ($@) {
-            is($@, $expected, $name);
+            eq_or_diff($@, $expected, $name);
         } else {
             fail($name);
         }
@@ -264,7 +265,7 @@ foreach my $path (@plugins) {
     $plugin->verify_zone($plugin_config, $zone, 1);
 
     if ($@) {
-        is($@, $expected, $name);
+        eq_or_diff($@, $expected, $name);
     } else {
         fail($name);
     }
diff --git a/src/test/run_test_ipams.pl b/src/test/run_test_ipams.pl
index 193b34bb98d9..f9c44fe1a3b9 100755
--- a/src/test/run_test_ipams.pl
+++ b/src/test/run_test_ipams.pl
@@ -8,6 +8,7 @@ use File::Slurp;
 
 use Test::More;
 use Test::MockModule;
+use Test::Differences;
 
 use PVE::Network::SDN;
 use PVE::Network::SDN::Zones;
@@ -120,7 +121,7 @@ foreach my $path (@plugins) {
     );
 
     if ($@) {
-        is($@, $expected, $name);
+        eq_or_diff($@, $expected, $name);
     } else {
         fail($name);
     }
@@ -133,7 +134,7 @@ foreach my $path (@plugins) {
     $plugin->add_next_freeip($plugin_config, $subnetid, $subnet, $hostname, $mac, $description, 1);
 
     if ($@) {
-        is($@, $expected, $name);
+        eq_or_diff($@, $expected, $name);
     } else {
         fail($name);
     }
@@ -146,7 +147,7 @@ foreach my $path (@plugins) {
     $plugin->del_ip($plugin_config, $subnetid, $subnet, $ip, 1);
 
     if ($@) {
-        is($@, $expected, $name);
+        eq_or_diff($@, $expected, $name);
     } else {
         fail($name);
     }
@@ -168,7 +169,7 @@ foreach my $path (@plugins) {
     );
 
     if ($@) {
-        is($@, $expected, $name);
+        eq_or_diff($@, $expected, $name);
     } else {
         fail($name);
     }
@@ -192,7 +193,7 @@ foreach my $path (@plugins) {
     );
 
     if ($@) {
-        is($@, $expected, $name);
+        eq_or_diff($@, $expected, $name);
     } else {
         fail($name);
     }
@@ -211,7 +212,7 @@ foreach my $path (@plugins) {
     $plugin->add_subnet($plugin_config, $subnetid, $subnet, 1);
 
     if ($@) {
-        is($@, $expected, $name);
+        eq_or_diff($@, $expected, $name);
     } else {
         fail($name);
     }
diff --git a/src/test/run_test_subnets.pl b/src/test/run_test_subnets.pl
index d4f8d6616de3..16443bf7ee26 100755
--- a/src/test/run_test_subnets.pl
+++ b/src/test/run_test_subnets.pl
@@ -8,6 +8,7 @@ use File::Slurp;
 
 use Test::More;
 use Test::MockModule;
+use Test::Differences;
 
 use PVE::Network::SDN;
 use PVE::Network::SDN::Zones;
@@ -147,9 +148,9 @@ foreach my $path (@plugins) {
         fail("$name : $@");
     } elsif ($ipam) {
         $result = $js->encode($plugin->read_db());
-        is($result, $expected, $name);
+        eq_or_diff($result, $expected, $name);
     } else {
-        is(undef, undef, $name);
+        eq_or_diff(undef, undef, $name);
     }
 
     ## add_ip
@@ -173,9 +174,9 @@ foreach my $path (@plugins) {
         fail("$name : $@");
     } elsif ($ipam) {
         $result = $js->encode($plugin->read_db());
-        is($result, $expected, $name);
+        eq_or_diff($result, $expected, $name);
     } else {
-        is(undef, undef, $name);
+        eq_or_diff(undef, undef, $name);
     }
 
     if ($ipam) {
@@ -190,7 +191,7 @@ foreach my $path (@plugins) {
         };
 
         if ($@) {
-            is(undef, undef, $name);
+            eq_or_diff(undef, undef, $name);
         } else {
             fail("$name : $@");
         }
@@ -225,9 +226,9 @@ foreach my $path (@plugins) {
         fail("$name : $@");
     } elsif ($ipam) {
         $result = $js->encode($plugin->read_db());
-        is($result, $expected, $name);
+        eq_or_diff($result, $expected, $name);
     } else {
-        is(undef, undef, $name);
+        eq_or_diff(undef, undef, $name);
     }
 
     ## add_next_free
@@ -266,7 +267,7 @@ foreach my $path (@plugins) {
         fail("$name : $@");
     } elsif ($ipam) {
         $result = $js->encode($plugin->read_db());
-        is($result, $expected, $name);
+        eq_or_diff($result, $expected, $name);
     }
 
     ## del_ip
@@ -299,9 +300,9 @@ foreach my $path (@plugins) {
         fail("$name : $@");
     } elsif ($ipam) {
         $result = $js->encode($plugin->read_db());
-        is($result, $expected, $name);
+        eq_or_diff($result, $expected, $name);
     } else {
-        is(undef, undef, $name);
+        eq_or_diff(undef, undef, $name);
     }
 
     if ($ipam) {
@@ -314,7 +315,7 @@ foreach my $path (@plugins) {
         eval { PVE::Network::SDN::Subnets::del_subnet($zone, $subnetid, $subnet); };
 
         if ($@) {
-            is($result, $expected, $name);
+            eq_or_diff($result, $expected, $name);
         } else {
             fail("$name : $@");
         }
@@ -367,9 +368,9 @@ foreach my $path (@plugins) {
     if ($@) {
         if ($ipam) {
             $result = $js->encode($plugin->read_db());
-            is($result, $expected, $name);
+            eq_or_diff($result, $expected, $name);
         } else {
-            is(undef, undef, $name);
+            eq_or_diff(undef, undef, $name);
         }
     } else {
         fail("$name : $@");
@@ -390,9 +391,9 @@ foreach my $path (@plugins) {
         fail("$name : $@");
     } elsif ($ipam) {
         $result = $js->encode($plugin->read_db());
-        is($result, $expected, $name);
+        eq_or_diff($result, $expected, $name);
     } else {
-        is(undef, undef, $name);
+        eq_or_diff(undef, undef, $name);
     }
 
 }
diff --git a/src/test/run_test_vnets_blackbox.pl b/src/test/run_test_vnets_blackbox.pl
index 468b1ede17e2..9f4c424f3d2f 100755
--- a/src/test/run_test_vnets_blackbox.pl
+++ b/src/test/run_test_vnets_blackbox.pl
@@ -10,6 +10,7 @@ use NetAddr::IP qw(:lower);
 
 use Test::More;
 use Test::MockModule;
+use Test::Differences;
 
 use PVE::Tools qw(extract_param file_set_contents);
 
@@ -416,7 +417,7 @@ sub test_without_subnet {
 
     my @ips = get_ips_from_mac($mac);
     my $num_ips = scalar @ips;
-    is($num_ips, 0, "$test_name: No IP allocated in IPAM");
+    eq_or_diff($num_ips, 0, "$test_name: No IP allocated in IPAM");
 }
 run_test(\&test_without_subnet);
 
@@ -463,7 +464,7 @@ sub test_nic_join {
 
     my @ips = get_ips_from_mac($mac);
     my $num_ips = scalar @ips;
-    is($num_ips, $num_subnets, "$test_name: Expecting $num_subnets IPs, found $num_ips");
+    eq_or_diff($num_ips, $num_subnets, "$test_name: Expecting $num_subnets IPs, found $num_ips");
     ok(
         (all { ($_->{vnet} eq $vnetid && $_->{zone} eq $zoneid) } @ips),
         "$test_name: all IPs in correct vnet and zone",
@@ -622,7 +623,7 @@ sub test_nic_join_full_dhcp_range {
 
     my @ips = get_ips_from_mac($mac);
     my $num_ips = scalar @ips;
-    is($num_ips, 0, "$test_name: No IP allocated in IPAM");
+    eq_or_diff($num_ips, 0, "$test_name: No IP allocated in IPAM");
 }
 
 run_test(
@@ -770,9 +771,9 @@ sub test_nic_start {
         });
     }
     my @current_ips = get_ips_from_mac($mac);
-    is(get_ip4(@current_ips), $current_ip4, "$test_name: setup current IPv4: $current_ip4")
+    eq_or_diff(get_ip4(@current_ips), $current_ip4, "$test_name: setup current IPv4: $current_ip4")
         if defined $current_ip4;
-    is(get_ip6(@current_ips), $current_ip6, "$test_name: setup current IPv6: $current_ip6")
+    eq_or_diff(get_ip6(@current_ips), $current_ip6, "$test_name: setup current IPv6: $current_ip6")
         if defined $current_ip6;
 
     eval { nic_start($vnetid, $mac, $hostname, $vmid); };
@@ -784,14 +785,20 @@ sub test_nic_start {
 
     my @ips = get_ips_from_mac($mac);
     my $num_ips = scalar @ips;
-    is($num_ips, $num_expected_ips, "$test_name: Expecting $num_expected_ips IPs, found $num_ips");
+    eq_or_diff(
+        $num_ips,
+        $num_expected_ips,
+        "$test_name: Expecting $num_expected_ips IPs, found $num_ips",
+    );
     ok(
         (all { ($_->{vnet} eq $vnetid && $_->{zone} eq $zoneid) } @ips),
         "$test_name: all IPs in correct vnet and zone",
     );
 
-    is(get_ip4(@ips), $current_ip4, "$test_name: still current IPv4: $current_ip4") if $current_ip4;
-    is(get_ip6(@ips), $current_ip6, "$test_name: still current IPv6: $current_ip6") if $current_ip6;
+    eq_or_diff(get_ip4(@ips), $current_ip4, "$test_name: still current IPv4: $current_ip4")
+        if $current_ip4;
+    eq_or_diff(get_ip6(@ips), $current_ip6, "$test_name: still current IPv6: $current_ip6")
+        if $current_ip6;
 }
 
 run_test(
diff --git a/src/test/run_test_zones.pl b/src/test/run_test_zones.pl
index 917f40a90069..905b2f42e1dc 100755
--- a/src/test/run_test_zones.pl
+++ b/src/test/run_test_zones.pl
@@ -8,6 +8,7 @@ use File::Slurp;
 
 use Test::More;
 use Test::MockModule;
+use Test::Differences;
 
 use PVE::Network::SDN;
 use PVE::Network::SDN::Zones;
@@ -140,7 +141,7 @@ foreach my $test (@tests) {
         diag("got unexpected error - $err");
         fail($name);
     } else {
-        is($result, $expected, $name);
+        eq_or_diff($result, $expected, $name);
     }
 
     if ($sdn_config->{controllers}) {
@@ -155,7 +156,7 @@ foreach my $test (@tests) {
             diag("got unexpected error - $err");
             fail($name);
         } else {
-            is($config, $expected, $name);
+            eq_or_diff($config, $expected, $name);
         }
     }
 }
-- 
2.47.3





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

* [PATCH pve-network 04/10] sdn: write structured frr config that can be rendered using templates
  2026-02-03 16:01 [PATCH docs/manager/network/proxmox{-ve-rs,-perl-rs} 00/23] Generate frr config using jinja templates and rust types Gabriel Goller
                   ` (13 preceding siblings ...)
  2026-02-03 16:01 ` [PATCH pve-network 03/10] tests: use Test::Differences to make test assertions Gabriel Goller
@ 2026-02-03 16:01 ` Gabriel Goller
  2026-02-03 16:01 ` [PATCH pve-network 05/10] tests: rearrange some statements in the frr config Gabriel Goller
                   ` (7 subsequent siblings)
  22 siblings, 0 replies; 24+ messages in thread
From: Gabriel Goller @ 2026-02-03 16:01 UTC (permalink / raw)
  To: pve-devel

The structured frr config can be deserialized by rust and rendered using
the templates (isis and bgp) in proxmox-frr.

Co-authored-by: Stefan Hanreich <s.hanreich@proxmox.com>
Signed-off-by: Gabriel Goller <g.goller@proxmox.com>
---
 src/PVE/Network/SDN.pm                        |  11 +-
 src/PVE/Network/SDN/Controllers/BgpPlugin.pm  | 104 ++---
 src/PVE/Network/SDN/Controllers/EvpnPlugin.pm | 372 +++++++++---------
 src/PVE/Network/SDN/Controllers/IsisPlugin.pm |  28 +-
 src/PVE/Network/SDN/Fabrics.pm                |  14 +-
 src/PVE/Network/SDN/Frr.pm                    | 163 +-------
 6 files changed, 276 insertions(+), 416 deletions(-)

diff --git a/src/PVE/Network/SDN.pm b/src/PVE/Network/SDN.pm
index c7c390e80586..c000bed498ec 100644
--- a/src/PVE/Network/SDN.pm
+++ b/src/PVE/Network/SDN.pm
@@ -419,15 +419,16 @@ sub generate_frr_raw_config {
     $fabric_config = PVE::Network::SDN::Fabrics::config(1) if !$fabric_config;
 
     my $frr_config = {};
+
     PVE::Network::SDN::Controllers::generate_frr_config($frr_config, $running_config);
     PVE::Network::SDN::Frr::append_local_config($frr_config);
+    PVE::Network::SDN::Frr::fix_routemap_seqs($frr_config);
 
-    my $raw_config = PVE::Network::SDN::Frr::to_raw_config($frr_config);
-
-    my $fabrics_config = PVE::Network::SDN::Fabrics::generate_frr_raw_config($fabric_config);
-    push @$raw_config, @$fabrics_config;
+    my $nodename = PVE::INotify::nodename();
 
-    return $raw_config;
+    return PVE::RS::SDN::get_frr_raw_config(
+        $frr_config->{'frr'}, $fabric_config, $nodename,
+    );
 }
 
 =head3 get_frr_daemon_status(\%fabric_config)
diff --git a/src/PVE/Network/SDN/Controllers/BgpPlugin.pm b/src/PVE/Network/SDN/Controllers/BgpPlugin.pm
index 447ebf1ba744..5651b85b64af 100644
--- a/src/PVE/Network/SDN/Controllers/BgpPlugin.pm
+++ b/src/PVE/Network/SDN/Controllers/BgpPlugin.pm
@@ -62,7 +62,7 @@ sub generate_frr_config {
     my @peers;
     @peers = PVE::Tools::split_list($plugin_config->{'peers'}) if $plugin_config->{'peers'};
 
-    my $asn = $plugin_config->{asn};
+    my $asn = int($plugin_config->{asn});
     my $ebgp = $plugin_config->{ebgp};
     my $ebgp_multihop = $plugin_config->{'ebgp-multihop'};
     my $loopback = $plugin_config->{loopback};
@@ -73,66 +73,80 @@ sub generate_frr_config {
     return if !$asn;
     return if $local_node ne $plugin_config->{node};
 
-    my $bgp = $config->{frr}->{router}->{"bgp $asn"} //= {};
-
     my ($ifaceip, $interface) =
         PVE::Network::SDN::Zones::Plugin::find_local_ip_interface_peers(\@peers, $loopback);
     my $routerid = PVE::Network::SDN::Controllers::Plugin::get_router_id($ifaceip, $interface);
 
-    my $remoteas = $ebgp ? "external" : $asn;
-
-    #global options
-    my @controller_config = (
-        "bgp router-id $routerid", "no bgp default ipv4-unicast", "coalesce-time 1000",
-    );
-
-    push(@{ $bgp->{""} }, @controller_config) if keys %{$bgp} == 0;
+    my $bgp_router = $config->{frr}->{bgp}->{vrf_router}->{'default'} //= {};
 
-    @controller_config = ();
-    if ($ebgp) {
-        push @controller_config, "bgp disable-ebgp-connected-route-check" if $loopback;
+    # Initialize router if not already configured
+    if (!keys %{$bgp_router}) {
+        $bgp_router->{asn} = $asn;
+        $bgp_router->{router_id} = $routerid;
+        $bgp_router->{default_ipv4_unicast} = 1;
+        $bgp_router->{coalesce_time} = 1000;
+        $bgp_router->{neighbor_groups} = [];
+        $bgp_router->{address_families} = {};
     }
 
-    push @controller_config, "bgp bestpath as-path multipath-relax" if $multipath_relax;
+    # Add BGP-specific options
+    $bgp_router->{disable_ebgp_connected_route_check} = 1 if $loopback && $ebgp;
+    $bgp_router->{bestpath_as_path_multipath_relax} = 1 if $multipath_relax;
 
-    #BGP neighbors
-    if (@peers) {
-        push @controller_config, "neighbor BGP peer-group";
-        push @controller_config, "neighbor BGP remote-as $remoteas";
-        push @controller_config, "neighbor BGP bfd";
-        push @controller_config, "neighbor BGP ebgp-multihop $ebgp_multihop"
-            if $ebgp && $ebgp_multihop;
-    }
-
-    # BGP peers
-    foreach my $address (@peers) {
-        push @controller_config, "neighbor $address peer-group BGP";
-    }
-    push(@{ $bgp->{""} }, @controller_config);
-
-    # address-family unicast
+    # Build BGP neighbor group
     if (@peers) {
+        my $neighbor_group = {
+            name => "BGP",
+            bfd => 1,
+            remote_as => $ebgp ? "external" : $asn,
+            ips => \@peers,
+            interfaces => [],
+        };
+        $neighbor_group->{ebgp_multihop} = int($ebgp_multihop) if $ebgp && $ebgp_multihop;
+
+        push @{ $bgp_router->{neighbor_groups} }, $neighbor_group;
+
+        # Configure address-family unicast
         my $ipversion = Net::IP::ip_is_ipv6($ifaceip) ? "ipv6" : "ipv4";
         my $mask = Net::IP::ip_is_ipv6($ifaceip) ? "128" : "32";
+        my $af_key = "${ipversion}_unicast";
+
+        $bgp_router->{address_families}->{$af_key} //= {
+            networks => [],
+            neighbors => [{
+                name => "BGP",
+                soft_reconfiguration_inbound => 1,
+            }],
+        };
 
-        push(@{ $bgp->{"address-family"}->{"$ipversion unicast"} }, "network $ifaceip/$mask")
+        push @{ $bgp_router->{address_families}->{$af_key}->{networks} }, "$ifaceip/$mask"
             if $loopback;
-        push(@{ $bgp->{"address-family"}->{"$ipversion unicast"} }, "neighbor BGP activate");
-        push(
-            @{ $bgp->{"address-family"}->{"$ipversion unicast"} },
-            "neighbor BGP soft-reconfiguration inbound",
-        );
     }
 
+    # Configure route-map for source IP correction with loopback
     if ($loopback) {
-        $config->{frr_prefix_list}->{loopbacks_ips}->{10} = "permit 0.0.0.0/0 le 32";
-        push(@{ $config->{frr_ip_protocol} }, "ip protocol bgp route-map correct_src");
-
-        my $routemap_config = ();
-        push @{$routemap_config}, "match ip address prefix-list loopbacks_ips";
-        push @{$routemap_config}, "set src $ifaceip";
-        my $routemap = { rule => $routemap_config, action => "permit" };
-        push(@{ $config->{frr_routemap}->{'correct_src'} }, $routemap);
+        $config->{frr}->{prefix_lists}->{loopbacks_ips} = [{
+            seq => 10,
+            action => 'permit',
+            network => '0.0.0.0/0',
+            le => 32,
+            is_ipv6 => 0,
+        }];
+
+        $config->{frr}->{protocol_routemaps}->{bgp}->{v4} = "correct_src";
+
+        my $routemap_config = {
+            protocol_type => 'ip',
+            match_type => 'address',
+            value => { list_type => 'prefixlist', list_name => 'loopbacks_ips' },
+        };
+        my $routemap = {
+            matches => [$routemap_config],
+            sets => [{ set_type => 'src', value => $ifaceip }],
+            action => "permit",
+            seq => 1,
+        };
+        push(@{ $config->{frr}->{routemaps}->{'correct_src'} }, $routemap);
     }
 
     return $config;
diff --git a/src/PVE/Network/SDN/Controllers/EvpnPlugin.pm b/src/PVE/Network/SDN/Controllers/EvpnPlugin.pm
index cc217126607f..3ea3ce2f033a 100644
--- a/src/PVE/Network/SDN/Controllers/EvpnPlugin.pm
+++ b/src/PVE/Network/SDN/Controllers/EvpnPlugin.pm
@@ -55,15 +55,15 @@ sub generate_frr_config {
     my $local_node = PVE::INotify::nodename();
 
     my @peers;
-    my $asn = $plugin_config->{asn};
+    my $asn = int($plugin_config->{asn});
     my $ebgp = undef;
     my $loopback = undef;
     my $autortas = undef;
     my $ifaceip = undef;
     my $routerid = undef;
 
-    my $bgprouter = find_bgp_controller($local_node, $controller_cfg);
-    my $isisrouter = find_isis_controller($local_node, $controller_cfg);
+    my $bgp_controller = find_bgp_controller($local_node, $controller_cfg);
+    my $isis_controller = find_isis_controller($local_node, $controller_cfg);
 
     if ($plugin_config->{'fabric'}) {
         my $config = PVE::Network::SDN::Fabrics::config(1);
@@ -102,10 +102,10 @@ sub generate_frr_config {
     } elsif ($plugin_config->{'peers'}) {
         @peers = PVE::Tools::split_list($plugin_config->{'peers'});
 
-        if ($bgprouter) {
-            $loopback = $bgprouter->{loopback} if $bgprouter->{loopback};
-        } elsif ($isisrouter) {
-            $loopback = $isisrouter->{loopback} if $isisrouter->{loopback};
+        if ($bgp_controller) {
+            $loopback = $bgp_controller->{loopback} if $bgp_controller->{loopback};
+        } elsif ($isis_controller) {
+            $loopback = $isis_controller->{loopback} if $isis_controller->{loopback};
         }
 
         ($ifaceip, my $interface) =
@@ -116,58 +116,58 @@ sub generate_frr_config {
         return;
     }
 
-    if ($bgprouter) {
-        $ebgp = 1 if $plugin_config->{'asn'} ne $bgprouter->{asn};
-        $asn = $bgprouter->{asn} if $bgprouter->{asn};
+    if ($bgp_controller) {
+        $ebgp = 1 if $plugin_config->{'asn'} ne $bgp_controller->{asn};
+        $asn = $bgp_controller->{asn} if $bgp_controller->{asn};
         $autortas = $plugin_config->{'asn'} if $ebgp;
     }
 
     return if !$asn || !$routerid;
-    my $bgp = $config->{frr}->{router}->{"bgp $asn"} //= {};
-
-    my $remoteas = $ebgp ? "external" : $asn;
-
-    #global options
-    my @controller_config = (
-        "bgp router-id $routerid",
-        "no bgp hard-administrative-reset",
-        "no bgp default ipv4-unicast",
-        "coalesce-time 1000",
-        "no bgp graceful-restart notification",
-    );
-
-    push(@{ $bgp->{""} }, @controller_config) if keys %{$bgp} == 0;
 
-    @controller_config = ();
+    my $bgp_router = $config->{frr}->{bgp}->{vrf_router}->{'default'} //= {};
 
-    #VTEP neighbors
-    push @controller_config, "neighbor VTEP peer-group";
-    push @controller_config, "neighbor VTEP remote-as $remoteas";
-    push @controller_config, "neighbor VTEP bfd";
+    # Initialize router if not already configured
+    if (!keys %{$bgp_router}) {
+        $bgp_router->{asn} = $asn;
+        $bgp_router->{router_id} = $routerid;
+        $bgp_router->{default_ipv4_unicast} = 0;
+        $bgp_router->{coalesce_time} = 1000;
+        $bgp_router->{neighbor_groups} = [];
+        $bgp_router->{address_families} = {};
+    }
 
-    push @controller_config, "neighbor VTEP ebgp-multihop 10" if $ebgp && $loopback;
-    push @controller_config, "neighbor VTEP update-source $loopback" if $loopback;
+    # Build VTEP neighbor group
+    my @vtep_ips = grep { $_ ne $ifaceip } @peers;
 
-    # VTEP peers
-    foreach my $address (@peers) {
-        next if $address eq $ifaceip;
-        push @controller_config, "neighbor $address peer-group VTEP";
-    }
+    my $neighbor_group = {
+        name => "VTEP",
+        bfd => 1,
+        remote_as => $ebgp ? "external" : $asn,
+        ips => \@vtep_ips,
+        interfaces => [],
+    };
+    $neighbor_group->{ebgp_multihop} = 10 if $ebgp && $loopback;
+    $neighbor_group->{update_source} = $loopback if $loopback;
+
+    push @{ $bgp_router->{neighbor_groups} }, $neighbor_group;
+
+    # Configure l2vpn evpn address family
+    $bgp_router->{address_families}->{l2vpn_evpn} //= {
+        neighbors => [{
+            name => "VTEP",
+            route_map_in => 'MAP_VTEP_IN',
+            route_map_out => 'MAP_VTEP_OUT',
+        }],
+        advertise_all_vni => 1,
+    };
 
-    push(@{ $bgp->{""} }, @controller_config);
+    $bgp_router->{address_families}->{l2vpn_evpn}->{autort_as} = $autortas if $autortas;
 
-    # address-family l2vpn
-    @controller_config = ();
-    push @controller_config, "neighbor VTEP activate";
-    push @controller_config, "neighbor VTEP route-map MAP_VTEP_IN in";
-    push @controller_config, "neighbor VTEP route-map MAP_VTEP_OUT out";
-    push @controller_config, "advertise-all-vni";
-    push @controller_config, "autort as $autortas" if $autortas;
-    push(@{ $bgp->{"address-family"}->{"l2vpn evpn"} }, @controller_config);
+    my $routemap_in = { seq => 1, action => "permit" };
+    my $routemap_out = { seq => 1, action => "permit" };
 
-    my $routemap = { rule => undef, action => "permit" };
-    push(@{ $config->{frr_routemap}->{'MAP_VTEP_IN'} }, $routemap);
-    push(@{ $config->{frr_routemap}->{'MAP_VTEP_OUT'} }, $routemap);
+    push($config->{frr}->{routemaps}->{'MAP_VTEP_IN'}->@*, $routemap_in);
+    push($config->{frr}->{routemaps}->{'MAP_VTEP_OUT'}->@*, $routemap_out);
 
     return $config;
 }
@@ -260,11 +260,17 @@ sub generate_zone_frr_config {
 
     my $is_gateway = $exitnodes->{$local_node};
 
-    # vrf
-    my @controller_config = ();
-    push @controller_config, "vni $vrfvxlan";
-    #avoid to routes between nodes through the exit nodes
-    #null routes subnets of other zones
+    # Configure VRF
+    my $vrf_router = $config->{frr}->{bgp}->{vrf_router}->{$vrf} //= {};
+    $vrf_router->{asn} = $asn;
+    $vrf_router->{router_id} = $routerid;
+
+    my $bgp_vrf = $config->{frr}->{bgp}->{vrfs}->{$vrf} //= {};
+
+    $bgp_vrf->{vni} = $vrfvxlan;
+    $bgp_vrf->{ip_routes} = [];
+
+    # Add null routes for other zones to avoid routing between nodes through exit nodes
     if ($is_gateway) {
         my $subnets = PVE::Network::SDN::Vnets::get_subnets();
         my $cidrs = {};
@@ -283,162 +289,135 @@ sub generate_zone_frr_config {
             keys $cidrs->%*;
 
         foreach my $ip (@sorted_ip) {
-            my $ipversion = Net::IP::ip_is_ipv4($ip) ? 'ip' : 'ipv6';
-            push @controller_config, "$ipversion route $ip/$cidrs->{$ip} null0";
+            my $is_ipv6 = Net::IP::ip_is_ipv6($ip);
+            push @{ $bgp_vrf->{ip_routes} },
+                {
+                    is_ipv6 => $is_ipv6,
+                    prefix => "$ip/$cidrs->{$ip}",
+                    via => "null0",
+                };
         }
     }
 
-    push(@{ $config->{frr}->{vrf}->{"$vrf"} }, @controller_config);
-
-    #main vrf router
-    @controller_config = ();
-    push @controller_config, "bgp router-id $routerid";
-    push @controller_config, "no bgp hard-administrative-reset";
-    push @controller_config, "no bgp graceful-restart notification";
-
-    #    push @controller_config, "!";
-    push(@{ $config->{frr}->{router}->{"bgp $asn vrf $vrf"}->{""} }, @controller_config);
+    # Configure VRF BGP router
+    $vrf_router->{neighbor_groups} = [];
+    $vrf_router->{address_families} = {};
 
+    # Configure L2VPN EVPN address family with route targets
     if ($autortas) {
-        push(
-            @{
-                $config->{frr}->{router}->{"bgp $asn vrf $vrf"}->{"address-family"}
-                    ->{"l2vpn evpn"}
-            },
-            "route-target import $autortas:$vrfvxlan",
-        );
-        push(
-            @{
-                $config->{frr}->{router}->{"bgp $asn vrf $vrf"}->{"address-family"}
-                    ->{"l2vpn evpn"}
-            },
-            "route-target export $autortas:$vrfvxlan",
-        );
+        $vrf_router->{address_families}->{l2vpn_evpn} //= {};
+        $vrf_router->{address_families}->{l2vpn_evpn}->{route_targets} = {
+            import => ["$autortas:$vrfvxlan"],
+            export => ["$autortas:$vrfvxlan"],
+        };
     }
 
     if ($is_gateway) {
-
-        $config->{frr_prefix_list}->{'only_default'}->{1} = "permit 0.0.0.0/0";
-        $config->{frr_prefix_list_v6}->{'only_default_v6'}->{1} = "permit ::/0";
+        push(
+            @{ $config->{frr}->{prefix_lists}->{only_default} },
+            { seq => 1, action => 'permit', network => '0.0.0.0/0', is_ipv6 => 0 },
+        ) if !defined($config->{frr}->{prefix_lists}->{only_default});
+        push(
+            @{ $config->{frr}->{prefix_lists}->{only_default_v6} },
+            { seq => 1, action => 'permit', network => '::/0', is_ipv6 => 1 },
+        ) if !defined($config->{frr}->{prefix_lists}->{only_default_v6});
 
         if (!$exitnodes_primary || $exitnodes_primary eq $local_node) {
-            #filter default route coming from other exit nodes on primary node or both nodes if no primary is defined.
-            my $routemap_config_v6 = ();
-            push @{$routemap_config_v6}, "match ipv6 address prefix-list only_default_v6";
-            my $routemap_v6 = { rule => $routemap_config_v6, action => "deny" };
-            unshift(@{ $config->{frr_routemap}->{'MAP_VTEP_IN'} }, $routemap_v6);
+            # Filter default route coming from other exit nodes on primary node
+            my $routemap_config_v6 = {
+                protocol_type => 'ipv6',
+                match_type => 'address',
+                value => { list_type => 'prefixlist', list_name => 'only_default_v6' },
+            };
+            my $routemap_v6 = { seq => 1, matches => [$routemap_config_v6], action => "deny" };
+            unshift(
+                @{ $config->{frr}->{routemaps}->{'MAP_VTEP_IN'} }, $routemap_v6,
+            );
 
-            my $routemap_config = ();
-            push @{$routemap_config}, "match ip address prefix-list only_default";
-            my $routemap = { rule => $routemap_config, action => "deny" };
-            unshift(@{ $config->{frr_routemap}->{'MAP_VTEP_IN'} }, $routemap);
+            my $routemap_config = {
+                protocol_type => 'ip',
+                match_type => 'address',
+                value => { list_type => 'prefixlist', list_name => 'only_default' },
+            };
+            my $routemap = { seq => 1, matches => [$routemap_config], action => "deny" };
+            unshift(@{ $config->{frr}->{routemaps}->{'MAP_VTEP_IN'} }, $routemap);
 
         } elsif ($exitnodes_primary ne $local_node) {
-            my $routemap_config_v6 = ();
-            push @{$routemap_config_v6}, "match ipv6 address prefix-list only_default_v6";
-            push @{$routemap_config_v6}, "set metric 200";
-            my $routemap_v6 = { rule => $routemap_config_v6, action => "permit" };
-            unshift(@{ $config->{frr_routemap}->{'MAP_VTEP_OUT'} }, $routemap_v6);
-
-            my $routemap_config = ();
-            push @{$routemap_config}, "match ip address prefix-list only_default";
-            push @{$routemap_config}, "set metric 200";
-            my $routemap = { rule => $routemap_config, action => "permit" };
-            unshift(@{ $config->{frr_routemap}->{'MAP_VTEP_OUT'} }, $routemap);
+            my $routemap_config_v6 = {
+                protocol_type => 'ipv6',
+                match_type => 'address',
+                value => { list_type => 'prefixlist', list_name => 'only_default_v6' },
+            };
+            my $routemap_v6 = {
+                seq => 1,
+                matches => [$routemap_config_v6],
+                sets => [{ set_type => 'metric', value => 200 }],
+                action => "permit",
+            };
+            unshift(
+                @{ $config->{frr}->{routemaps}->{'MAP_VTEP_OUT'} }, $routemap_v6,
+            );
+
+            my $routemap_config = {
+                protocol_type => 'ip',
+                match_type => 'address',
+                value => { list_type => 'prefixlist', list_name => 'only_default' },
+            };
+            my $routemap = {
+                seq => 1,
+                matches => [$routemap_config],
+                sets => [{ set_type => 'metric', value => 200 }],
+                action => "permit",
+            };
+            unshift(@{ $config->{frr}->{routemaps}->{'MAP_VTEP_OUT'} }, $routemap);
         }
 
         if (!$exitnodes_local_routing) {
-            @controller_config = ();
-            #import /32 routes of evpn network from vrf1 to default vrf (for packet return)
-            push @controller_config, "import vrf $vrf";
-            push(
-                @{
-                    $config->{frr}->{router}->{"bgp $asn"}->{"address-family"}->{"ipv4 unicast"}
-                },
-                @controller_config,
-            );
-            push(
-                @{
-                    $config->{frr}->{router}->{"bgp $asn"}->{"address-family"}->{"ipv6 unicast"}
-                },
-                @controller_config,
-            );
-
-            @controller_config = ();
-            #redistribute connected to be able to route to local vms on the gateway
-            push @controller_config, "redistribute connected";
-            push(
-                @{
-                    $config->{frr}->{router}->{"bgp $asn vrf $vrf"}->{"address-family"}
-                        ->{"ipv4 unicast"}
-                },
-                @controller_config,
-            );
-            push(
-                @{
-                    $config->{frr}->{router}->{"bgp $asn vrf $vrf"}->{"address-family"}
-                        ->{"ipv6 unicast"}
-                },
-                @controller_config,
-            );
+            # Import /32 routes from VRF to main router
+            my $main_bgp_router = $config->{frr}->{bgp}->{vrf_router}->{'default'};
+            if ($main_bgp_router) {
+                $main_bgp_router->{address_families}->{ipv4_unicast} //= {};
+                push(@ {$main_bgp_router->{address_families}->{ipv4_unicast}->{import_vrf} }, $vrf);
+
+                $main_bgp_router->{address_families}->{ipv6_unicast} //= {};
+                push(@ {$main_bgp_router->{address_families}->{ipv6_unicast}->{import_vrf} }, $vrf);
+            }
+
+            # Redistribute connected in VRF router
+            $vrf_router->{address_families}->{ipv4_unicast} //= { redistribute => [] };
+            push @{ $vrf_router->{address_families}->{ipv4_unicast}->{redistribute} },
+                { protocol => "connected" };
+
+            $vrf_router->{address_families}->{ipv6_unicast} //= { redistribute => [] };
+            push @{ $vrf_router->{address_families}->{ipv6_unicast}->{redistribute} },
+                { protocol => "connected" };
         }
 
-        @controller_config = ();
-        #add default originate to announce 0.0.0.0/0 type5 route in evpn
-        push @controller_config, "default-originate ipv4";
-        push @controller_config, "default-originate ipv6";
-        push(
-            @{
-                $config->{frr}->{router}->{"bgp $asn vrf $vrf"}->{"address-family"}
-                    ->{"l2vpn evpn"}
-            },
-            @controller_config,
-        );
-    } elsif ($advertisesubnets) {
+        # Add default originate to announce 0.0.0.0/0 type5 route in evpn
+        $vrf_router->{address_families}->{l2vpn_evpn} //= {};
+        $vrf_router->{address_families}->{l2vpn_evpn}->{default_originate} = ["ipv4", "ipv6"];
 
-        @controller_config = ();
-        #redistribute connected networks
-        push @controller_config, "redistribute connected";
-        push(
-            @{
-                $config->{frr}->{router}->{"bgp $asn vrf $vrf"}->{"address-family"}
-                    ->{"ipv4 unicast"}
-            },
-            @controller_config,
-        );
-        push(
-            @{
-                $config->{frr}->{router}->{"bgp $asn vrf $vrf"}->{"address-family"}
-                    ->{"ipv6 unicast"}
-            },
-            @controller_config,
-        );
-
-        @controller_config = ();
-        #advertise connected networks type5 route in evpn
-        push @controller_config, "advertise ipv4 unicast";
-        push @controller_config, "advertise ipv6 unicast";
-        push(
-            @{
-                $config->{frr}->{router}->{"bgp $asn vrf $vrf"}->{"address-family"}
-                    ->{"l2vpn evpn"}
-            },
-            @controller_config,
-        );
+    } elsif ($advertisesubnets) {
+        # Redistribute connected networks
+        $vrf_router->{address_families}->{ipv4_unicast} //= { redistribute => [] };
+        push @{ $vrf_router->{address_families}->{ipv4_unicast}->{redistribute} },
+            { protocol => "connected" };
+
+        $vrf_router->{address_families}->{ipv6_unicast} //= { redistribute => [] };
+        push @{ $vrf_router->{address_families}->{ipv6_unicast}->{redistribute} },
+            { protocol => "connected" };
+
+        # Advertise connected networks type5 route in evpn
+        $vrf_router->{address_families}->{l2vpn_evpn} //= {};
+        $vrf_router->{address_families}->{l2vpn_evpn}->{advertise_ipv4_unicast} = 1;
+        $vrf_router->{address_families}->{l2vpn_evpn}->{advertise_ipv6_unicast} = 1;
     }
 
     if ($rt_import) {
-        @controller_config = ();
-        foreach my $rt (sort @{$rt_import}) {
-            push @controller_config, "route-target import $rt";
-        }
-        push(
-            @{
-                $config->{frr}->{router}->{"bgp $asn vrf $vrf"}->{"address-family"}
-                    ->{"l2vpn evpn"}
-            },
-            @controller_config,
-        );
+        $vrf_router->{address_families}->{l2vpn_evpn} //= { route_targets => {} };
+        $vrf_router->{address_families}->{l2vpn_evpn}->{route_targets}->{import} //= [];
+        push @{ $vrf_router->{address_families}->{l2vpn_evpn}->{route_targets}->{import} },
+            @{$rt_import};
     }
 
     return $config;
@@ -458,18 +437,29 @@ sub generate_vnet_frr_config {
     return if !$is_gateway;
 
     my $subnets = PVE::Network::SDN::Vnets::get_subnets($vnetid, 1);
-    my @controller_config = ();
+    $config->{frr}->{ip_routes} //= [];
     foreach my $subnetid (sort keys %{$subnets}) {
         my $subnet = $subnets->{$subnetid};
         my $cidr = $subnet->{cidr};
         my ($ip) = split(/\//, $cidr, 2);
         if (Net::IP::ip_is_ipv6($ip)) {
-            push @controller_config, "ipv6 route $cidr fe80::2 xvrf_$zoneid";
+            push @{ $config->{frr}->{ip_routes} },
+                {
+                    prefix => $cidr,
+                    via => "fe80::2",
+                    vrf => "xvrf_$zoneid",
+                    is_ipv6 => 1,
+                };
         } else {
-            push @controller_config, "ip route $cidr 10.255.255.2 xvrf_$zoneid";
+            push @{ $config->{frr}->{ip_routes} },
+                {
+                    prefix => $cidr,
+                    via => "10.255.255.2",
+                    vrf => "xvrf_$zoneid",
+                    is_ipv6 => 0,
+                };
         }
     }
-    push(@{ $config->{frr_ip_protocol} }, @controller_config);
 }
 
 sub on_delete_hook {
diff --git a/src/PVE/Network/SDN/Controllers/IsisPlugin.pm b/src/PVE/Network/SDN/Controllers/IsisPlugin.pm
index 3a9acfda0744..454bdda6d316 100644
--- a/src/PVE/Network/SDN/Controllers/IsisPlugin.pm
+++ b/src/PVE/Network/SDN/Controllers/IsisPlugin.pm
@@ -69,23 +69,27 @@ sub generate_frr_config {
     return if !$isis_ifaces || !$isis_net || !$isis_domain;
     return if $local_node ne $plugin_config->{node};
 
-    my @router_config = (
-        "net $isis_net",
-        "redistribute ipv4 connected level-1",
-        "redistribute ipv6 connected level-1",
-        "log-adjacency-changes",
-    );
-
-    push(@{ $config->{frr}->{router}->{"isis $isis_domain"} }, @router_config);
-
-    my @iface_config = ("ip router isis $isis_domain");
+    # Configure IS-IS router
+    my $isis_router = $config->{frr}->{isis}->{router}->{$isis_domain} //= {};
+
+    $isis_router->{net} = $isis_net;
+    $isis_router->{log_adjacency_changes} = 1;
+    $isis_router->{redistribute} = {
+        ipv4_connected => "level-1",
+        ipv6_connected => "level-1",
+    };
 
+    # Configure interfaces
     my $altnames = PVE::Network::altname_mapping();
-
     my @ifaces = PVE::Tools::split_list($isis_ifaces);
+
+    $config->{frr}->{isis}->{interfaces} //= {};
     for my $iface (sort @ifaces) {
         my $iface_name = $altnames->{$iface} // $iface;
-        push(@{ $config->{frr_interfaces}->{$iface_name} }, @iface_config);
+        $config->{frr}->{isis}->{interfaces}->{$iface_name} //= {};
+        $config->{frr}->{isis}->{interfaces}->{$iface_name}->{domain} = $isis_domain;
+        $config->{frr}->{isis}->{interfaces}->{$iface_name}->{is_ipv4} = 1;
+        $config->{frr}->{isis}->{interfaces}->{$iface_name}->{is_ipv6} = 0;
     }
 
     return $config;
diff --git a/src/PVE/Network/SDN/Fabrics.pm b/src/PVE/Network/SDN/Fabrics.pm
index d90992a7eceb..3ca362a02660 100644
--- a/src/PVE/Network/SDN/Fabrics.pm
+++ b/src/PVE/Network/SDN/Fabrics.pm
@@ -6,6 +6,7 @@ use warnings;
 use PVE::Cluster qw(cfs_register_file cfs_read_file cfs_lock_file cfs_write_file);
 use PVE::JSONSchema qw(get_standard_option);
 use PVE::INotify;
+use PVE::RS::SDN;
 use PVE::RS::SDN::Fabrics;
 
 PVE::JSONSchema::register_format(
@@ -100,19 +101,6 @@ sub get_frr_daemon_status {
     return $daemon_status;
 }
 
-sub generate_frr_raw_config {
-    my ($fabric_config) = @_;
-
-    my @raw_config = ();
-
-    my $nodename = PVE::INotify::nodename();
-
-    my $frr_config = $fabric_config->get_frr_raw_config($nodename);
-    push @raw_config, @$frr_config if @$frr_config;
-
-    return \@raw_config;
-}
-
 sub generate_etc_network_config {
     my $nodename = PVE::INotify::nodename();
     my $fabric_config = PVE::Network::SDN::Fabrics::config(1);
diff --git a/src/PVE/Network/SDN/Frr.pm b/src/PVE/Network/SDN/Frr.pm
index 9f81369c079b..f084ad5a578f 100644
--- a/src/PVE/Network/SDN/Frr.pm
+++ b/src/PVE/Network/SDN/Frr.pm
@@ -187,28 +187,26 @@ sub set_daemon_status {
     return $changed;
 }
 
-=head3 to_raw_config(\%frr_config)
+=head3 fix_routemap_seqs(\$frr_config)
 
-Converts a given C<\%frr_config> to the raw config format.
+Iterates over all bgp route-maps in C<\$frr_config> and renumbers their sequence
+numbers to be consecutive, starting from 1 and incrementing by 1 for each entry.
 
 =cut
 
-sub to_raw_config {
+sub fix_routemap_seqs {
     my ($frr_config) = @_;
 
-    my $raw_config = [];
+    my $routemaps = $frr_config->{'frr'}->{'bgp'}->{'routemaps'};
 
-    generate_frr_vrf($raw_config, $frr_config->{frr}->{vrf});
-    generate_frr_interfaces($raw_config, $frr_config->{frr_interfaces});
-    generate_frr_recurse($raw_config, $frr_config->{frr}, undef, 0);
-    generate_frr_list($raw_config, $frr_config->{frr_access_list}, "access-list");
-    generate_frr_list($raw_config, $frr_config->{frr_prefix_list}, "ip prefix-list");
-    generate_frr_list($raw_config, $frr_config->{frr_prefix_list_v6}, "ipv6 prefix-list");
-    generate_frr_simple_list($raw_config, $frr_config->{frr_bgp_community_list});
-    generate_frr_routemap($raw_config, $frr_config->{frr_routemap});
-    generate_frr_simple_list($raw_config, $frr_config->{frr_ip_protocol});
-
-    return $raw_config;
+    foreach my $id (sort keys %$routemaps) {
+        my $routemap = $routemaps->{$id};
+        my $order = 0;
+        foreach my $seq (@$routemap) {
+            $order++;
+            $seq->{seq} = $order;
+        }
+    }
 }
 
 =head3 raw_config_to_string(\@raw_config)
@@ -339,139 +337,4 @@ sub append_local_config {
     }
 }
 
-sub generate_frr_recurse {
-    my ($final_config, $content, $parentkey, $level) = @_;
-
-    my $keylist = {};
-    $keylist->{'address-family'} = 1;
-    $keylist->{router} = 1;
-
-    my $exitkeylist = {};
-    $exitkeylist->{'address-family'} = 1;
-
-    my $simple_exitkeylist = {};
-    $simple_exitkeylist->{router} = 1;
-
-    # FIXME: make this generic
-    my $paddinglevel = undef;
-    if ($level == 1 || $level == 2) {
-        $paddinglevel = $level - 1;
-    } elsif ($level == 3 || $level == 4) {
-        $paddinglevel = $level - 2;
-    }
-
-    my $padding = "";
-    $padding = ' ' x ($paddinglevel) if $paddinglevel;
-
-    if (ref $content eq 'HASH') {
-        foreach my $key (sort keys %$content) {
-            next if $key eq 'vrf';
-            if ($parentkey && defined($keylist->{$parentkey})) {
-                push @{$final_config}, $padding . "!";
-                push @{$final_config}, $padding . "$parentkey $key";
-            } elsif ($key ne '' && !defined($keylist->{$key})) {
-                push @{$final_config}, $padding . "$key";
-            }
-
-            my $option = $content->{$key};
-            generate_frr_recurse($final_config, $option, $key, $level + 1);
-
-            push @{$final_config}, $padding . "exit-$parentkey"
-                if $parentkey && defined($exitkeylist->{$parentkey});
-            push @{$final_config}, $padding . "exit"
-                if $parentkey && defined($simple_exitkeylist->{$parentkey});
-        }
-    }
-
-    if (ref $content eq 'ARRAY') {
-        push @{$final_config}, map { $padding . "$_" } @$content;
-    }
-}
-
-sub generate_frr_vrf {
-    my ($final_config, $vrfs) = @_;
-
-    return if !$vrfs;
-
-    my @config = ();
-
-    foreach my $id (sort keys %$vrfs) {
-        my $vrf = $vrfs->{$id};
-        push @config, "!";
-        push @config, "vrf $id";
-        foreach my $rule (@$vrf) {
-            push @config, " $rule";
-
-        }
-        push @config, "exit-vrf";
-    }
-
-    push @{$final_config}, @config;
-}
-
-sub generate_frr_simple_list {
-    my ($final_config, $rules) = @_;
-
-    return if !$rules;
-
-    my @config = ();
-    push @{$final_config}, "!";
-    foreach my $rule (sort @$rules) {
-        push @{$final_config}, $rule;
-    }
-}
-
-sub generate_frr_list {
-    my ($final_config, $lists, $type) = @_;
-
-    my $config = [];
-
-    for my $id (sort keys %$lists) {
-        my $list = $lists->{$id};
-
-        for my $seq (sort keys %$list) {
-            my $rule = $list->{$seq};
-            push @$config, "$type $id seq $seq $rule";
-        }
-    }
-
-    if (@$config > 0) {
-        push @{$final_config}, "!", @$config;
-    }
-}
-
-sub generate_frr_interfaces {
-    my ($final_config, $interfaces) = @_;
-
-    foreach my $k (sort keys %$interfaces) {
-        my $iface = $interfaces->{$k};
-        push @{$final_config}, "!";
-        push @{$final_config}, "interface $k";
-        foreach my $rule (sort @$iface) {
-            push @{$final_config}, " $rule";
-        }
-    }
-}
-
-sub generate_frr_routemap {
-    my ($final_config, $routemaps) = @_;
-
-    foreach my $id (sort keys %$routemaps) {
-
-        my $routemap = $routemaps->{$id};
-        my $order = 0;
-        foreach my $seq (@$routemap) {
-            $order++;
-            next if !defined($seq->{action});
-            my @config = ();
-            push @config, "!";
-            push @config, "route-map $id $seq->{action} $order";
-            my $rule = $seq->{rule};
-            push @config, map { " $_" } @$rule;
-            push @{$final_config}, @config;
-            push @{$final_config}, "exit";
-        }
-    }
-}
-
 1;
-- 
2.47.3





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

* [PATCH pve-network 05/10] tests: rearrange some statements in the frr config
  2026-02-03 16:01 [PATCH docs/manager/network/proxmox{-ve-rs,-perl-rs} 00/23] Generate frr config using jinja templates and rust types Gabriel Goller
                   ` (14 preceding siblings ...)
  2026-02-03 16:01 ` [PATCH pve-network 04/10] sdn: write structured frr config that can be rendered using templates Gabriel Goller
@ 2026-02-03 16:01 ` Gabriel Goller
  2026-02-03 16:01 ` [PATCH pve-network 06/10] sdn: adjust frr.conf.local merging to rust template types Gabriel Goller
                   ` (6 subsequent siblings)
  22 siblings, 0 replies; 24+ messages in thread
From: Gabriel Goller @ 2026-02-03 16:01 UTC (permalink / raw)
  To: pve-devel

Move some stuff up and down, this makes it easier to generate the config
using templates.

Signed-off-by: Gabriel Goller <g.goller@proxmox.com>
---
 .../evpn/ebgp_loopback/expected_controller_config  |  2 +-
 .../zones/evpn/isis/expected_controller_config     | 14 ++++++++------
 .../evpn/isis_loopback/expected_controller_config  | 14 ++++++++------
 .../isis_standalone/expected_controller_config     | 14 ++++++++------
 .../multipath_relax/expected_controller_config     |  2 +-
 .../openfabric_fabric/expected_controller_config   | 12 ++++++------
 .../evpn/ospf_fabric/expected_controller_config    | 12 ++++++------
 7 files changed, 38 insertions(+), 32 deletions(-)

diff --git a/src/test/zones/evpn/ebgp_loopback/expected_controller_config b/src/test/zones/evpn/ebgp_loopback/expected_controller_config
index 38f62d06540c..aedb2daef418 100644
--- a/src/test/zones/evpn/ebgp_loopback/expected_controller_config
+++ b/src/test/zones/evpn/ebgp_loopback/expected_controller_config
@@ -14,6 +14,7 @@ router bgp 65001
  no bgp default ipv4-unicast
  coalesce-time 1000
  no bgp graceful-restart notification
+ bgp disable-ebgp-connected-route-check
  neighbor VTEP peer-group
  neighbor VTEP remote-as external
  neighbor VTEP bfd
@@ -21,7 +22,6 @@ router bgp 65001
  neighbor VTEP update-source dummy1
  neighbor 192.168.0.2 peer-group VTEP
  neighbor 192.168.0.3 peer-group VTEP
- bgp disable-ebgp-connected-route-check
  neighbor BGP peer-group
  neighbor BGP remote-as external
  neighbor BGP bfd
diff --git a/src/test/zones/evpn/isis/expected_controller_config b/src/test/zones/evpn/isis/expected_controller_config
index 2361879e0cc2..af9ba30ac97e 100644
--- a/src/test/zones/evpn/isis/expected_controller_config
+++ b/src/test/zones/evpn/isis/expected_controller_config
@@ -8,12 +8,6 @@ vrf vrf_myzone
  vni 1000
 exit-vrf
 !
-interface eth0
- ip router isis isis1
-!
-interface eth1
- ip router isis isis1
-!
 router bgp 65000
  bgp router-id 192.168.0.1
  no bgp hard-administrative-reset
@@ -47,6 +41,14 @@ router isis isis1
  log-adjacency-changes
 exit
 !
+interface eth0
+ ip router isis isis1
+exit
+!
+interface eth1
+ ip router isis isis1
+exit
+!
 route-map MAP_VTEP_IN permit 1
 exit
 !
diff --git a/src/test/zones/evpn/isis_loopback/expected_controller_config b/src/test/zones/evpn/isis_loopback/expected_controller_config
index f4a6e353377b..0eed628286af 100644
--- a/src/test/zones/evpn/isis_loopback/expected_controller_config
+++ b/src/test/zones/evpn/isis_loopback/expected_controller_config
@@ -8,12 +8,6 @@ vrf vrf_myzone
  vni 1000
 exit-vrf
 !
-interface eth0
- ip router isis isis1
-!
-interface eth1
- ip router isis isis1
-!
 router bgp 65000
  bgp router-id 10.0.0.1
  no bgp hard-administrative-reset
@@ -48,6 +42,14 @@ router isis isis1
  log-adjacency-changes
 exit
 !
+interface eth0
+ ip router isis isis1
+exit
+!
+interface eth1
+ ip router isis isis1
+exit
+!
 route-map MAP_VTEP_IN permit 1
 exit
 !
diff --git a/src/test/zones/evpn/isis_standalone/expected_controller_config b/src/test/zones/evpn/isis_standalone/expected_controller_config
index 2cf4dcbf359a..149e4e1f8b79 100644
--- a/src/test/zones/evpn/isis_standalone/expected_controller_config
+++ b/src/test/zones/evpn/isis_standalone/expected_controller_config
@@ -4,12 +4,6 @@ hostname localhost
 log syslog informational
 service integrated-vtysh-config
 !
-interface eth0
- ip router isis isis1
-!
-interface eth1
- ip router isis isis1
-!
 router isis isis1
  net 47.0023.0000.0000.0000.0000.0000.0000.1900.0004.00
  redistribute ipv4 connected level-1
@@ -17,5 +11,13 @@ router isis isis1
  log-adjacency-changes
 exit
 !
+interface eth0
+ ip router isis isis1
+exit
+!
+interface eth1
+ ip router isis isis1
+exit
+!
 line vty
 !
diff --git a/src/test/zones/evpn/multipath_relax/expected_controller_config b/src/test/zones/evpn/multipath_relax/expected_controller_config
index 783d05cf24d5..26b70b767782 100644
--- a/src/test/zones/evpn/multipath_relax/expected_controller_config
+++ b/src/test/zones/evpn/multipath_relax/expected_controller_config
@@ -14,12 +14,12 @@ router bgp 65000
  no bgp default ipv4-unicast
  coalesce-time 1000
  no bgp graceful-restart notification
+ bgp bestpath as-path multipath-relax
  neighbor VTEP peer-group
  neighbor VTEP remote-as 65000
  neighbor VTEP bfd
  neighbor 192.168.0.2 peer-group VTEP
  neighbor 192.168.0.3 peer-group VTEP
- bgp bestpath as-path multipath-relax
  neighbor BGP peer-group
  neighbor BGP remote-as 65000
  neighbor BGP bfd
diff --git a/src/test/zones/evpn/openfabric_fabric/expected_controller_config b/src/test/zones/evpn/openfabric_fabric/expected_controller_config
index f3d82ef242be..e749e279769e 100644
--- a/src/test/zones/evpn/openfabric_fabric/expected_controller_config
+++ b/src/test/zones/evpn/openfabric_fabric/expected_controller_config
@@ -35,12 +35,6 @@ router bgp 65000 vrf vrf_evpn
  no bgp graceful-restart notification
 exit
 !
-route-map MAP_VTEP_IN permit 1
-exit
-!
-route-map MAP_VTEP_OUT permit 1
-exit
-!
 router openfabric test
  net 49.0001.1720.2000.3001.00
 exit
@@ -62,6 +56,12 @@ exit
 !
 access-list pve_openfabric_test_ips permit 172.20.3.0/24
 !
+route-map MAP_VTEP_IN permit 1
+exit
+!
+route-map MAP_VTEP_OUT permit 1
+exit
+!
 route-map pve_openfabric permit 100
  match ip address pve_openfabric_test_ips
  set src 172.20.3.1
diff --git a/src/test/zones/evpn/ospf_fabric/expected_controller_config b/src/test/zones/evpn/ospf_fabric/expected_controller_config
index 3e41a56eeb9d..b5a1b925213c 100644
--- a/src/test/zones/evpn/ospf_fabric/expected_controller_config
+++ b/src/test/zones/evpn/ospf_fabric/expected_controller_config
@@ -35,12 +35,6 @@ router bgp 65000 vrf vrf_evpn
  no bgp graceful-restart notification
 exit
 !
-route-map MAP_VTEP_IN permit 1
-exit
-!
-route-map MAP_VTEP_OUT permit 1
-exit
-!
 router ospf
  ospf router-id 172.20.30.1
 exit
@@ -56,6 +50,12 @@ exit
 !
 access-list pve_ospf_test_ips permit 172.20.30.0/24
 !
+route-map MAP_VTEP_IN permit 1
+exit
+!
+route-map MAP_VTEP_OUT permit 1
+exit
+!
 route-map pve_ospf permit 100
  match ip address pve_ospf_test_ips
  set src 172.20.30.1
-- 
2.47.3





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

* [PATCH pve-network 06/10] sdn: adjust frr.conf.local merging to rust template types
  2026-02-03 16:01 [PATCH docs/manager/network/proxmox{-ve-rs,-perl-rs} 00/23] Generate frr config using jinja templates and rust types Gabriel Goller
                   ` (15 preceding siblings ...)
  2026-02-03 16:01 ` [PATCH pve-network 05/10] tests: rearrange some statements in the frr config Gabriel Goller
@ 2026-02-03 16:01 ` Gabriel Goller
  2026-02-03 16:01 ` [PATCH pve-network 07/10] cli: add pvesdn cli tool for managing frr template overrides Gabriel Goller
                   ` (5 subsequent siblings)
  22 siblings, 0 replies; 24+ messages in thread
From: Gabriel Goller @ 2026-02-03 16:01 UTC (permalink / raw)
  To: pve-devel

The frr object in perl which stores the whole frr config is now also
modeled in rust, so it changed a bit. Adjust the frr.conf.local merging
code so that the frr.conf.local is still merged correctly. This makes
use of the `custom_frr_config` properties scattered in many rust types.
So if we encounter a line in frr.conf.local that we need to merge, we
just throw it into this string vec and render it as-is.

Co-authored-by: Stefan Hanreich <s.hanreich@proxmox.com>
Signed-off-by: Gabriel Goller <g.goller@proxmox.com>
---
 src/PVE/Network/SDN/Controllers/EvpnPlugin.pm |   4 +-
 src/PVE/Network/SDN/Frr.pm                    | 204 +++++++++++++++---
 2 files changed, 171 insertions(+), 37 deletions(-)

diff --git a/src/PVE/Network/SDN/Controllers/EvpnPlugin.pm b/src/PVE/Network/SDN/Controllers/EvpnPlugin.pm
index 3ea3ce2f033a..1f221807cb7b 100644
--- a/src/PVE/Network/SDN/Controllers/EvpnPlugin.pm
+++ b/src/PVE/Network/SDN/Controllers/EvpnPlugin.pm
@@ -377,10 +377,10 @@ sub generate_zone_frr_config {
             my $main_bgp_router = $config->{frr}->{bgp}->{vrf_router}->{'default'};
             if ($main_bgp_router) {
                 $main_bgp_router->{address_families}->{ipv4_unicast} //= {};
-                push(@ {$main_bgp_router->{address_families}->{ipv4_unicast}->{import_vrf} }, $vrf);
+                push(@{ $main_bgp_router->{address_families}->{ipv4_unicast}->{import_vrf} }, $vrf);
 
                 $main_bgp_router->{address_families}->{ipv6_unicast} //= {};
-                push(@ {$main_bgp_router->{address_families}->{ipv6_unicast}->{import_vrf} }, $vrf);
+                push(@{ $main_bgp_router->{address_families}->{ipv6_unicast}->{import_vrf} }, $vrf);
             }
 
             # Redistribute connected in VRF router
diff --git a/src/PVE/Network/SDN/Frr.pm b/src/PVE/Network/SDN/Frr.pm
index f084ad5a578f..99002c6b65bf 100644
--- a/src/PVE/Network/SDN/Frr.pm
+++ b/src/PVE/Network/SDN/Frr.pm
@@ -197,7 +197,7 @@ numbers to be consecutive, starting from 1 and incrementing by 1 for each entry.
 sub fix_routemap_seqs {
     my ($frr_config) = @_;
 
-    my $routemaps = $frr_config->{'frr'}->{'bgp'}->{'routemaps'};
+    my $routemaps = $frr_config->{'frr'}->{'routemaps'};
 
     foreach my $id (sort keys %$routemaps) {
         my $routemap = $routemaps->{$id};
@@ -270,70 +270,204 @@ sub append_local_config {
     return if !$local_config;
 
     my $section = \$frr_config->{""};
-    my $router = undef;
+    my $isis_router_name = undef;
+    my $bgp_router_asn = undef;
+    my $bgp_router_vrf = undef;
     my $routemap = undef;
-    my $routemap_config = ();
-    my $routemap_action = undef;
+    my $interface = undef;
+    my $vrf = undef;
+    my $new_block = 0;
+    my $new_af_block = 0;
 
-    while ($local_config =~ /^\s*(.+?)\s*$/gm) {
+    while ($local_config =~ /^(.+?)\s*$/gm) {
         my $line = $1;
-        $line =~ s/^\s+|\s+$//g;
-
-        if ($line =~ m/^router (.+)$/) {
-            $router = $1;
-            $section = \$frr_config->{'frr'}->{'router'}->{$router}->{""};
+        $line =~ s/\s+$//g;
+
+        if ($line =~ m/^router isis (.+)$/) {
+            $isis_router_name = $1;
+            if (defined $frr_config->{'frr'}->{'isis'}->{'router'}->{$isis_router_name}) {
+                $section =
+                    \($frr_config->{'frr'}->{'isis'}->{'router'}->{$isis_router_name}
+                        ->{'custom_frr_config'} //= []);
+            } else {
+                $new_block = 1;
+                push(
+                    $frr_config->{'frr'}->{'custom_frr_config'}->@*,
+                    "router isis $isis_router_name",
+                );
+                $section = \$frr_config->{'frr'}->{'custom_frr_config'};
+            }
+            next;
+        } elsif ($line =~ m/^router bgp (\S+)(?: vrf (.+))?$/) {
+            $bgp_router_asn = $1;
+            $bgp_router_vrf = $2 // 'default';
+
+            my $config_line =
+                defined($2)
+                ? "router bgp $bgp_router_asn vrf $bgp_router_vrf"
+                : "router bgp $bgp_router_asn";
+
+            if (
+                defined $frr_config->{'frr'}->{'bgp'}->{'vrf_router'}->{$bgp_router_vrf}
+                and $frr_config->{'frr'}->{'bgp'}->{'vrf_router'}->{$bgp_router_vrf}->{'asn'}
+                eq $bgp_router_asn
+            ) {
+                $section =
+                    \($frr_config->{'frr'}->{'bgp'}->{'vrf_router'}->{$bgp_router_vrf}
+                        ->{'custom_frr_config'} //= []);
+            } else {
+                $new_block = 1;
+                push(
+                    $frr_config->{'frr'}->{'custom_frr_config'}->@*, $config_line,
+                );
+                $section = \$frr_config->{'frr'}->{'custom_frr_config'};
+            }
             next;
         } elsif ($line =~ m/^vrf (.+)$/) {
-            $section = \$frr_config->{'frr'}->{'vrf'}->{$1};
+            $vrf = $1;
+            if (defined $frr_config->{'frr'}->{'bgp'}->{'vrfs'}->{$vrf}) {
+                $section = \$frr_config->{'frr'}->{'bgp'}->{'vrfs'}->{$vrf}->{'custom_frr_config'};
+            } else {
+                $new_block = 1;
+                push($frr_config->{'frr'}->{'custom_frr_config'}->@*, "vrf $vrf");
+                $section = \$frr_config->{'frr'}->{'custom_frr_config'};
+            }
             next;
         } elsif ($line =~ m/^interface (.+)$/) {
-            $section = \$frr_config->{'frr_interfaces'}->{$1};
+            $interface = $1;
+            if (defined $frr_config->{'frr'}->{'isis'}->{'interfaces'}->{$interface}) {
+                $section = \($frr_config->{'frr'}->{'isis'}->{'interfaces'}->{$interface}
+                    ->{'custom_frr_config'} //= []);
+            } else {
+                $new_block = 1;
+                push(
+                    $frr_config->{'frr'}->{'custom_frr_config'}->@*, "interface $interface",
+                );
+                $section = \$frr_config->{'frr'}->{'custom_frr_config'};
+            }
             next;
         } elsif ($line =~ m/^bgp community-list (.+)$/) {
-            push(@{ $frr_config->{'frr_bgp_community_list'} }, $line);
+            push(@{ $frr_config->{'frr'}->{'custom_frr_config'} }, $line);
             next;
         } elsif ($line =~ m/address-family (.+)$/) {
-            $section = \$frr_config->{'frr'}->{'router'}->{$router}->{'address-family'}->{$1};
+            # convert the address family from frr (e.g. l2vpn evpn) into the rust property (e.g. l2vpn_evpn)
+            my $address_family_unchanged = $1;
+            my $address_family = $1 =~ s/ /_/gr;
+
+            if (
+                defined $frr_config->{'frr'}->{'bgp'}->{'vrf_router'}->{$bgp_router_vrf}
+                and $frr_config->{'frr'}->{'bgp'}->{'vrf_router'}->{$bgp_router_vrf}->{'asn'}
+                eq $bgp_router_asn
+            ) {
+                if (
+                    defined $frr_config->{'frr'}->{'bgp'}->{'vrf_router'}->{$bgp_router_vrf}
+                    ->{'address_families'}->{$address_family}
+                ) {
+                    $section =
+                        \($frr_config->{'frr'}->{'bgp'}->{'vrf_router'}->{$bgp_router_vrf}
+                            ->{'address_families'}->{$address_family}->{custom_frr_config} //= []);
+                } else {
+                    $new_af_block = 1;
+                    push(
+                        $frr_config->{'frr'}->{'bgp'}->{'vrf_router'}->{$bgp_router_vrf}
+                            ->{'custom_frr_config'}->@*,
+                        " address-family $address_family_unchanged",
+                    );
+                    $section = \$frr_config->{'frr'}->{'bgp'}->{'vrf_router'}->{$bgp_router_vrf}
+                        ->{'custom_frr_config'};
+                }
+            } else {
+                $new_af_block = 1;
+                push(
+                    $frr_config->{'frr'}->{'custom_frr_config'}->@*,
+                    " address-family $address_family_unchanged",
+                );
+                $section = \$frr_config->{'frr'}->{'custom_frr_config'};
+            }
             next;
         } elsif ($line =~ m/^route-map (.+) (permit|deny) (\d+)/) {
             $routemap = $1;
-            $routemap_config = ();
-            $routemap_action = $2;
-            $section = \$frr_config->{'frr_routemap'}->{$routemap};
+            my $routemap_action = $2;
+            my $seq_number = $3;
+            if (defined $frr_config->{'frr'}->{'routemaps'}->{$routemap}) {
+                my $index = 0;
+                foreach my $single_routemap ($frr_config->{'frr'}->{'routemaps'}->{$routemap}->@*) {
+                    if (
+                        $single_routemap->{'seq'} == $seq_number
+                        && $single_routemap->{'action'} eq $routemap_action
+                    ) {
+                        last;
+                    }
+                    $index++;
+                }
+                if ($index < scalar @{ $frr_config->{'frr'}->{'routemaps'}->{$routemap} }) {
+                    $section = \($frr_config->{'frr'}->{'routemaps'}->{$routemap}->[$index]
+                        ->{'custom_frr_config'} //= []);
+                } else {
+                    $new_block = 1;
+                    push(
+                        $frr_config->{'frr'}->{'custom_frr_config'}->@*,
+                        "route-map $routemap $routemap_action $seq_number",
+                    );
+                    $section = \$frr_config->{'frr'}->{'custom_frr_config'};
+                }
+            } else {
+                $new_block = 1;
+                push(
+                    $frr_config->{'frr'}->{'custom_frr_config'}->@*,
+                    "route-map $routemap $routemap_action $seq_number",
+                );
+                $section = \$frr_config->{'frr'}->{'custom_frr_config'};
+            }
             next;
         } elsif ($line =~ m/^access-list (.+) seq (\d+) (.+)$/) {
-            $frr_config->{'frr_access_list'}->{$1}->{$2} = $3;
+            push($frr_config->{'frr'}->{'custom_frr_config'}->@*, $line);
             next;
         } elsif ($line =~ m/^ip prefix-list (.+) seq (\d+) (.*)$/) {
-            $frr_config->{'frr_prefix_list'}->{$1}->{$2} = $3;
+            push($frr_config->{'frr'}->{'custom_frr_config'}->@*, $line);
             next;
         } elsif ($line =~ m/^ipv6 prefix-list (.+) seq (\d+) (.*)$/) {
-            $frr_config->{'frr_prefix_list_v6'}->{$1}->{$2} = $3;
+            push($frr_config->{'frr'}->{'custom_frr_config'}->@*, $line);
             next;
-        } elsif ($line =~ m/^exit-address-family$/) {
+        } elsif ($line =~ m/exit-address-family$/) {
+            if ($new_af_block) {
+                push(@{$$section}, $line);
+                $section = \$frr_config->{'frr'}->{'bgp'}->{'custom_frr_config'};
+            } else {
+                $section =
+                    \($frr_config->{'frr'}->{'bgp'}->{'vrf_router'}->{$bgp_router_vrf}
+                        ->{'custom_frr_config'} //= []);
+            }
+            $new_af_block = 0;
             next;
-        } elsif ($line =~ m/^exit$/) {
-            if ($router) {
-                $section = \$frr_config->{''};
-                $router = undef;
-            } elsif ($routemap) {
-                push(@{$$section}, { rule => $routemap_config, action => $routemap_action });
-                $section = \$frr_config->{''};
+        } elsif ($line =~ m/^exit/) {
+            if ($bgp_router_vrf || $vrf || $interface || $routemap || $isis_router_name) {
+                # this means we just added a new router/vrf/interface/routemap
+                if ($new_block) {
+                    push(@{$$section}, $line);
+                    push(@{$$section}, "!");
+                }
+                $section = \$frr_config->{'frr'}->{'custom_frr_config'};
+                # we can't stack these, so exit out of all of them (technically we can have a vrf inside of a router bgp block, but we don't support that)
+                $isis_router_name = undef;
+                $bgp_router_vrf = undef;
+                $bgp_router_asn = undef;
+                $vrf = undef;
+                $interface = undef;
                 $routemap = undef;
-                $routemap_action = undef;
-                $routemap_config = ();
+            } else {
+                $section = \$frr_config->{'frr'}->{'custom_frr_config'};
+                push(@{$$section}, $line);
+                push(@{$$section}, "!");
             }
+            $new_block = 0;
             next;
         } elsif ($line =~ m/!/) {
             next;
         }
 
         next if !$section;
-        if ($routemap) {
-            push(@{$routemap_config}, $line);
-        } else {
-            push(@{$$section}, $line);
-        }
+        push(@{$$section}, $line);
     }
 }
 
-- 
2.47.3





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

* [PATCH pve-network 07/10] cli: add pvesdn cli tool for managing frr template overrides
  2026-02-03 16:01 [PATCH docs/manager/network/proxmox{-ve-rs,-perl-rs} 00/23] Generate frr config using jinja templates and rust types Gabriel Goller
                   ` (16 preceding siblings ...)
  2026-02-03 16:01 ` [PATCH pve-network 06/10] sdn: adjust frr.conf.local merging to rust template types Gabriel Goller
@ 2026-02-03 16:01 ` Gabriel Goller
  2026-02-03 16:01 ` [PATCH pve-network 08/10] debian: handle user modifications to FRR templates via ucf Gabriel Goller
                   ` (4 subsequent siblings)
  22 siblings, 0 replies; 24+ messages in thread
From: Gabriel Goller @ 2026-02-03 16:01 UTC (permalink / raw)
  To: pve-devel

This introduces a new cli tool to help users customize frr configuration
templates by overriding default templates, viewing differences, and
resetting modifications when needed.

Signed-off-by: Gabriel Goller <g.goller@proxmox.com>
---
 debian/libpve-network-api-perl.install |   1 +
 debian/libpve-network-perl.install     |   4 +
 src/Makefile                           |   2 +-
 src/PVE/CLI/Makefile                   |   7 +
 src/PVE/CLI/pvesdn.pm                  | 252 +++++++++++++++++++++++++
 src/PVE/Makefile                       |   1 +
 src/bin/Makefile                       |  69 +++++++
 src/bin/pvesdn                         |   8 +
 8 files changed, 343 insertions(+), 1 deletion(-)
 create mode 100644 src/PVE/CLI/Makefile
 create mode 100644 src/PVE/CLI/pvesdn.pm
 create mode 100644 src/bin/Makefile
 create mode 100755 src/bin/pvesdn

diff --git a/debian/libpve-network-api-perl.install b/debian/libpve-network-api-perl.install
index c48f1c76f9f7..1f5ed3eaeb05 100644
--- a/debian/libpve-network-api-perl.install
+++ b/debian/libpve-network-api-perl.install
@@ -1 +1,2 @@
 usr/share/perl5/PVE/API2
+usr/share/perl5/PVE/CLI
diff --git a/debian/libpve-network-perl.install b/debian/libpve-network-perl.install
index 4e63c1ff9374..f344b8c85e13 100644
--- a/debian/libpve-network-perl.install
+++ b/debian/libpve-network-perl.install
@@ -1,2 +1,6 @@
 lib/systemd/system/dnsmasq@.service.d/00-dnsmasq-after-networking.conf /usr/lib/systemd/system/dnsmasq@.service.d/
 usr/share/perl5/PVE/Network
+usr/bin/pvesdn
+usr/share/man/man1/pvesdn.1
+usr/share/bash-completion/completions/pvesdn
+usr/share/zsh/vendor-completions/_pvesdn
diff --git a/src/Makefile b/src/Makefile
index c4056b480251..cbd9a507ae5e 100644
--- a/src/Makefile
+++ b/src/Makefile
@@ -1,4 +1,4 @@
-SUBDIRS := PVE services
+SUBDIRS := PVE services bin
 
 all:
 	set -e && for i in $(SUBDIRS); do $(MAKE) -C $$i; done
diff --git a/src/PVE/CLI/Makefile b/src/PVE/CLI/Makefile
new file mode 100644
index 000000000000..5058945a716b
--- /dev/null
+++ b/src/PVE/CLI/Makefile
@@ -0,0 +1,7 @@
+SOURCES=pvesdn.pm
+
+PERL5DIR=${DESTDIR}/usr/share/perl5
+
+.PHONY: install
+install:
+	for i in ${SOURCES}; do install -D -m 0644 $$i ${PERL5DIR}/PVE/CLI/$$i; done
diff --git a/src/PVE/CLI/pvesdn.pm b/src/PVE/CLI/pvesdn.pm
new file mode 100644
index 000000000000..ebb0b60715c9
--- /dev/null
+++ b/src/PVE/CLI/pvesdn.pm
@@ -0,0 +1,252 @@
+package PVE::CLI::pvesdn;
+
+use strict;
+use warnings;
+
+use File::Path;
+use File::Temp qw(tempfile);
+use File::Copy;
+
+use PVE::RPCEnvironment;
+use PVE::Tools qw(run_command);
+
+use PVE::RS::SDN;
+
+use base qw(PVE::CLIHandler);
+
+sub setup_environment {
+    PVE::RPCEnvironment->setup_default_cli_env();
+}
+
+my $TEMPLATE_OVERRIDE_DIR = "/etc/proxmox-frr/templates";
+
+__PACKAGE__->register_method({
+    name => 'override',
+    path => 'override',
+    method => 'GET',
+    description => "Override FRR templates.",
+    parameters => {
+        properties => {
+            protocol => {
+                description =>
+                    "Specifies the FRR routing protocol (e.g., 'bgp', 'ospf') or template file (e.g., 'access_lists.jinja') to copy to the override directory for customization.",
+                type => 'string',
+            },
+        },
+
+    },
+    returns => { type => 'null' },
+    code => sub {
+        my ($param) = @_;
+        my @template_files = ();
+
+        if ($param->{protocol} eq 'openfabric') {
+            push(@template_files, 'frr.conf.jinja');
+            push(@template_files, 'fabricd.jinja');
+            push(@template_files, 'protocol_routemaps.jinja');
+            push(@template_files, 'route_maps.jinja');
+            push(@template_files, 'access_lists.jinja');
+            push(@template_files, 'interface.jinja');
+        } elsif ($param->{protocol} eq 'ospf') {
+            push(@template_files, 'frr.conf.jinja');
+            push(@template_files, 'ospfd.jinja');
+            push(@template_files, 'protocol_routemaps.jinja');
+            push(@template_files, 'route_maps.jinja');
+            push(@template_files, 'access_lists.jinja');
+            push(@template_files, 'interface.jinja');
+        } elsif ($param->{protocol} eq 'isis') {
+            push(@template_files, 'frr.conf.jinja');
+            push(@template_files, 'isisd.jinja');
+            push(@template_files, 'interface.jinja');
+        } elsif ($param->{protocol} eq 'bgp') {
+            push(@template_files, 'frr.conf.jinja');
+            push(@template_files, 'bgpd.jinja');
+            push(@template_files, 'bgp_router.jinja');
+            push(@template_files, 'route_maps.jinja');
+            push(@template_files, 'access_lists.jinja');
+            push(@template_files, 'prefix_lists.jinja');
+            push(@template_files, 'ip_routes.jinja');
+        } else {
+            push(@template_files, $param->{protocol});
+        }
+
+        File::Path::make_path($TEMPLATE_OVERRIDE_DIR);
+
+        foreach my $template (@template_files) {
+            my $filepath = "$TEMPLATE_OVERRIDE_DIR/$template";
+
+            open(my $fh, '>', $filepath) or die "Could not open file '$filepath': $!\n";
+
+            my $template_content = PVE::RS::SDN::get_template($template);
+            if (!defined($template_content)) {
+                die "Template '$template' not found\n";
+            }
+            print $fh $template_content;
+            close $fh;
+
+            print "Created override file: $filepath\n";
+        }
+        return undef;
+    },
+});
+
+__PACKAGE__->register_method({
+    name => 'show',
+    path => 'show',
+    method => 'GET',
+    description => "Show FRR template.",
+    parameters => {
+        properties => {
+            "template-name" => {
+                description => "Name of the FRR template (e.g. 'bgpd.jinja').",
+                type => 'string',
+            },
+        },
+    },
+    returns => { type => 'null' },
+    code => sub {
+        my ($param) = @_;
+
+        my $template_name = $param->{"template-name"};
+        my $template = PVE::RS::SDN::get_template($template_name);
+        if (defined($template)) {
+            print($template);
+        } else {
+            die("Template '$template_name' not found\n");
+        }
+        return undef;
+    },
+});
+
+sub write_to_template_file {
+    my ($filename, $content) = @_;
+    if ($filename =~ m/^([\w_-]+\.jinja)$/) {
+        my $safe_filename = $1;
+
+        # create backup
+        my $filepath = "$TEMPLATE_OVERRIDE_DIR/$safe_filename";
+        my $backup_path = "$filepath-bak";
+        if (-f $filepath) {
+            copy($filepath, $backup_path) or die "Could not create backup: $!\n";
+        }
+
+        open(my $fh, '>', $filepath) or die "Could not open file '$filepath': $!\n";
+        print $fh $content;
+        close $fh;
+    }
+    return undef;
+}
+
+__PACKAGE__->register_method({
+    name => 'reset',
+    path => 'reset',
+    method => 'GET',
+    description => "Reset a single or all override files by copying the packaged version over.",
+    parameters => {
+        properties => {
+            name => {
+                description => "Name of the FRR template (e.g. 'bgpd.jinja').",
+                type => 'string',
+                optional => 1,
+            },
+        },
+    },
+    returns => { type => 'null' },
+    code => sub {
+        my ($param) = @_;
+
+        if (defined($param->{name})) {
+            my $template = PVE::RS::SDN::get_template($param->{name});
+            if (defined($template)) {
+                print(
+                    "Resetting the /etc/proxmox-frr/templates/$param->{name} file - continue (y/N)? "
+                );
+                my $answer = <STDIN>;
+                my $continue = defined($answer) && $answer =~ m/^\s*y(?:es)?\s*$/i;
+                die "Aborting reset as requested\n" if !$continue;
+
+                write_to_template_file($param->{name}, $template);
+                print("Reset template: $param->{name}\n");
+            } else {
+                die("Template '$param->{name}' not found\n");
+            }
+        } else {
+            print(
+                "Resetting all template files in /etc/proxmox-frr/templates/ - continue (y/N)? ");
+            my $answer = <STDIN>;
+            my $continue = defined($answer) && $answer =~ m/^\s*y(?:es)?\s*$/i;
+            die "Aborting reset as requested\n" if !$continue;
+
+            opendir(my $dh, $TEMPLATE_OVERRIDE_DIR) or die "Cannot open directory: $!\n";
+            my @files = grep { -f "$TEMPLATE_OVERRIDE_DIR/$_" } readdir($dh);
+            closedir($dh);
+
+            foreach my $file (@files) {
+                my $packaged_content = PVE::RS::SDN::get_template($file);
+                next unless $packaged_content;
+
+                write_to_template_file($file, $packaged_content);
+                print("Reset template: $file\n");
+            }
+        }
+        return undef;
+    },
+});
+
+__PACKAGE__->register_method({
+    name => 'diff',
+    path => 'diff',
+    method => 'GET',
+    description => "Show the difference between the override templates and packaged templates.",
+    parameters => {
+        properties => {},
+    },
+    returns => { type => 'null' },
+    code => sub {
+        my ($param) = @_;
+
+        opendir(my $dh, $TEMPLATE_OVERRIDE_DIR) or die "Cannot open directory: $!\n";
+        my @files = grep { -f "$TEMPLATE_OVERRIDE_DIR/$_" } readdir($dh);
+        closedir($dh);
+
+        foreach my $file (@files) {
+            # Untaint filename for use with run_command in taint mode
+            next unless $file =~ m/^([\w.-]+)$/;
+            my $safe_file = $1;
+
+            my $override_path = "$TEMPLATE_OVERRIDE_DIR/$safe_file";
+            my $packaged_content = PVE::RS::SDN::get_template($safe_file);
+            next unless $packaged_content;
+
+            my ($temp_fh, $temp_filename) = tempfile();
+            print $temp_fh $packaged_content;
+
+            eval {
+                run_command(
+                    [
+                        "/usr/bin/diff",
+                        "--color=always",
+                        "-N",
+                        "-u",
+                        "$override_path",
+                        "$temp_filename",
+                    ],
+                );
+            };
+            close($temp_fh);
+        }
+        return undef;
+    },
+});
+
+our $cmddef = {
+    template => {
+        override => [__PACKAGE__, 'override', ['protocol']],
+        show => [__PACKAGE__, 'show', ['template-name']],
+        diff => [__PACKAGE__, 'diff', []],
+        reset => [__PACKAGE__, 'reset', []],
+    },
+};
+
+1;
+
diff --git a/src/PVE/Makefile b/src/PVE/Makefile
index 7f1cf985465f..d56158823099 100644
--- a/src/PVE/Makefile
+++ b/src/PVE/Makefile
@@ -4,5 +4,6 @@ all:
 install:
 	make -C Network install
 	make -C API2 install
+	make -C CLI install
 
 clean:
diff --git a/src/bin/Makefile b/src/bin/Makefile
new file mode 100644
index 000000000000..ed539f5e3f94
--- /dev/null
+++ b/src/bin/Makefile
@@ -0,0 +1,69 @@
+PERL_DOC_INC_DIRS=..
+-include /usr/share/pve-doc-generator/pve-doc-generator.mk
+
+
+CLITOOLS = \
+	pvesdn \
+
+CLI_MANS = 				\
+	$(addsuffix .1, $(CLITOOLS))	\
+
+BASH_COMPLETIONS = 						\
+	$(addsuffix .bash-completion, $(CLITOOLS)) 		\
+
+ZSH_COMPLETIONS =						\
+	$(addsuffix .zsh-completion, $(CLITOOLS))		\
+
+BINDIR=/usr/bin
+MAN1DIR=/usr/share/man/man1
+BASHCOMPLDIR=/usr/share/bash-completion/completions
+ZSHCOMPLDIR=/usr/share/zsh/vendor-completions
+DESTDIR=
+
+all: $(CLI_MANS)
+
+%.1: %.1.pod
+	rm -f $@
+	cat $<|pod2man -n $* -s 1 -r $(VERSION) -c"Proxmox Documentation" - >$@.tmp
+	mv $@.tmp $@
+
+%.1.pod:
+	podselect $* > $@.tmp
+	mv $@.tmp $@
+
+.PHONY: tidy
+tidy:
+	echo $(CLITOOLS) | xargs -n4 -P0 proxmox-perltidy
+
+pvesdn.api-verified:
+	touch $@
+
+pvesdn.bash-completion:
+	echo "# bash completion for pvesdn" > $@.tmp
+	echo "complete -C 'pvesdn bashcomplete' pvesdn" >> $@.tmp
+	mv $@.tmp $@
+
+pvesdn.zsh-completion:
+	echo "#compdef pvesdn" > $@.tmp
+	echo "" >> $@.tmp
+	mv $@.tmp $@
+
+.PHONY: check
+check: $(addsuffix .api-verified, $(CLITOOLS))
+	rm -f *.service-api-verified *.api-verified
+
+.PHONY: install
+install: $(CLITOOLS) $(CLI_MANS) $(BASH_COMPLETIONS) $(ZSH_COMPLETIONS)
+	install -d $(DESTDIR)$(BINDIR)
+	install -m 0755 $(CLITOOLS) $(DESTDIR)$(BINDIR)
+	install -d $(DESTDIR)$(MAN1DIR)
+	install -m 0644 $(CLI_MANS) $(DESTDIR)$(MAN1DIR)
+	for i in $(CLITOOLS); do install -m 0644 -D $$i.bash-completion $(DESTDIR)$(BASHCOMPLDIR)/$$i; done
+	for i in $(CLITOOLS); do install -m 0644 -D $$i.zsh-completion $(DESTDIR)$(ZSHCOMPLDIR)/_$$i; done
+
+.PHONY: clean
+clean:
+	rm -f *.xml.tmp *.1 *.5 *.8 *{synopsis,opts}.adoc docinfo.xml *.tmp
+	rm -f *~ *.tmp $(CLI_MANS) *.1.pod *.8.pod
+	rm -f *.bash-completion *.zsh-completion *.service-zsh-completion
+
diff --git a/src/bin/pvesdn b/src/bin/pvesdn
new file mode 100755
index 000000000000..a95e596793b0
--- /dev/null
+++ b/src/bin/pvesdn
@@ -0,0 +1,8 @@
+#!/usr/bin/perl -T
+
+use strict;
+use warnings;
+
+use PVE::CLI::pvesdn;
+
+PVE::CLI::pvesdn->run_cli_handler();
-- 
2.47.3





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

* [PATCH pve-network 08/10] debian: handle user modifications to FRR templates via ucf
  2026-02-03 16:01 [PATCH docs/manager/network/proxmox{-ve-rs,-perl-rs} 00/23] Generate frr config using jinja templates and rust types Gabriel Goller
                   ` (17 preceding siblings ...)
  2026-02-03 16:01 ` [PATCH pve-network 07/10] cli: add pvesdn cli tool for managing frr template overrides Gabriel Goller
@ 2026-02-03 16:01 ` Gabriel Goller
  2026-02-03 16:01 ` [PATCH pve-network 09/10] api: add dry-run endpoint for sdn apply to preview changes Gabriel Goller
                   ` (3 subsequent siblings)
  22 siblings, 0 replies; 24+ messages in thread
From: Gabriel Goller @ 2026-02-03 16:01 UTC (permalink / raw)
  To: pve-devel

This ensures that user customizations to frr jinja templates in
/etc/proxmox-frr/templates/ are preserved across package updates through
ucf's three-way merge, preventing silent overwrites of local changes.
When a user has customized a template and updates this package which
ships a new template version, ucf shows a three-way merge dialog
(ncurses) allowing them to view the differences and choose between the
maintainer's version, their version, or manually edit the merge result.

Signed-off-by: Gabriel Goller <g.goller@proxmox.com>
---
 debian/control                      |  1 +
 debian/libpve-network-perl.postinst | 34 ++++++++++++++++++++++++++++-
 debian/libpve-network-perl.postrm   | 33 ++++++++++++++++++++++++++++
 3 files changed, 67 insertions(+), 1 deletion(-)
 create mode 100644 debian/libpve-network-perl.postrm

diff --git a/debian/control b/debian/control
index 6fe98822e64c..83ddfc053048 100644
--- a/debian/control
+++ b/debian/control
@@ -29,6 +29,7 @@ Depends: libpve-common-perl (>= 9.1.1),
          libnetaddr-ip-perl,
          libpve-rs-perl (>= 0.11.1),
          libuuid-perl,
+         ucf,
          ${misc:Depends},
          ${perl:Depends},
 Recommends: frr-pythontools (>= 10.3.1-1+pve2~),
diff --git a/debian/libpve-network-perl.postinst b/debian/libpve-network-perl.postinst
index 99faedf48f56..629c5bdc9e18 100644
--- a/debian/libpve-network-perl.postinst
+++ b/debian/libpve-network-perl.postinst
@@ -2,6 +2,36 @@
 
 set -e
 
+TEMPLATE_OVERRIDE_DIR="/etc/proxmox-frr/templates"
+
+update_override_frr_templates() {
+  for override_file in "$TEMPLATE_OVERRIDE_DIR"/*; do
+    # only consider files ending in .jinja. we often have .ucf-old files as
+    # well storing the previous ucf decision.
+    case "$override_file" in
+      *.jinja)
+        ;;
+      *)
+        continue
+        ;;
+    esac
+
+    filename=$(basename "$override_file")
+
+    temp_packaged_file=$(mktemp)
+
+    # we want to embed the variable now, not when the trap is executed
+    # shellcheck disable=SC2064
+    trap "rm -f -- '$temp_packaged_file'" EXIT
+
+    if pvesdn template show "$filename" > "$temp_packaged_file"; then
+      ucf --three-way --debconf-ok "$temp_packaged_file" "$override_file"
+    fi
+
+    ucfr libpve-network-perl "$override_file"
+  done
+}
+
 migrate_ipam_db() {
   LEGACY_IPAM_DB_FILE="/etc/pve/priv/ipam.db"
   IPAM_DB_FILE="/etc/pve/sdn/pve-ipam-state.json"
@@ -29,7 +59,9 @@ case "$1" in
       migrate_ipam_db
       migrate_mac_cache
     fi
-  ;;
+
+    update_override_frr_templates
+    ;;
 esac
 
 exit 0
diff --git a/debian/libpve-network-perl.postrm b/debian/libpve-network-perl.postrm
new file mode 100644
index 000000000000..5a1c6834111b
--- /dev/null
+++ b/debian/libpve-network-perl.postrm
@@ -0,0 +1,33 @@
+#!/bin/bash
+set -e
+
+TEMPLATE_OVERRIDE_DIR="/etc/proxmox-frr/templates"
+
+case "$1" in
+    purge)
+        # Remove ucf registrations on purge
+        if [ -d "$TEMPLATE_OVERRIDE_DIR" ]; then
+            for package_file in "$TEMPLATE_OVERRIDE_DIR"/*; do
+                [ -e "$package_file" ] || continue
+
+                filename=$(basename "$package_file")
+                target_file="$TEMPLATE_OVERRIDE_DIR/$filename"
+
+                ucf --purge "$target_file" 2>/dev/null || true
+                ucfr --purge libpve-network-perl "$target_file" 2>/dev/null || true
+            done
+        fi
+        ;;
+
+    remove|upgrade|failed-upgrade|abort-install|abort-upgrade|disappear)
+        ;;
+
+    *)
+        echo "postrm called with unknown argument \`$1'" >&2
+        exit 1
+        ;;
+esac
+
+#DEBHELPER#
+
+exit 0
-- 
2.47.3





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

* [PATCH pve-network 09/10] api: add dry-run endpoint for sdn apply to preview changes
  2026-02-03 16:01 [PATCH docs/manager/network/proxmox{-ve-rs,-perl-rs} 00/23] Generate frr config using jinja templates and rust types Gabriel Goller
                   ` (18 preceding siblings ...)
  2026-02-03 16:01 ` [PATCH pve-network 08/10] debian: handle user modifications to FRR templates via ucf Gabriel Goller
@ 2026-02-03 16:01 ` Gabriel Goller
  2026-02-03 16:01 ` [PATCH pve-network 10/10] test: add test for frr.conf.local merging Gabriel Goller
                   ` (2 subsequent siblings)
  22 siblings, 0 replies; 24+ messages in thread
From: Gabriel Goller @ 2026-02-03 16:01 UTC (permalink / raw)
  To: pve-devel

Allows users to see the diff of frr configuration before applying
SDN changes. Previously this was not possible and the user had to apply
and then see what changed. Ideally this would also include the ifupdown2
config, but that's a bit tricky since we add config lines in perl-rs as
well.

Signed-off-by: Gabriel Goller <g.goller@proxmox.com>
---
 src/PVE/API2/Network/SDN.pm | 67 +++++++++++++++++++++++++++++++++++++
 src/PVE/Network/SDN.pm      |  9 +++--
 2 files changed, 74 insertions(+), 2 deletions(-)

diff --git a/src/PVE/API2/Network/SDN.pm b/src/PVE/API2/Network/SDN.pm
index b35a588d391d..9208d6f4e8b3 100644
--- a/src/PVE/API2/Network/SDN.pm
+++ b/src/PVE/API2/Network/SDN.pm
@@ -3,6 +3,9 @@ package PVE::API2::Network::SDN;
 use strict;
 use warnings;
 
+use File::Temp qw(tempfile);
+use Encode qw(decode);
+
 use PVE::Cluster qw(cfs_lock_file cfs_read_file cfs_write_file);
 use PVE::Exception qw(raise_param_exc);
 use PVE::JSONSchema qw(get_standard_option);
@@ -325,4 +328,68 @@ __PACKAGE__->register_method({
     },
 });
 
+sub get_diff {
+    my ($filename_one, $filename_two) = @_;
+
+    my $diff = '';
+
+    my $cmd = ['/usr/bin/diff', '-b', '-N', '-u', $filename_one, $filename_two];
+    PVE::Tools::run_command(
+        $cmd,
+        noerr => 1,
+        outfunc => sub {
+            my ($line) = @_;
+            $diff .= decode('UTF-8', $line) . "\n";
+        },
+    );
+
+    $diff = undef if !$diff;
+
+    return $diff;
+}
+
+__PACKAGE__->register_method({
+    name => 'dry-apply',
+    path => 'dry-apply',
+    method => 'PUT',
+    permissions => {
+        check => ['perm', '/nodes/{node}', ['Sys.Modify']],
+    },
+    description => "Dry-Run the SDN apply",
+    protected => 1,
+    proxyto => 'node',
+    parameters => {
+        additionalProperties => 0,
+        properties => {
+            node => get_standard_option('pve-node'),
+        },
+    },
+
+    returns => {
+        type => 'object',
+        properties => {
+            "frr-diff" =>
+                { type => 'string', description => 'The frr config generated by SDN.' },
+        },
+    },
+    code => sub {
+        my ($param) = @_;
+
+        my $config = PVE::Network::SDN::compile_running_cfg();
+
+        my $fabric_config = PVE::Network::SDN::Fabrics::config(0);
+        my $frr_config = PVE::Network::SDN::generate_frr_raw_config($config, $fabric_config);
+        my $new_config_frr = PVE::Network::SDN::Frr::raw_config_to_string($frr_config);
+
+        my ($frr_tmp_fh, $frr_tmp_filename) = tempfile();
+        print $frr_tmp_fh $new_config_frr;
+
+        my $return_value = {};
+        $return_value->{"frr-diff"} = get_diff('/etc/frr/frr.conf', $frr_tmp_filename);
+
+        close($frr_tmp_fh);
+        return $return_value;
+    },
+});
+
 1;
diff --git a/src/PVE/Network/SDN.pm b/src/PVE/Network/SDN.pm
index c000bed498ec..18938d73ba70 100644
--- a/src/PVE/Network/SDN.pm
+++ b/src/PVE/Network/SDN.pm
@@ -187,8 +187,7 @@ sub pending_config {
 
 }
 
-sub commit_config {
-
+sub compile_running_cfg {
     my $cfg = cfs_read_file($running_cfg);
     my $version = $cfg->{version};
 
@@ -219,6 +218,12 @@ sub commit_config {
         fabrics => $fabrics,
     };
 
+    return $cfg;
+}
+
+sub commit_config {
+    my $cfg = compile_running_cfg();
+
     cfs_write_file($running_cfg, $cfg);
 }
 
-- 
2.47.3





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

* [PATCH pve-network 10/10] test: add test for frr.conf.local merging
  2026-02-03 16:01 [PATCH docs/manager/network/proxmox{-ve-rs,-perl-rs} 00/23] Generate frr config using jinja templates and rust types Gabriel Goller
                   ` (19 preceding siblings ...)
  2026-02-03 16:01 ` [PATCH pve-network 09/10] api: add dry-run endpoint for sdn apply to preview changes Gabriel Goller
@ 2026-02-03 16:01 ` Gabriel Goller
  2026-02-03 16:01 ` [PATCH pve-manager 1/1] sdn: add dry-run view for sdn apply Gabriel Goller
  2026-02-03 16:01 ` [PATCH pve-docs 1/1] docs: add man page for the `pvesdn` cli Gabriel Goller
  22 siblings, 0 replies; 24+ messages in thread
From: Gabriel Goller @ 2026-02-03 16:01 UTC (permalink / raw)
  To: pve-devel

Add a test that tests the frr.conf.local merging. This should ensure we
do not run into further regressions. The test also "succeeds" with the
pre-templates version, there are just some whitespace and "!" issues.
Also the route-maps are merged instead of pushed with higher sequence
number. This shouldn't change anything.

Signed-off-by: Gabriel Goller <g.goller@proxmox.com>
---
 src/test/run_test_zones.pl                    | 16 ++++-
 .../expected_controller_config                | 61 +++++++++++++++++++
 .../frr_local_merge/expected_sdn_interfaces   | 42 +++++++++++++
 .../zones/evpn/frr_local_merge/frr.conf.local | 30 +++++++++
 .../zones/evpn/frr_local_merge/interfaces     |  7 +++
 .../zones/evpn/frr_local_merge/sdn_config     | 24 ++++++++
 6 files changed, 177 insertions(+), 3 deletions(-)
 create mode 100644 src/test/zones/evpn/frr_local_merge/expected_controller_config
 create mode 100644 src/test/zones/evpn/frr_local_merge/expected_sdn_interfaces
 create mode 100644 src/test/zones/evpn/frr_local_merge/frr.conf.local
 create mode 100644 src/test/zones/evpn/frr_local_merge/interfaces
 create mode 100644 src/test/zones/evpn/frr_local_merge/sdn_config

diff --git a/src/test/run_test_zones.pl b/src/test/run_test_zones.pl
index 905b2f42e1dc..806225735e6b 100755
--- a/src/test/run_test_zones.pl
+++ b/src/test/run_test_zones.pl
@@ -126,12 +126,22 @@ foreach my $test (@tests) {
             reload_controller => sub {
                 return;
             },
-            read_local_frr_config => sub {
-                return;
-            },
         );
     }
 
+    # Mock read_local_frr_config in PVE::Network::SDN::Frr to support testing frr.conf.local merging
+    my $frr_local_config;
+    my $frr_local_path = "./$test/frr.conf.local";
+    if (-e $frr_local_path) {
+        $frr_local_config = read_file($frr_local_path);
+    }
+    my $mocked_frr = Test::MockModule->new('PVE::Network::SDN::Frr');
+    $mocked_frr->mock(
+        read_local_frr_config => sub {
+            return $frr_local_config;
+        },
+    );
+
     my $name = $test;
     my $expected = read_file("./$test/expected_sdn_interfaces");
 
diff --git a/src/test/zones/evpn/frr_local_merge/expected_controller_config b/src/test/zones/evpn/frr_local_merge/expected_controller_config
new file mode 100644
index 000000000000..9d47e080bf7a
--- /dev/null
+++ b/src/test/zones/evpn/frr_local_merge/expected_controller_config
@@ -0,0 +1,61 @@
+frr version 10.4.1
+frr defaults datacenter
+hostname localhost
+log syslog informational
+service integrated-vtysh-config
+!
+vrf vrf_myzone
+ vni 1000
+exit-vrf
+!
+router bgp 65000
+ bgp router-id 192.168.0.1
+ no bgp hard-administrative-reset
+ no bgp default ipv4-unicast
+ coalesce-time 1000
+ no bgp graceful-restart notification
+ neighbor VTEP peer-group
+ neighbor VTEP remote-as 65000
+ neighbor VTEP bfd
+ neighbor 192.168.0.2 peer-group VTEP
+ neighbor 192.168.0.3 peer-group VTEP
+ neighbor 192.168.1.1 remote-as 65001
+ neighbor 192.168.1.1 description "External Peer"
+ address-family ipv4 unicast
+  neighbor VTEP activate
+ exit-address-family
+ !
+ address-family l2vpn evpn
+  neighbor VTEP activate
+  neighbor VTEP route-map MAP_VTEP_IN in
+  neighbor VTEP route-map MAP_VTEP_OUT out
+  advertise-svi-ip
+  advertise-all-vni
+ exit-address-family
+exit
+!
+router bgp 65000 vrf vrf_myzone
+ bgp router-id 192.168.0.1
+ no bgp hard-administrative-reset
+ no bgp graceful-restart notification
+exit
+!
+route-map MAP_VTEP_IN permit 1
+exit
+!
+route-map MAP_VTEP_OUT permit 1
+ set community 65000:100
+exit
+route-map MAP_VTEP_IN permit 2
+ set community 65000:200
+exit
+!
+ip prefix-list PL_ALLOW seq 10 permit 10.0.0.0/8 le 24
+route-map CUSTOM_MAP permit 10
+ match ip address prefix-list PL_ALLOW
+exit
+!
+bgp community-list standard CL_LOCAL permit 65000:200
+!
+line vty
+!
diff --git a/src/test/zones/evpn/frr_local_merge/expected_sdn_interfaces b/src/test/zones/evpn/frr_local_merge/expected_sdn_interfaces
new file mode 100644
index 000000000000..9d1c64c0f3fa
--- /dev/null
+++ b/src/test/zones/evpn/frr_local_merge/expected_sdn_interfaces
@@ -0,0 +1,42 @@
+#version:1
+
+auto myvnet
+iface myvnet
+	address 10.0.0.1/24
+	hwaddress A2:1D:CB:1A:C0:8B
+	bridge_ports vxlan_myvnet
+	bridge_stp off
+	bridge_fd 0
+	mtu 1450
+	ip-forward on
+	arp-accept on
+	vrf vrf_myzone
+
+auto vrf_myzone
+iface vrf_myzone
+	vrf-table auto
+	post-up ip route add vrf vrf_myzone unreachable default metric 4278198272
+
+auto vrfbr_myzone
+iface vrfbr_myzone
+	bridge-ports vrfvx_myzone
+	bridge_stp off
+	bridge_fd 0
+	mtu 1450
+	vrf vrf_myzone
+
+auto vrfvx_myzone
+iface vrfvx_myzone
+	vxlan-id 1000
+	vxlan-local-tunnelip 192.168.0.1
+	bridge-learning off
+	bridge-arp-nd-suppress on
+	mtu 1450
+
+auto vxlan_myvnet
+iface vxlan_myvnet
+	vxlan-id 100
+	vxlan-local-tunnelip 192.168.0.1
+	bridge-learning off
+	bridge-arp-nd-suppress on
+	mtu 1450
diff --git a/src/test/zones/evpn/frr_local_merge/frr.conf.local b/src/test/zones/evpn/frr_local_merge/frr.conf.local
new file mode 100644
index 000000000000..a08f805cdabc
--- /dev/null
+++ b/src/test/zones/evpn/frr_local_merge/frr.conf.local
@@ -0,0 +1,30 @@
+!
+! Custom FRR configuration to be merged
+!
+router bgp 65000
+ neighbor 192.168.1.1 remote-as 65001
+ neighbor 192.168.1.1 description "External Peer"
+ address-family l2vpn evpn
+  advertise-svi-ip
+ exit-address-family
+ address-family ipv4 unicast
+  neighbor VTEP activate
+ exit-address-family
+exit
+!
+route-map MAP_VTEP_OUT permit 1
+ set community 65000:100
+exit
+!
+route-map MAP_VTEP_IN permit 2
+ set community 65000:200
+exit
+!
+ip prefix-list PL_ALLOW seq 10 permit 10.0.0.0/8 le 24
+!
+route-map CUSTOM_MAP permit 10
+ match ip address prefix-list PL_ALLOW
+exit
+!
+bgp community-list standard CL_LOCAL permit 65000:200
+!
diff --git a/src/test/zones/evpn/frr_local_merge/interfaces b/src/test/zones/evpn/frr_local_merge/interfaces
new file mode 100644
index 000000000000..66bb826a44b3
--- /dev/null
+++ b/src/test/zones/evpn/frr_local_merge/interfaces
@@ -0,0 +1,7 @@
+auto vmbr0
+iface vmbr0 inet static
+	address 192.168.0.1/24
+	gateway 192.168.0.254
+        bridge-ports eth0
+        bridge-stp off
+        bridge-fd 0
diff --git a/src/test/zones/evpn/frr_local_merge/sdn_config b/src/test/zones/evpn/frr_local_merge/sdn_config
new file mode 100644
index 000000000000..d6e44b7593c2
--- /dev/null
+++ b/src/test/zones/evpn/frr_local_merge/sdn_config
@@ -0,0 +1,24 @@
+{
+    version => 1,
+            vnets   => {
+                ids => {
+                    myvnet => { tag => "100", type => "vnet", zone => "myzone" },
+                },
+            },
+
+            zones   => {
+                ids => { myzone => { ipam => "pve", type => "evpn", controller => "evpnctl", 'vrf-vxlan' => 1000, 'mac' => 'A2:1D:CB:1A:C0:8B' } },
+            },
+            controllers  => {
+                ids => { evpnctl => { type => "evpn", 'peers' => '192.168.0.1,192.168.0.2,192.168.0.3', asn => "65000" } },
+            },
+
+            subnets => {
+                ids => { 'myzone-10.0.0.0-24' => {
+                    'type' => 'subnet',
+                    'vnet' => 'myvnet',
+                    'gateway' => '10.0.0.1',
+                }
+                }
+            }
+}
-- 
2.47.3





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

* [PATCH pve-manager 1/1] sdn: add dry-run view for sdn apply
  2026-02-03 16:01 [PATCH docs/manager/network/proxmox{-ve-rs,-perl-rs} 00/23] Generate frr config using jinja templates and rust types Gabriel Goller
                   ` (20 preceding siblings ...)
  2026-02-03 16:01 ` [PATCH pve-network 10/10] test: add test for frr.conf.local merging Gabriel Goller
@ 2026-02-03 16:01 ` Gabriel Goller
  2026-02-03 16:01 ` [PATCH pve-docs 1/1] docs: add man page for the `pvesdn` cli Gabriel Goller
  22 siblings, 0 replies; 24+ messages in thread
From: Gabriel Goller @ 2026-02-03 16:01 UTC (permalink / raw)
  To: pve-devel

Introduce a new SdnDiffView modal that runs a dry-run and shows the frr
configuration changes which will be made when clicking apply. Now the
user knows which frr config options will be set without needing to apply
the config.

Signed-off-by: Gabriel Goller <g.goller@proxmox.com>
---
 www/manager6/Makefile           |   1 +
 www/manager6/sdn/SdnDiffView.js | 123 ++++++++++++++++++++++++++++++++
 www/manager6/sdn/StatusView.js  |   8 +++
 3 files changed, 132 insertions(+)
 create mode 100644 www/manager6/sdn/SdnDiffView.js

diff --git a/www/manager6/Makefile b/www/manager6/Makefile
index 4558d53e54be..da602523b27a 100644
--- a/www/manager6/Makefile
+++ b/www/manager6/Makefile
@@ -286,6 +286,7 @@ JSSRC= 							\
 	sdn/ControllerView.js				\
 	sdn/Status.js					\
 	sdn/StatusView.js				\
+	sdn/SdnDiffView.js				\
 	sdn/VnetEdit.js					\
 	sdn/VnetView.js					\
 	sdn/VnetACLView.js				\
diff --git a/www/manager6/sdn/SdnDiffView.js b/www/manager6/sdn/SdnDiffView.js
new file mode 100644
index 000000000000..2de10efd14b6
--- /dev/null
+++ b/www/manager6/sdn/SdnDiffView.js
@@ -0,0 +1,123 @@
+Ext.define('PVE.sdn.SdnDiffView', {
+    extend: 'Ext.window.Window',
+
+    maxWidth: 1000,
+    maxHeight: 1000,
+    minWidth: 600,
+    minHeight: 400,
+    scrollable: true,
+    modal: true,
+    title: gettext('FRR config diff view'),
+
+    node: undefined,
+
+    viewModel: {
+        data: {
+            diff: undefined,
+        },
+    },
+
+    items: [
+        {
+            xtype: 'displayfield',
+            padding: 10,
+            scrollable: true,
+            bind: {
+                value: '{diff}',
+            },
+        },
+    ],
+    buttons: [
+        {
+            handler: function () {
+                this.up('window').close();
+            },
+            text: gettext('OK'),
+        },
+    ],
+
+    loadDiff: async function () {
+        let me = this;
+
+        let req = await Proxmox.Async.api2({
+            url: `/cluster/sdn/dry-apply`,
+            params: { node: me.node },
+            method: 'PUT',
+        });
+
+        return req.result.data;
+    },
+
+    load: function () {
+        let me = this;
+
+        me.setLoading('fetching node diff');
+
+        me.loadDiff()
+            .catch(Proxmox.Utils.alertResponseFailure)
+            .then((diff) => {
+                if (diff['frr-diff'] === null) {
+                    this.getViewModel().set('diff', '');
+                } else {
+                    this.getViewModel().set('diff', diff['frr-diff'].replaceAll('\n', '<br>'));
+                }
+            })
+            .finally(() => {
+                me.setLoading(false);
+            });
+    },
+
+    getNodeSelector: function () {
+        let me = this;
+
+        return Ext.create('PVE.form.NodeSelector', {
+            xtype: 'pveNodeSelector',
+            reference: 'nodeselector',
+            fieldLabel: gettext('Node'),
+            padding: 10,
+            labelWidth: 120,
+            name: 'node',
+            allowBlank: false,
+            listeners: {
+                change: function (f, value) {
+                    me.node = value;
+                    me.load();
+                },
+            },
+            listConfig: {
+                columns: [
+                    {
+                        header: gettext('Node'),
+                        dataIndex: 'node',
+                        sortable: true,
+                        hideable: false,
+                        flex: 1,
+                    },
+                ],
+            },
+            store: {
+                fields: ['node'],
+                proxy: {
+                    type: 'proxmox',
+                    url: '/api2/json/nodes',
+                },
+                sorters: [
+                    {
+                        property: 'node',
+                        direction: 'ASC',
+                    },
+                ],
+            },
+        });
+    },
+
+    initComponent: function () {
+        let me = this;
+
+        me.nodeSelector = me.getNodeSelector();
+
+        me.items = [me.nodeSelector, ...me.items];
+
+        me.callParent();
+    },
+});
diff --git a/www/manager6/sdn/StatusView.js b/www/manager6/sdn/StatusView.js
index fbc712c6cf6b..fada50411e91 100644
--- a/www/manager6/sdn/StatusView.js
+++ b/www/manager6/sdn/StatusView.js
@@ -69,6 +69,14 @@ Ext.define(
                             });
                         },
                     },
+                    {
+                        text: gettext('Dry-Run'),
+                        handler: function () {
+                            Ext.create('PVE.sdn.SdnDiffView', {
+                                autoShow: true,
+                            });
+                        },
+                    },
                 ],
                 viewConfig: {
                     trackOver: false,
-- 
2.47.3





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

* [PATCH pve-docs 1/1] docs: add man page for the `pvesdn` cli
  2026-02-03 16:01 [PATCH docs/manager/network/proxmox{-ve-rs,-perl-rs} 00/23] Generate frr config using jinja templates and rust types Gabriel Goller
                   ` (21 preceding siblings ...)
  2026-02-03 16:01 ` [PATCH pve-manager 1/1] sdn: add dry-run view for sdn apply Gabriel Goller
@ 2026-02-03 16:01 ` Gabriel Goller
  22 siblings, 0 replies; 24+ messages in thread
From: Gabriel Goller @ 2026-02-03 16:01 UTC (permalink / raw)
  To: pve-devel

Add the man page for the `pvesdn` cli. The man page is on top of the
general `SDN` page, as it is done in `ceph` and `pct`.

Signed-off-by: Gabriel Goller <g.goller@proxmox.com>
---
 pvesdn.1-synopsis.adoc | 39 +++++++++++++++++++++++++++++++++++++++
 pvesdn.adoc            | 24 +++++++++++++++++++++++-
 2 files changed, 62 insertions(+), 1 deletion(-)
 create mode 100644 pvesdn.1-synopsis.adoc

diff --git a/pvesdn.1-synopsis.adoc b/pvesdn.1-synopsis.adoc
new file mode 100644
index 000000000000..8ef0e7071e89
--- /dev/null
+++ b/pvesdn.1-synopsis.adoc
@@ -0,0 +1,39 @@
+[[cli_pvesdn]]
+*pvesdn* `<COMMAND> [ARGS] [OPTIONS]`
+
+[[cli_pvesdn_template_diff]]
+*pvesdn template diff*
+
+Show the diff between the default frr config templates and the override
+templates in `/etc/proxmox-frr/templates/`.
+
+[[cli_pvesdn_template_override]]
+*pvesdn template override* `<protocol>`
+
+Create a override file in `/etc/proxmox-frr/templates/` for a specific protocol
+or a specific template.
+
+`<protocol>`: `<string>` ::
+
+The protocol name (e.g. 'bgp', 'openfabric') or a template name (e.g. 'bgpd.jinja', 'access_lists.jinja').
+
+[[cli_pvesdn_template_reset]]
+*pvesdn template reset* `[OPTIONS]`
+
+Reset the override template files in `/etc/proxmox-frr/templates/`. If no
+specific template is passed, then reset all.
+
+`--name` `<string>` ::
+
+Reset a specific template file (e.g. 'frr.conf.jinja').
+
+[[cli_pvesdn_template_show]]
+*pvesdn template show* `<template-name>`
+
+Show the default content of a specific template.
+
+`<template-name>`: `<string>` ::
+
+The template name (e.g. 'bgpd.jinja', 'access_lists.jinja').
+
+
diff --git a/pvesdn.adoc b/pvesdn.adoc
index d20a0eb85b0e..479468f506de 100644
--- a/pvesdn.adoc
+++ b/pvesdn.adoc
@@ -1,7 +1,25 @@
 [[chapter_pvesdn]]
+ifdef::manvolnum[]
+pvesdn(1)
+==========
+:pve-toplevel:
+
+NAME
+----
+
+pvesdn - Manage Proxmox VE Software Defined Network (SDN)
+
+SYNOPSIS
+--------
+
+include::pvesdn.1-synopsis.adoc[]
+
+DESCRIPTION
+-----------
+endif::manvolnum[]
+ifndef::manvolnum[]
 Software-Defined Network
 ========================
-ifndef::manvolnum[]
 :pve-toplevel:
 endif::manvolnum[]
 
@@ -1469,3 +1487,7 @@ and add the key to `/etc/ipsec.secrets`, so that the file contents looks like:
 ----
 
 Copy the PSK and the configuration to all nodes participating in the VXLAN network.
+
+ifdef::manvolnum[]
+include::pve-copyright.adoc[]
+endif::manvolnum[]
-- 
2.47.3





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

end of thread, other threads:[~2026-02-03 16:05 UTC | newest]

Thread overview: 24+ messages (download: mbox.gz / follow: Atom feed)
-- links below jump to the message on this page --
2026-02-03 16:01 [PATCH docs/manager/network/proxmox{-ve-rs,-perl-rs} 00/23] Generate frr config using jinja templates and rust types Gabriel Goller
2026-02-03 16:01 ` [PATCH proxmox-ve-rs 1/9] ve-config: firewall: cargo fmt Gabriel Goller
2026-02-03 16:01 ` [PATCH proxmox-ve-rs 2/9] frr: add proxmox-frr-templates package that contains templates Gabriel Goller
2026-02-03 16:01 ` [PATCH proxmox-ve-rs 3/9] ve-config: remove FrrConfigBuilder struct Gabriel Goller
2026-02-03 16:01 ` [PATCH proxmox-ve-rs 4/9] sdn-types: support variable-length NET identifier Gabriel Goller
2026-02-03 16:01 ` [PATCH proxmox-ve-rs 5/9] frr: add template serializer and serialize fabrics using templates Gabriel Goller
2026-02-03 16:01 ` [PATCH proxmox-ve-rs 6/9] frr: add isis configuration and templates Gabriel Goller
2026-02-03 16:01 ` [PATCH proxmox-ve-rs 7/9] frr: support custom frr configuration lines Gabriel Goller
2026-02-03 16:01 ` [PATCH proxmox-ve-rs 8/9] frr: add bgp support with templates and serialization Gabriel Goller
2026-02-03 16:01 ` [PATCH proxmox-ve-rs 9/9] frr: store frr template content as a const map Gabriel Goller
2026-02-03 16:01 ` [PATCH proxmox-perl-rs 1/2] sdn: add function to generate the frr config for all daemons Gabriel Goller
2026-02-03 16:01 ` [PATCH proxmox-perl-rs 2/2] sdn: add method to get a frr template Gabriel Goller
2026-02-03 16:01 ` [PATCH pve-network 01/10] sdn: remove duplicate comment line '!' in frr config Gabriel Goller
2026-02-03 16:01 ` [PATCH pve-network 02/10] sdn: tests: add missing comment " Gabriel Goller
2026-02-03 16:01 ` [PATCH pve-network 03/10] tests: use Test::Differences to make test assertions Gabriel Goller
2026-02-03 16:01 ` [PATCH pve-network 04/10] sdn: write structured frr config that can be rendered using templates Gabriel Goller
2026-02-03 16:01 ` [PATCH pve-network 05/10] tests: rearrange some statements in the frr config Gabriel Goller
2026-02-03 16:01 ` [PATCH pve-network 06/10] sdn: adjust frr.conf.local merging to rust template types Gabriel Goller
2026-02-03 16:01 ` [PATCH pve-network 07/10] cli: add pvesdn cli tool for managing frr template overrides Gabriel Goller
2026-02-03 16:01 ` [PATCH pve-network 08/10] debian: handle user modifications to FRR templates via ucf Gabriel Goller
2026-02-03 16:01 ` [PATCH pve-network 09/10] api: add dry-run endpoint for sdn apply to preview changes Gabriel Goller
2026-02-03 16:01 ` [PATCH pve-network 10/10] test: add test for frr.conf.local merging Gabriel Goller
2026-02-03 16:01 ` [PATCH pve-manager 1/1] sdn: add dry-run view for sdn apply Gabriel Goller
2026-02-03 16:01 ` [PATCH pve-docs 1/1] docs: add man page for the `pvesdn` cli Gabriel Goller

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