public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: Gabriel Goller <g.goller@proxmox.com>
To: pve-devel@lists.proxmox.com
Subject: [PATCH proxmox-ve-rs v7 10/21] frr: add vtysh integration tests for proxmox-frr
Date: Mon, 23 Mar 2026 14:49:00 +0100	[thread overview]
Message-ID: <20260323134934.243110-11-g.goller@proxmox.com> (raw)
In-Reply-To: <20260323134934.243110-1-g.goller@proxmox.com>

Add integration tests that render FrrConfig structs to frr configuration
text and validate them against a real vtysh (frr) instance. The tests use
vtysh's dry-run mode, which checks for syntax errors and limited semantic
errors without requiring the other FRR daemons.

Tests are split across several files covering common topologies:
 - BGP/EVPN (leaf/spine iBGP, EVPN-only, IPv6-only, maximal config)
 - Multi-protocol combinations (BGP + OSPF + IS-IS + OpenFabric)
 - Route maps, ACLs, prefix lists, protocol route maps
 - Edge cases (static routes, VRFs with routes, neighbor local-as, etc.)

Since the tests require a local vtysh installation, the `test-with` crate
is used to automatically mark them as `ignored` when the vtysh binary is
not present.

To allow constructing config structs directly in tests, a few changes
are made to the serializer types:
 - Make `IpRoute`, `AddressFamilies`, and `Redistribute` fields public
 - Expose `create_env()` from `ser/serializer.rs`
 - Add `IsisRouterName::new` constructor and `From<FrrWord>` impl
 - Add `PrefixListName::new` constructor
 - Add `CsnpInterval::new`, `HelloInterval::new`, and
   `HelloMultiplier::new` constructors in proxmox-sdn-types, each
   validating the given value against the declared API schema constraints

Reviewed-by: Hannes Laimer <h.laimer@proxmox.com>
Tested-by: Stefan Hanreich <s.hanreich@proxmox.com>
Signed-off-by: Gabriel Goller <g.goller@proxmox.com>
---
 proxmox-frr/Cargo.toml                      |    3 +
 proxmox-frr/src/ser/bgp.rs                  |    6 +-
 proxmox-frr/src/ser/isis.rs                 |   16 +-
 proxmox-frr/src/ser/mod.rs                  |    8 +-
 proxmox-frr/src/ser/route_map.rs            |    6 +
 proxmox-frr/src/ser/serializer.rs           |    2 +-
 proxmox-frr/tests/bgp_evpn.rs               |  844 +++++++++++++
 proxmox-frr/tests/common/mod.rs             |   69 ++
 proxmox-frr/tests/fabric_ospf_openfabric.rs |  569 +++++++++
 proxmox-frr/tests/template_validation.rs    |  676 ++++++++++
 proxmox-frr/tests/weird_combinations.rs     | 1225 +++++++++++++++++++
 proxmox-sdn-types/src/openfabric.rs         |   32 +-
 12 files changed, 3445 insertions(+), 11 deletions(-)
 create mode 100644 proxmox-frr/tests/bgp_evpn.rs
 create mode 100644 proxmox-frr/tests/common/mod.rs
 create mode 100644 proxmox-frr/tests/fabric_ospf_openfabric.rs
 create mode 100644 proxmox-frr/tests/template_validation.rs
 create mode 100644 proxmox-frr/tests/weird_combinations.rs

diff --git a/proxmox-frr/Cargo.toml b/proxmox-frr/Cargo.toml
index 37a112e73870..d10233af29ad 100644
--- a/proxmox-frr/Cargo.toml
+++ b/proxmox-frr/Cargo.toml
@@ -21,3 +21,6 @@ proxmox-network-types = { workspace = true }
 proxmox-sdn-types = { workspace = true }
 proxmox-serde = { workspace = true }
 proxmox-sortable-macro = "1"
+
+[dev-dependencies]
+test-with = { version = "0.12", default-features = false, features = ["executable"] }
diff --git a/proxmox-frr/src/ser/bgp.rs b/proxmox-frr/src/ser/bgp.rs
index 4510ccd8ddd9..6eb7c573cd9c 100644
--- a/proxmox-frr/src/ser/bgp.rs
+++ b/proxmox-frr/src/ser/bgp.rs
@@ -137,9 +137,9 @@ pub struct CommonAddressFamilyOptions {
 
 #[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, Default)]
 pub struct AddressFamilies {
-    ipv4_unicast: Option<Ipv4UnicastAF>,
-    ipv6_unicast: Option<Ipv6UnicastAF>,
-    l2vpn_evpn: Option<L2vpnEvpnAF>,
+    pub ipv4_unicast: Option<Ipv4UnicastAF>,
+    pub ipv6_unicast: Option<Ipv6UnicastAF>,
+    pub l2vpn_evpn: Option<L2vpnEvpnAF>,
 }
 
 #[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
diff --git a/proxmox-frr/src/ser/isis.rs b/proxmox-frr/src/ser/isis.rs
index 211c5b21e9e1..eb2abc3103e9 100644
--- a/proxmox-frr/src/ser/isis.rs
+++ b/proxmox-frr/src/ser/isis.rs
@@ -9,6 +9,18 @@ use crate::ser::FrrWord;
 #[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
 pub struct IsisRouterName(FrrWord);
 
+impl IsisRouterName {
+    pub fn new(name: FrrWord) -> Self {
+        Self(name)
+    }
+}
+
+impl From<FrrWord> for IsisRouterName {
+    fn from(value: FrrWord) -> Self {
+        Self(value)
+    }
+}
+
 #[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
 pub enum IsisLevel {
     #[serde(rename = "level-1")]
@@ -21,8 +33,8 @@ pub enum IsisLevel {
 
 #[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
 pub struct Redistribute {
-    ipv4_connected: IsisLevel,
-    ipv6_connected: IsisLevel,
+    pub ipv4_connected: IsisLevel,
+    pub ipv6_connected: IsisLevel,
 }
 
 #[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
diff --git a/proxmox-frr/src/ser/mod.rs b/proxmox-frr/src/ser/mod.rs
index 7bb48364fadb..692fb7fae66c 100644
--- a/proxmox-frr/src/ser/mod.rs
+++ b/proxmox-frr/src/ser/mod.rs
@@ -164,10 +164,10 @@ pub enum IpOrInterface {
 #[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
 pub struct IpRoute {
     #[serde(default, deserialize_with = "proxmox_serde::perl::deserialize_bool")]
-    is_ipv6: bool,
-    prefix: Cidr,
-    via: IpOrInterface,
-    vrf: Option<InterfaceName>,
+    pub is_ipv6: bool,
+    pub prefix: Cidr,
+    pub via: IpOrInterface,
+    pub vrf: Option<InterfaceName>,
 }
 
 #[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
diff --git a/proxmox-frr/src/ser/route_map.rs b/proxmox-frr/src/ser/route_map.rs
index d12ae05fc5b9..a79ded7795df 100644
--- a/proxmox-frr/src/ser/route_map.rs
+++ b/proxmox-frr/src/ser/route_map.rs
@@ -39,6 +39,12 @@ pub struct AccessListName(String);
 #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
 pub struct PrefixListName(String);
 
+impl PrefixListName {
+    pub fn new(name: String) -> PrefixListName {
+        PrefixListName(name)
+    }
+}
+
 impl AccessListName {
     pub fn new(name: String) -> AccessListName {
         AccessListName(name)
diff --git a/proxmox-frr/src/ser/serializer.rs b/proxmox-frr/src/ser/serializer.rs
index 2ac85d8cf6c5..8664e5fd927b 100644
--- a/proxmox-frr/src/ser/serializer.rs
+++ b/proxmox-frr/src/ser/serializer.rs
@@ -56,7 +56,7 @@ pub static TEMPLATES: [(&str, &str); 12] = sorted!([
     ),
 ]);
 
-fn create_env<'a>() -> Environment<'a> {
+pub fn create_env<'a>() -> Environment<'a> {
     let mut env = Environment::new();
 
     env.set_undefined_behavior(minijinja::UndefinedBehavior::Strict);
diff --git a/proxmox-frr/tests/bgp_evpn.rs b/proxmox-frr/tests/bgp_evpn.rs
new file mode 100644
index 000000000000..94ab624bbb0e
--- /dev/null
+++ b/proxmox-frr/tests/bgp_evpn.rs
@@ -0,0 +1,844 @@
+mod common;
+
+use std::collections::BTreeMap;
+
+use proxmox_network_types::ip_address::{Ipv4Cidr, Ipv6Cidr};
+
+use proxmox_frr::ser::bgp::{
+    AddressFamilies, AddressFamilyNeighbor, BgpRouter, CommonAddressFamilyOptions,
+    DefaultOriginate, Ipv4UnicastAF, Ipv6UnicastAF, L2vpnEvpnAF, NeighborGroup, NeighborRemoteAs,
+    RedistributeProtocol, Redistribution, Vrf,
+};
+use proxmox_frr::ser::route_map::{
+    AccessAction, AccessListOrPrefixList, PrefixListName, PrefixListRule, RouteMapEntry,
+    RouteMapMatch, RouteMapMatchInner, RouteMapName, RouteMapSet,
+};
+use proxmox_frr::ser::{BgpFrrConfig, FrrConfig, IpOrInterface, IpRoute, VrfName};
+
+use common::{dump_and_check, iface, word};
+
+#[test_with::executable(vtysh)]
+#[test]
+fn test_vtysh_bgp_evpn_leaf_ibgp() {
+    let config = FrrConfig {
+        bgp: BgpFrrConfig {
+            vrf_router: BTreeMap::from([(
+                VrfName::Default,
+                BgpRouter {
+                    asn: 65000,
+                    router_id: "10.0.0.1".parse().unwrap(),
+                    coalesce_time: Some(1000),
+                    default_ipv4_unicast: Some(false),
+                    hard_administrative_reset: None,
+                    graceful_restart_notification: None,
+                    disable_ebgp_connected_route_check: None,
+                    bestpath_as_path_multipath_relax: Some(true),
+                    neighbor_groups: vec![NeighborGroup {
+                        name: word("SPINE"),
+                        bfd: true,
+                        local_as: None,
+                        remote_as: NeighborRemoteAs::Internal,
+                        ips: vec!["10.0.0.10".parse().unwrap(), "10.0.0.11".parse().unwrap()],
+                        interfaces: vec![],
+                        ebgp_multihop: None,
+                        update_source: Some(iface("lo")),
+                    }],
+                    address_families: AddressFamilies {
+                        ipv4_unicast: None,
+                        ipv6_unicast: None,
+                        l2vpn_evpn: Some(L2vpnEvpnAF {
+                            common_options: CommonAddressFamilyOptions {
+                                import_vrf: vec![],
+                                neighbors: vec![AddressFamilyNeighbor {
+                                    name: "SPINE".to_string(),
+                                    soft_reconfiguration_inbound: Some(true),
+                                    route_map_in: None,
+                                    route_map_out: None,
+                                }],
+                                custom_frr_config: vec![],
+                            },
+                            advertise_all_vni: Some(true),
+                            advertise_default_gw: Some(true),
+                            default_originate: vec![],
+                            advertise_ipv4_unicast: Some(true),
+                            advertise_ipv6_unicast: Some(true),
+                            autort_as: None,
+                            route_targets: None,
+                        }),
+                    },
+                    custom_frr_config: vec![" bgp log-neighbor-changes".to_string()],
+                },
+            )]),
+            ..Default::default()
+        },
+        ..Default::default()
+    };
+
+    dump_and_check(&config, function_name!());
+}
+
+#[test_with::executable(vtysh)]
+#[test]
+fn test_vtysh_bgp_evpn_spine_ebgp() {
+    let config = FrrConfig {
+        bgp: BgpFrrConfig {
+            vrf_router: BTreeMap::from([(
+                VrfName::Default,
+                BgpRouter {
+                    asn: 65000,
+                    router_id: "10.0.0.10".parse().unwrap(),
+                    coalesce_time: None,
+                    default_ipv4_unicast: Some(false),
+                    hard_administrative_reset: Some(false),
+                    graceful_restart_notification: Some(false),
+                    disable_ebgp_connected_route_check: Some(true),
+                    bestpath_as_path_multipath_relax: Some(true),
+                    neighbor_groups: vec![
+                        NeighborGroup {
+                            name: word("LEAF"),
+                            bfd: true,
+                            local_as: None,
+                            remote_as: NeighborRemoteAs::External,
+                            ips: vec![
+                                "10.0.0.1".parse().unwrap(),
+                                "10.0.0.2".parse().unwrap(),
+                                "10.0.0.3".parse().unwrap(),
+                                "10.0.0.4".parse().unwrap(),
+                            ],
+                            interfaces: vec![],
+                            ebgp_multihop: Some(2),
+                            update_source: Some(iface("lo")),
+                        },
+                        NeighborGroup {
+                            name: word("BORDER"),
+                            bfd: false,
+                            local_as: None,
+                            remote_as: NeighborRemoteAs::Asn(65100),
+                            ips: vec!["10.0.0.50".parse().unwrap()],
+                            interfaces: vec![],
+                            ebgp_multihop: Some(5),
+                            update_source: None,
+                        },
+                    ],
+                    address_families: AddressFamilies {
+                        ipv4_unicast: Some(Ipv4UnicastAF {
+                            common_options: CommonAddressFamilyOptions {
+                                import_vrf: vec![],
+                                neighbors: vec![
+                                    AddressFamilyNeighbor {
+                                        name: "LEAF".to_string(),
+                                        soft_reconfiguration_inbound: Some(true),
+                                        route_map_in: None,
+                                        route_map_out: None,
+                                    },
+                                    AddressFamilyNeighbor {
+                                        name: "BORDER".to_string(),
+                                        soft_reconfiguration_inbound: None,
+                                        route_map_in: Some(RouteMapName::new(
+                                            "IMPORT_FILTER".to_string(),
+                                        )),
+                                        route_map_out: Some(RouteMapName::new(
+                                            "EXPORT_FILTER".to_string(),
+                                        )),
+                                    },
+                                ],
+                                custom_frr_config: vec!["  maximum-paths 4".to_string()],
+                            },
+                            networks: vec![
+                                Ipv4Cidr::new([10, 0, 0, 0], 24).unwrap(),
+                                Ipv4Cidr::new([10, 1, 0, 0], 24).unwrap(),
+                            ],
+                            redistribute: vec![Redistribution {
+                                protocol: RedistributeProtocol::Connected,
+                                metric: None,
+                                route_map: None,
+                            }],
+                        }),
+                        ipv6_unicast: None,
+                        l2vpn_evpn: Some(L2vpnEvpnAF {
+                            common_options: CommonAddressFamilyOptions {
+                                import_vrf: vec![],
+                                neighbors: vec![AddressFamilyNeighbor {
+                                    name: "LEAF".to_string(),
+                                    soft_reconfiguration_inbound: None,
+                                    route_map_in: None,
+                                    route_map_out: None,
+                                }],
+                                custom_frr_config: vec![],
+                            },
+                            advertise_all_vni: Some(true),
+                            advertise_default_gw: None,
+                            default_originate: vec![DefaultOriginate::Ipv4, DefaultOriginate::Ipv6],
+                            advertise_ipv4_unicast: Some(true),
+                            advertise_ipv6_unicast: Some(true),
+                            autort_as: None,
+                            route_targets: None,
+                        }),
+                    },
+                    custom_frr_config: vec![],
+                },
+            )]),
+            ..Default::default()
+        },
+        routemaps: BTreeMap::from([
+            (
+                RouteMapName::new("IMPORT_FILTER".to_string()),
+                vec![RouteMapEntry {
+                    seq: 10,
+                    action: AccessAction::Permit,
+                    matches: vec![],
+                    sets: vec![RouteMapSet::LocalPreference(150)],
+                    custom_frr_config: vec![],
+                }],
+            ),
+            (
+                RouteMapName::new("EXPORT_FILTER".to_string()),
+                vec![RouteMapEntry {
+                    seq: 10,
+                    action: AccessAction::Permit,
+                    matches: vec![],
+                    sets: vec![RouteMapSet::Metric(200)],
+                    custom_frr_config: vec![],
+                }],
+            ),
+        ]),
+        ..Default::default()
+    };
+
+    dump_and_check(&config, function_name!());
+}
+
+#[test_with::executable(vtysh)]
+#[test]
+fn test_vtysh_bgp_evpn_multi_vrf() {
+    let config = FrrConfig {
+        bgp: BgpFrrConfig {
+            vrfs: BTreeMap::from([
+                (
+                    iface("vrf-tenant1"),
+                    Vrf {
+                        vni: Some(1000),
+                        ip_routes: vec![IpRoute {
+                            is_ipv6: false,
+                            prefix: proxmox_network_types::Cidr::new_v4([10, 100, 0, 0], 24)
+                                .unwrap(),
+                            via: IpOrInterface::Ip("10.0.0.254".parse().unwrap()),
+                            vrf: None,
+                        }],
+                        custom_frr_config: vec![],
+                    },
+                ),
+                (
+                    iface("vrf-tenant2"),
+                    Vrf {
+                        vni: Some(2000),
+                        ip_routes: vec![],
+                        custom_frr_config: vec![],
+                    },
+                ),
+            ]),
+            vrf_router: BTreeMap::from([
+                (
+                    VrfName::Default,
+                    BgpRouter {
+                        asn: 65000,
+                        router_id: "10.0.0.1".parse().unwrap(),
+                        coalesce_time: None,
+                        default_ipv4_unicast: Some(false),
+                        hard_administrative_reset: None,
+                        graceful_restart_notification: None,
+                        disable_ebgp_connected_route_check: None,
+                        bestpath_as_path_multipath_relax: None,
+                        neighbor_groups: vec![NeighborGroup {
+                            name: word("EVPNPEERS"),
+                            bfd: true,
+                            local_as: None,
+                            remote_as: NeighborRemoteAs::External,
+                            ips: vec!["10.0.0.2".parse().unwrap()],
+                            interfaces: vec![],
+                            ebgp_multihop: Some(3),
+                            update_source: Some(iface("lo")),
+                        }],
+                        address_families: AddressFamilies {
+                            ipv4_unicast: Some(Ipv4UnicastAF {
+                                common_options: CommonAddressFamilyOptions {
+                                    import_vrf: vec![],
+                                    neighbors: vec![AddressFamilyNeighbor {
+                                        name: "EVPNPEERS".to_string(),
+                                        soft_reconfiguration_inbound: Some(true),
+                                        route_map_in: None,
+                                        route_map_out: None,
+                                    }],
+                                    custom_frr_config: vec![],
+                                },
+                                networks: vec![Ipv4Cidr::new([10, 0, 0, 0], 24).unwrap()],
+                                redistribute: vec![Redistribution {
+                                    protocol: RedistributeProtocol::Connected,
+                                    metric: None,
+                                    route_map: None,
+                                }],
+                            }),
+                            ipv6_unicast: None,
+                            l2vpn_evpn: Some(L2vpnEvpnAF {
+                                common_options: CommonAddressFamilyOptions {
+                                    import_vrf: vec![],
+                                    neighbors: vec![AddressFamilyNeighbor {
+                                        name: "EVPNPEERS".to_string(),
+                                        soft_reconfiguration_inbound: None,
+                                        route_map_in: None,
+                                        route_map_out: None,
+                                    }],
+                                    custom_frr_config: vec![],
+                                },
+                                advertise_all_vni: Some(true),
+                                advertise_default_gw: Some(true),
+                                default_originate: vec![
+                                    DefaultOriginate::Ipv4,
+                                    DefaultOriginate::Ipv6,
+                                ],
+                                advertise_ipv4_unicast: Some(true),
+                                advertise_ipv6_unicast: Some(true),
+                                autort_as: None,
+                                route_targets: None,
+                            }),
+                        },
+                        custom_frr_config: vec![],
+                    },
+                ),
+                (
+                    VrfName::Custom("cool".to_string()),
+                    BgpRouter {
+                        asn: 65000,
+                        router_id: "10.0.0.1".parse().unwrap(),
+                        coalesce_time: None,
+                        default_ipv4_unicast: None,
+                        hard_administrative_reset: None,
+                        graceful_restart_notification: None,
+                        disable_ebgp_connected_route_check: None,
+                        bestpath_as_path_multipath_relax: None,
+                        neighbor_groups: vec![],
+                        address_families: AddressFamilies {
+                            ipv4_unicast: Some(Ipv4UnicastAF {
+                                common_options: CommonAddressFamilyOptions {
+                                    import_vrf: vec![word("vrf-tenant1")],
+                                    neighbors: vec![],
+                                    custom_frr_config: vec![],
+                                },
+                                networks: vec![],
+                                redistribute: vec![Redistribution {
+                                    protocol: RedistributeProtocol::Connected,
+                                    metric: None,
+                                    route_map: None,
+                                }],
+                            }),
+                            ipv6_unicast: None,
+                            l2vpn_evpn: Some(L2vpnEvpnAF {
+                                common_options: CommonAddressFamilyOptions {
+                                    import_vrf: vec![],
+                                    neighbors: vec![],
+                                    custom_frr_config: vec![],
+                                },
+                                advertise_all_vni: Some(true),
+                                advertise_default_gw: Some(true),
+                                default_originate: vec![DefaultOriginate::Ipv4],
+                                advertise_ipv4_unicast: Some(true),
+                                advertise_ipv6_unicast: None,
+                                autort_as: None,
+                                route_targets: None,
+                            }),
+                        },
+                        custom_frr_config: vec![],
+                    },
+                ),
+            ]),
+            ..Default::default()
+        },
+        ..Default::default()
+    };
+
+    dump_and_check(&config, function_name!());
+}
+
+#[test_with::executable(vtysh)]
+#[test]
+fn test_vtysh_bgp_evpn_dual_stack() {
+    let config = FrrConfig {
+        bgp: BgpFrrConfig {
+            vrf_router: BTreeMap::from([(
+                VrfName::Default,
+                BgpRouter {
+                    asn: 65000,
+                    router_id: "10.0.0.5".parse().unwrap(),
+                    coalesce_time: Some(500),
+                    default_ipv4_unicast: Some(false),
+                    hard_administrative_reset: Some(false),
+                    graceful_restart_notification: Some(false),
+                    disable_ebgp_connected_route_check: Some(true),
+                    bestpath_as_path_multipath_relax: Some(true),
+                    neighbor_groups: vec![
+                        NeighborGroup {
+                            name: word("FABRIC"),
+                            bfd: true,
+                            local_as: None,
+                            remote_as: NeighborRemoteAs::External,
+                            ips: vec!["10.0.0.1".parse().unwrap(), "10.0.0.2".parse().unwrap()],
+                            interfaces: vec![],
+                            ebgp_multihop: Some(3),
+                            update_source: Some(iface("lo")),
+                        },
+                        NeighborGroup {
+                            name: word("FABRICv6"),
+                            bfd: true,
+                            local_as: None,
+                            remote_as: NeighborRemoteAs::External,
+                            ips: vec!["fd00::1".parse().unwrap(), "fd00::2".parse().unwrap()],
+                            interfaces: vec![],
+                            ebgp_multihop: Some(3),
+                            update_source: Some(iface("lo")),
+                        },
+                    ],
+                    address_families: AddressFamilies {
+                        ipv4_unicast: Some(Ipv4UnicastAF {
+                            common_options: CommonAddressFamilyOptions {
+                                import_vrf: vec![],
+                                neighbors: vec![AddressFamilyNeighbor {
+                                    name: "FABRIC".to_string(),
+                                    soft_reconfiguration_inbound: Some(true),
+                                    route_map_in: Some(RouteMapName::new("V4_IN".to_string())),
+                                    route_map_out: Some(RouteMapName::new("V4_OUT".to_string())),
+                                }],
+                                custom_frr_config: vec![],
+                            },
+                            networks: vec![
+                                Ipv4Cidr::new([10, 0, 0, 0], 24).unwrap(),
+                                Ipv4Cidr::new([172, 16, 0, 0], 16).unwrap(),
+                            ],
+                            redistribute: vec![
+                                Redistribution {
+                                    protocol: RedistributeProtocol::Connected,
+                                    metric: None,
+                                    route_map: None,
+                                },
+                                Redistribution {
+                                    protocol: RedistributeProtocol::Static,
+                                    metric: Some(50),
+                                    route_map: None,
+                                },
+                                Redistribution {
+                                    protocol: RedistributeProtocol::Kernel,
+                                    metric: Some(200),
+                                    route_map: None,
+                                },
+                            ],
+                        }),
+                        ipv6_unicast: Some(Ipv6UnicastAF {
+                            common_options: CommonAddressFamilyOptions {
+                                import_vrf: vec![],
+                                neighbors: vec![AddressFamilyNeighbor {
+                                    name: "FABRICv6".to_string(),
+                                    soft_reconfiguration_inbound: Some(true),
+                                    route_map_in: None,
+                                    route_map_out: None,
+                                }],
+                                custom_frr_config: vec![],
+                            },
+                            networks: vec![
+                                Ipv6Cidr::new("fd00::".parse::<std::net::Ipv6Addr>().unwrap(), 64)
+                                    .unwrap(),
+                                Ipv6Cidr::new(
+                                    "2001:db8::".parse::<std::net::Ipv6Addr>().unwrap(),
+                                    48,
+                                )
+                                .unwrap(),
+                            ],
+                            redistribute: vec![Redistribution {
+                                protocol: RedistributeProtocol::Connected,
+                                metric: None,
+                                route_map: None,
+                            }],
+                        }),
+                        l2vpn_evpn: Some(L2vpnEvpnAF {
+                            common_options: CommonAddressFamilyOptions {
+                                import_vrf: vec![],
+                                neighbors: vec![
+                                    AddressFamilyNeighbor {
+                                        name: "FABRIC".to_string(),
+                                        soft_reconfiguration_inbound: None,
+                                        route_map_in: None,
+                                        route_map_out: None,
+                                    },
+                                    AddressFamilyNeighbor {
+                                        name: "FABRICv6".to_string(),
+                                        soft_reconfiguration_inbound: None,
+                                        route_map_in: None,
+                                        route_map_out: None,
+                                    },
+                                ],
+                                custom_frr_config: vec![],
+                            },
+                            advertise_all_vni: Some(true),
+                            advertise_default_gw: Some(true),
+                            default_originate: vec![DefaultOriginate::Ipv4, DefaultOriginate::Ipv6],
+                            advertise_ipv4_unicast: Some(true),
+                            advertise_ipv6_unicast: Some(true),
+                            autort_as: None,
+                            route_targets: None,
+                        }),
+                    },
+                    custom_frr_config: vec![],
+                },
+            )]),
+            ..Default::default()
+        },
+        prefix_lists: BTreeMap::from([(
+            PrefixListName::new("ALLOWED_V4".to_string()),
+            vec![
+                PrefixListRule {
+                    action: AccessAction::Permit,
+                    network: proxmox_network_types::Cidr::new_v4([10, 0, 0, 0], 8).unwrap(),
+                    seq: Some(10),
+                    le: Some(24),
+                    ge: None,
+                    is_ipv6: false,
+                },
+                PrefixListRule {
+                    action: AccessAction::Deny,
+                    network: proxmox_network_types::Cidr::new_v4([0, 0, 0, 0], 0).unwrap(),
+                    seq: Some(100),
+                    le: Some(32),
+                    ge: None,
+                    is_ipv6: false,
+                },
+            ],
+        )]),
+        routemaps: BTreeMap::from([
+            (
+                RouteMapName::new("V4_IN".to_string()),
+                vec![RouteMapEntry {
+                    seq: 10,
+                    action: AccessAction::Permit,
+                    matches: vec![RouteMapMatch::V4(RouteMapMatchInner::Address(
+                        AccessListOrPrefixList::PrefixList(PrefixListName::new(
+                            "ALLOWED_V4".to_string(),
+                        )),
+                    ))],
+                    sets: vec![RouteMapSet::LocalPreference(200)],
+                    custom_frr_config: vec![],
+                }],
+            ),
+            (
+                RouteMapName::new("V4_OUT".to_string()),
+                vec![
+                    RouteMapEntry {
+                        seq: 10,
+                        action: AccessAction::Permit,
+                        matches: vec![],
+                        sets: vec![RouteMapSet::Metric(100)],
+                        custom_frr_config: vec![],
+                    },
+                    RouteMapEntry {
+                        seq: 20,
+                        action: AccessAction::Deny,
+                        matches: vec![],
+                        sets: vec![],
+                        custom_frr_config: vec![],
+                    },
+                ],
+            ),
+        ]),
+        ..Default::default()
+    };
+
+    dump_and_check(&config, function_name!());
+}
+
+#[test_with::executable(vtysh)]
+#[test]
+fn test_vtysh_bgp_evpn_unnumbered() {
+    let config = FrrConfig {
+        bgp: BgpFrrConfig {
+            vrf_router: BTreeMap::from([(
+                VrfName::Default,
+                BgpRouter {
+                    asn: 65000,
+                    router_id: "10.0.0.1".parse().unwrap(),
+                    coalesce_time: None,
+                    default_ipv4_unicast: Some(false),
+                    hard_administrative_reset: None,
+                    graceful_restart_notification: None,
+                    disable_ebgp_connected_route_check: None,
+                    bestpath_as_path_multipath_relax: Some(true),
+                    neighbor_groups: vec![NeighborGroup {
+                        name: word("UNDERLAY"),
+                        bfd: true,
+                        local_as: None,
+                        remote_as: NeighborRemoteAs::External,
+                        ips: vec![],
+                        interfaces: vec![
+                            iface("swp1"),
+                            iface("swp2"),
+                            iface("swp3"),
+                            iface("swp4"),
+                        ],
+                        ebgp_multihop: None,
+                        update_source: None,
+                    }],
+                    address_families: AddressFamilies {
+                        ipv4_unicast: Some(Ipv4UnicastAF {
+                            common_options: CommonAddressFamilyOptions {
+                                import_vrf: vec![],
+                                neighbors: vec![AddressFamilyNeighbor {
+                                    name: "UNDERLAY".to_string(),
+                                    soft_reconfiguration_inbound: None,
+                                    route_map_in: None,
+                                    route_map_out: None,
+                                }],
+                                custom_frr_config: vec![],
+                            },
+                            networks: vec![],
+                            redistribute: vec![Redistribution {
+                                protocol: RedistributeProtocol::Connected,
+                                metric: None,
+                                route_map: None,
+                            }],
+                        }),
+                        ipv6_unicast: None,
+                        l2vpn_evpn: Some(L2vpnEvpnAF {
+                            common_options: CommonAddressFamilyOptions {
+                                import_vrf: vec![],
+                                neighbors: vec![AddressFamilyNeighbor {
+                                    name: "UNDERLAY".to_string(),
+                                    soft_reconfiguration_inbound: None,
+                                    route_map_in: None,
+                                    route_map_out: None,
+                                }],
+                                custom_frr_config: vec![],
+                            },
+                            advertise_all_vni: Some(true),
+                            advertise_default_gw: None,
+                            default_originate: vec![],
+                            advertise_ipv4_unicast: Some(true),
+                            advertise_ipv6_unicast: None,
+                            autort_as: None,
+                            route_targets: None,
+                        }),
+                    },
+                    custom_frr_config: vec![],
+                },
+            )]),
+            ..Default::default()
+        },
+        ..Default::default()
+    };
+
+    dump_and_check(&config, function_name!());
+}
+
+#[test_with::executable(vtysh)]
+#[test]
+fn test_vtysh_bgp_evpn_route_targets() {
+    let route_targets = proxmox_frr::ser::bgp::RouteTargets {
+        import: vec!["65000:1000".parse().unwrap()],
+        export: vec!["65000:2000".parse().unwrap()],
+        both: vec!["65000:3000".parse().unwrap()],
+    };
+
+    let config = FrrConfig {
+        bgp: BgpFrrConfig {
+            vrf_router: BTreeMap::from([(
+                VrfName::Default,
+                BgpRouter {
+                    asn: 65000,
+                    router_id: "10.0.0.1".parse().unwrap(),
+                    coalesce_time: None,
+                    default_ipv4_unicast: Some(false),
+                    hard_administrative_reset: None,
+                    graceful_restart_notification: None,
+                    disable_ebgp_connected_route_check: None,
+                    bestpath_as_path_multipath_relax: None,
+                    neighbor_groups: vec![NeighborGroup {
+                        name: word("PEERS"),
+                        bfd: true,
+                        local_as: None,
+                        remote_as: NeighborRemoteAs::External,
+                        ips: vec!["10.0.0.2".parse().unwrap()],
+                        interfaces: vec![],
+                        ebgp_multihop: Some(2),
+                        update_source: Some(iface("lo")),
+                    }],
+                    address_families: AddressFamilies {
+                        ipv4_unicast: None,
+                        ipv6_unicast: None,
+                        l2vpn_evpn: Some(L2vpnEvpnAF {
+                            common_options: CommonAddressFamilyOptions {
+                                import_vrf: vec![],
+                                neighbors: vec![AddressFamilyNeighbor {
+                                    name: "PEERS".to_string(),
+                                    soft_reconfiguration_inbound: None,
+                                    route_map_in: None,
+                                    route_map_out: None,
+                                }],
+                                custom_frr_config: vec![],
+                            },
+                            advertise_all_vni: Some(true),
+                            advertise_default_gw: None,
+                            default_originate: vec![],
+                            advertise_ipv4_unicast: Some(true),
+                            advertise_ipv6_unicast: None,
+                            autort_as: Some(65000),
+                            route_targets: Some(route_targets),
+                        }),
+                    },
+                    custom_frr_config: vec![],
+                },
+            )]),
+            ..Default::default()
+        },
+        ..Default::default()
+    };
+
+    dump_and_check(&config, function_name!());
+}
+
+#[test_with::executable(vtysh)]
+#[test]
+fn test_vtysh_bgp_redistribute_multiple_protocols() {
+    let config = FrrConfig {
+        bgp: BgpFrrConfig {
+            vrf_router: BTreeMap::from([(
+                VrfName::Default,
+                BgpRouter {
+                    asn: 65000,
+                    router_id: "10.0.0.1".parse().unwrap(),
+                    coalesce_time: None,
+                    default_ipv4_unicast: Some(false),
+                    hard_administrative_reset: None,
+                    graceful_restart_notification: None,
+                    disable_ebgp_connected_route_check: None,
+                    bestpath_as_path_multipath_relax: None,
+                    neighbor_groups: vec![],
+                    address_families: AddressFamilies {
+                        ipv4_unicast: Some(Ipv4UnicastAF {
+                            common_options: CommonAddressFamilyOptions {
+                                import_vrf: vec![],
+                                neighbors: vec![],
+                                custom_frr_config: vec![],
+                            },
+                            networks: vec![Ipv4Cidr::new([10, 0, 0, 0], 8).unwrap()],
+                            redistribute: vec![
+                                Redistribution {
+                                    protocol: RedistributeProtocol::Connected,
+                                    metric: None,
+                                    route_map: None,
+                                },
+                                Redistribution {
+                                    protocol: RedistributeProtocol::Static,
+                                    metric: Some(100),
+                                    route_map: None,
+                                },
+                                Redistribution {
+                                    protocol: RedistributeProtocol::Ospf,
+                                    metric: Some(150),
+                                    route_map: Some(RouteMapName::new("OSPF_TO_BGP".to_string())),
+                                },
+                                Redistribution {
+                                    protocol: RedistributeProtocol::Kernel,
+                                    metric: None,
+                                    route_map: None,
+                                },
+                            ],
+                        }),
+                        ipv6_unicast: None,
+                        l2vpn_evpn: None,
+                    },
+                    custom_frr_config: vec![" timers bgp 10 30".to_string()],
+                },
+            )]),
+            ..Default::default()
+        },
+        routemaps: BTreeMap::from([(
+            RouteMapName::new("OSPF_TO_BGP".to_string()),
+            vec![RouteMapEntry {
+                seq: 10,
+                action: AccessAction::Permit,
+                matches: vec![],
+                sets: vec![RouteMapSet::LocalPreference(100)],
+                custom_frr_config: vec![],
+            }],
+        )]),
+        ..Default::default()
+    };
+
+    dump_and_check(&config, function_name!());
+}
+
+#[test_with::executable(vtysh)]
+#[test]
+fn test_vtysh_bgp_view_router() {
+    let config = FrrConfig {
+        bgp: BgpFrrConfig {
+            vrf_router: BTreeMap::from([(
+                VrfName::Default,
+                BgpRouter {
+                    asn: 65000,
+                    router_id: "10.0.0.1".parse().unwrap(),
+                    coalesce_time: None,
+                    default_ipv4_unicast: Some(false),
+                    hard_administrative_reset: None,
+                    graceful_restart_notification: None,
+                    disable_ebgp_connected_route_check: None,
+                    bestpath_as_path_multipath_relax: None,
+                    neighbor_groups: vec![],
+                    address_families: AddressFamilies::default(),
+                    custom_frr_config: vec![],
+                },
+            )]),
+            view_router: BTreeMap::from([(
+                1,
+                BgpRouter {
+                    asn: 65000,
+                    router_id: "10.0.0.1".parse().unwrap(),
+                    coalesce_time: None,
+                    default_ipv4_unicast: None,
+                    hard_administrative_reset: None,
+                    graceful_restart_notification: None,
+                    disable_ebgp_connected_route_check: None,
+                    bestpath_as_path_multipath_relax: None,
+                    neighbor_groups: vec![NeighborGroup {
+                        name: word("VIEW_PEERS"),
+                        bfd: false,
+                        local_as: None,
+                        remote_as: NeighborRemoteAs::External,
+                        ips: vec!["10.0.0.100".parse().unwrap()],
+                        interfaces: vec![],
+                        ebgp_multihop: None,
+                        update_source: None,
+                    }],
+                    address_families: AddressFamilies {
+                        ipv4_unicast: Some(Ipv4UnicastAF {
+                            common_options: CommonAddressFamilyOptions {
+                                import_vrf: vec![],
+                                neighbors: vec![AddressFamilyNeighbor {
+                                    name: "VIEW_PEERS".to_string(),
+                                    soft_reconfiguration_inbound: Some(true),
+                                    route_map_in: None,
+                                    route_map_out: None,
+                                }],
+                                custom_frr_config: vec![],
+                            },
+                            networks: vec![],
+                            redistribute: vec![],
+                        }),
+                        ipv6_unicast: None,
+                        l2vpn_evpn: None,
+                    },
+                    custom_frr_config: vec![],
+                },
+            )]),
+            ..Default::default()
+        },
+        ..Default::default()
+    };
+
+    dump_and_check(&config, function_name!());
+}
diff --git a/proxmox-frr/tests/common/mod.rs b/proxmox-frr/tests/common/mod.rs
new file mode 100644
index 000000000000..51dc456e4ae4
--- /dev/null
+++ b/proxmox-frr/tests/common/mod.rs
@@ -0,0 +1,69 @@
+use std::process::Command;
+
+use proxmox_frr::ser::{FrrWord, InterfaceName};
+
+pub fn word(s: &str) -> FrrWord {
+    FrrWord::new(s).expect("valid FrrWord")
+}
+
+pub fn iface(s: &str) -> InterfaceName {
+    InterfaceName::try_from(s).expect("valid InterfaceName")
+}
+
+pub fn vtysh_check(config: &str, test_name: &str) -> Result<(), String> {
+    let tmpdir_path = std::env::temp_dir().join(format!("frr-test-{test_name}"));
+    std::fs::create_dir_all(&tmpdir_path).expect("create temp dir");
+    std::fs::write(tmpdir_path.join("vtysh.conf"), "").expect("write vtysh.conf");
+
+    let tmpfile = tmpdir_path.join("test.conf");
+    std::fs::write(&tmpfile, config).expect("write test config");
+
+    let output = Command::new("vtysh")
+        .arg("--config_dir")
+        .arg(&tmpdir_path)
+        .arg("-f")
+        .arg(&tmpfile)
+        .arg("-C")
+        .output()
+        .expect("failed to run vtysh");
+
+    let _ = std::fs::remove_dir_all(&tmpdir_path);
+
+    let stdout = String::from_utf8_lossy(&output.stdout);
+    let stderr = String::from_utf8_lossy(&output.stderr);
+
+    if output.status.success() {
+        Ok(())
+    } else {
+        Err(format!(
+            "vtysh rejected config (exit code {:?}):\nstdout: {}\nstderr: {}\nconfig:\n{}",
+            output.status.code(),
+            stdout,
+            stderr,
+            config
+        ))
+    }
+}
+
+/// Dumps the config, prints it, and validates it with vtysh.
+pub fn dump_and_check(config: &proxmox_frr::ser::FrrConfig, test_name: &str) {
+    let output = proxmox_frr::ser::serializer::dump(config).expect("render must succeed");
+    eprintln!("{test_name}:\n{output}");
+    vtysh_check(&output, test_name).unwrap_or_else(|e| panic!("vtysh rejected config: {e}"));
+}
+
+// Extracts the function name from the type name of a closure defined inside the calling function.
+#[macro_export]
+macro_rules! function_name {
+    () => {{
+        fn f() {}
+        fn type_name_of<T>(_: T) -> &'static str {
+            std::any::type_name::<T>()
+        }
+        let name = type_name_of(f);
+        match &name[..name.len() - 3].rfind(':') {
+            Some(pos) => &name[pos + 1..name.len() - 3],
+            None => &name[..name.len() - 3],
+        }
+    }};
+}
diff --git a/proxmox-frr/tests/fabric_ospf_openfabric.rs b/proxmox-frr/tests/fabric_ospf_openfabric.rs
new file mode 100644
index 000000000000..a2503ba1b528
--- /dev/null
+++ b/proxmox-frr/tests/fabric_ospf_openfabric.rs
@@ -0,0 +1,569 @@
+mod common;
+
+use std::collections::BTreeMap;
+
+use proxmox_network_types::ip_address::{Ipv4Cidr, Ipv6Cidr};
+
+use proxmox_frr::ser::isis::{IsisInterface, IsisLevel, IsisRouter, IsisRouterName, Redistribute};
+use proxmox_frr::ser::openfabric::{OpenfabricInterface, OpenfabricRouter, OpenfabricRouterName};
+use proxmox_frr::ser::ospf::{Area, NetworkType, OspfInterface, OspfRouter};
+use proxmox_frr::ser::{FrrConfig, Interface, IsisFrrConfig, OpenfabricFrrConfig, OspfFrrConfig};
+use proxmox_sdn_types::openfabric::{CsnpInterval, HelloInterval, HelloMultiplier};
+
+use common::{dump_and_check, iface, word};
+
+#[test_with::executable(vtysh)]
+#[test]
+fn test_vtysh_ospf_multi_interface_backbone() {
+    let config = FrrConfig {
+        ospf: OspfFrrConfig {
+            router: Some(OspfRouter::new("10.0.0.1".parse().unwrap())),
+            interfaces: BTreeMap::from([
+                (
+                    iface("eth0"),
+                    Interface {
+                        addresses_v4: vec![Ipv4Cidr::new([10, 0, 0, 1], 24).unwrap()],
+                        addresses_v6: vec![],
+                        properties: OspfInterface {
+                            area: Area::new(word("0")).unwrap(),
+                            passive: None,
+                            network_type: None,
+                        },
+                    },
+                ),
+                (
+                    iface("eth1"),
+                    Interface {
+                        addresses_v4: vec![Ipv4Cidr::new([10, 0, 1, 1], 24).unwrap()],
+                        addresses_v6: vec![],
+                        properties: OspfInterface {
+                            area: Area::new(word("0")).unwrap(),
+                            passive: Some(false),
+                            network_type: None,
+                        },
+                    },
+                ),
+                (
+                    iface("eth2"),
+                    Interface {
+                        addresses_v4: vec![Ipv4Cidr::new([10, 0, 2, 1], 24).unwrap()],
+                        addresses_v6: vec![],
+                        properties: OspfInterface {
+                            area: Area::new(word("0")).unwrap(),
+                            passive: Some(true),
+                            network_type: Some(NetworkType::Broadcast),
+                        },
+                    },
+                ),
+            ]),
+        },
+        ..Default::default()
+    };
+
+    dump_and_check(&config, function_name!());
+}
+
+#[test_with::executable(vtysh)]
+#[test]
+fn test_vtysh_ospf_multi_area() {
+    let config = FrrConfig {
+        ospf: OspfFrrConfig {
+            router: Some(OspfRouter::new("10.0.0.1".parse().unwrap())),
+            interfaces: BTreeMap::from([
+                (
+                    iface("eth0"),
+                    Interface {
+                        addresses_v4: vec![Ipv4Cidr::new([10, 0, 0, 1], 24).unwrap()],
+                        addresses_v6: vec![],
+                        properties: OspfInterface {
+                            area: Area::new(word("0")).unwrap(),
+                            passive: None,
+                            network_type: None,
+                        },
+                    },
+                ),
+                (
+                    iface("eth1"),
+                    Interface {
+                        addresses_v4: vec![Ipv4Cidr::new([10, 1, 0, 1], 24).unwrap()],
+                        addresses_v6: vec![],
+                        properties: OspfInterface {
+                            area: Area::new(word("1")).unwrap(),
+                            passive: None,
+                            network_type: None,
+                        },
+                    },
+                ),
+                (
+                    iface("eth2"),
+                    Interface {
+                        addresses_v4: vec![Ipv4Cidr::new([10, 2, 0, 1], 24).unwrap()],
+                        addresses_v6: vec![],
+                        properties: OspfInterface {
+                            area: Area::new(word("0.0.0.2")).unwrap(),
+                            passive: Some(true),
+                            network_type: None,
+                        },
+                    },
+                ),
+            ]),
+        },
+        ..Default::default()
+    };
+
+    dump_and_check(&config, function_name!());
+}
+
+#[test_with::executable(vtysh)]
+#[test]
+fn test_vtysh_ospf_unnumbered_ptp() {
+    let config = FrrConfig {
+        ospf: OspfFrrConfig {
+            router: Some(OspfRouter::new("10.0.0.1".parse().unwrap())),
+            interfaces: BTreeMap::from([
+                (
+                    iface("lo"),
+                    Interface {
+                        addresses_v4: vec![Ipv4Cidr::new([10, 0, 0, 1], 32).unwrap()],
+                        addresses_v6: vec![],
+                        properties: OspfInterface {
+                            area: Area::new(word("0")).unwrap(),
+                            passive: Some(true),
+                            network_type: None,
+                        },
+                    },
+                ),
+                (
+                    iface("eth0"),
+                    Interface {
+                        addresses_v4: vec![Ipv4Cidr::new([10, 0, 0, 1], 32).unwrap()],
+                        addresses_v6: vec![],
+                        properties: OspfInterface {
+                            area: Area::new(word("0")).unwrap(),
+                            passive: None,
+                            network_type: Some(NetworkType::PointToPoint),
+                        },
+                    },
+                ),
+                (
+                    iface("eth1"),
+                    Interface {
+                        addresses_v4: vec![Ipv4Cidr::new([10, 0, 0, 1], 32).unwrap()],
+                        addresses_v6: vec![],
+                        properties: OspfInterface {
+                            area: Area::new(word("0")).unwrap(),
+                            passive: None,
+                            network_type: Some(NetworkType::PointToPoint),
+                        },
+                    },
+                ),
+            ]),
+        },
+        ..Default::default()
+    };
+
+    dump_and_check(&config, function_name!());
+}
+
+#[test_with::executable(vtysh)]
+#[test]
+fn test_vtysh_openfabric_multi_fabric_with_timers() {
+    let config = FrrConfig {
+        openfabric: OpenfabricFrrConfig {
+            router: BTreeMap::from([
+                (
+                    OpenfabricRouterName::new(word("fabric1")),
+                    OpenfabricRouter::new("49.0001.1921.6800.1001.00".parse().unwrap()),
+                ),
+                (
+                    OpenfabricRouterName::new(word("fabric2")),
+                    OpenfabricRouter::new("49.0002.1921.6800.1001.00".parse().unwrap()),
+                ),
+            ]),
+            interfaces: BTreeMap::from([
+                (
+                    iface("eth0"),
+                    Interface {
+                        addresses_v4: vec![Ipv4Cidr::new([10, 0, 0, 1], 24).unwrap()],
+                        addresses_v6: vec![],
+                        properties: OpenfabricInterface {
+                            fabric_id: OpenfabricRouterName::new(word("fabric1")),
+                            passive: Some(false),
+                            hello_interval: Some(HelloInterval::new(5).unwrap()),
+                            csnp_interval: Some(CsnpInterval::new(5).unwrap()),
+                            hello_multiplier: Some(HelloMultiplier::new(3).unwrap()),
+                            is_ipv4: true,
+                            is_ipv6: false,
+                        },
+                    },
+                ),
+                (
+                    iface("eth1"),
+                    Interface {
+                        addresses_v4: vec![],
+                        addresses_v6: vec![Ipv6Cidr::new(
+                            "fd00::1".parse::<std::net::Ipv6Addr>().unwrap(),
+                            64,
+                        )
+                        .unwrap()],
+                        properties: OpenfabricInterface {
+                            fabric_id: OpenfabricRouterName::new(word("fabric2")),
+                            passive: None,
+                            hello_interval: None,
+                            csnp_interval: None,
+                            hello_multiplier: None,
+                            is_ipv4: false,
+                            is_ipv6: true,
+                        },
+                    },
+                ),
+                (
+                    iface("eth2"),
+                    Interface {
+                        addresses_v4: vec![Ipv4Cidr::new([10, 1, 0, 1], 24).unwrap()],
+                        addresses_v6: vec![Ipv6Cidr::new(
+                            "fd01::1".parse::<std::net::Ipv6Addr>().unwrap(),
+                            64,
+                        )
+                        .unwrap()],
+                        properties: OpenfabricInterface {
+                            fabric_id: OpenfabricRouterName::new(word("fabric1")),
+                            passive: Some(true),
+                            hello_interval: Some(HelloInterval::new(10).unwrap()),
+                            csnp_interval: None,
+                            hello_multiplier: None,
+                            is_ipv4: true,
+                            is_ipv6: true,
+                        },
+                    },
+                ),
+            ]),
+        },
+        ..Default::default()
+    };
+
+    dump_and_check(&config, function_name!());
+}
+
+#[test_with::executable(vtysh)]
+#[test]
+fn test_vtysh_openfabric_ipv6_only() {
+    let config = FrrConfig {
+        openfabric: OpenfabricFrrConfig {
+            router: BTreeMap::from([(
+                OpenfabricRouterName::new(word("v6fabric")),
+                OpenfabricRouter::new("49.0001.1921.6800.0001.00".parse().unwrap()),
+            )]),
+            interfaces: BTreeMap::from([
+                (
+                    iface("eth0"),
+                    Interface {
+                        addresses_v4: vec![],
+                        addresses_v6: vec![
+                            Ipv6Cidr::new("fd00::1".parse::<std::net::Ipv6Addr>().unwrap(), 64)
+                                .unwrap(),
+                            Ipv6Cidr::new("2001:db8::1".parse::<std::net::Ipv6Addr>().unwrap(), 48)
+                                .unwrap(),
+                        ],
+                        properties: OpenfabricInterface {
+                            fabric_id: OpenfabricRouterName::new(word("v6fabric")),
+                            passive: Some(false),
+                            hello_interval: None,
+                            csnp_interval: None,
+                            hello_multiplier: None,
+                            is_ipv4: false,
+                            is_ipv6: true,
+                        },
+                    },
+                ),
+                (
+                    iface("eth1"),
+                    Interface {
+                        addresses_v4: vec![],
+                        addresses_v6: vec![Ipv6Cidr::new(
+                            "fd01::1".parse::<std::net::Ipv6Addr>().unwrap(),
+                            64,
+                        )
+                        .unwrap()],
+                        properties: OpenfabricInterface {
+                            fabric_id: OpenfabricRouterName::new(word("v6fabric")),
+                            passive: None,
+                            hello_interval: Some(HelloInterval::new(2).unwrap()),
+                            csnp_interval: Some(CsnpInterval::new(5).unwrap()),
+                            hello_multiplier: Some(HelloMultiplier::new(4).unwrap()),
+                            is_ipv4: false,
+                            is_ipv6: true,
+                        },
+                    },
+                ),
+            ]),
+        },
+        ..Default::default()
+    };
+
+    dump_and_check(&config, function_name!());
+}
+
+#[test_with::executable(vtysh)]
+#[test]
+fn test_vtysh_ospf_plus_openfabric() {
+    let config = FrrConfig {
+        ospf: OspfFrrConfig {
+            router: Some(OspfRouter::new("10.0.0.1".parse().unwrap())),
+            interfaces: BTreeMap::from([
+                (
+                    iface("eth0"),
+                    Interface {
+                        addresses_v4: vec![Ipv4Cidr::new([10, 0, 0, 1], 24).unwrap()],
+                        addresses_v6: vec![],
+                        properties: OspfInterface {
+                            area: Area::new(word("0")).unwrap(),
+                            passive: None,
+                            network_type: Some(NetworkType::PointToPoint),
+                        },
+                    },
+                ),
+                (
+                    iface("eth1"),
+                    Interface {
+                        addresses_v4: vec![Ipv4Cidr::new([10, 0, 1, 1], 24).unwrap()],
+                        addresses_v6: vec![],
+                        properties: OspfInterface {
+                            area: Area::new(word("0")).unwrap(),
+                            passive: Some(true),
+                            network_type: None,
+                        },
+                    },
+                ),
+            ]),
+        },
+        openfabric: OpenfabricFrrConfig {
+            router: BTreeMap::from([(
+                OpenfabricRouterName::new(word("myfabric")),
+                OpenfabricRouter::new("49.0001.1000.0000.0001.00".parse().unwrap()),
+            )]),
+            interfaces: BTreeMap::from([
+                (
+                    iface("eth2"),
+                    Interface {
+                        addresses_v4: vec![Ipv4Cidr::new([10, 1, 0, 1], 24).unwrap()],
+                        addresses_v6: vec![],
+                        properties: OpenfabricInterface {
+                            fabric_id: OpenfabricRouterName::new(word("myfabric")),
+                            passive: Some(false),
+                            hello_interval: None,
+                            csnp_interval: None,
+                            hello_multiplier: None,
+                            is_ipv4: true,
+                            is_ipv6: false,
+                        },
+                    },
+                ),
+                (
+                    iface("eth3"),
+                    Interface {
+                        addresses_v4: vec![],
+                        addresses_v6: vec![Ipv6Cidr::new(
+                            "fd00::1".parse::<std::net::Ipv6Addr>().unwrap(),
+                            64,
+                        )
+                        .unwrap()],
+                        properties: OpenfabricInterface {
+                            fabric_id: OpenfabricRouterName::new(word("myfabric")),
+                            passive: None,
+                            hello_interval: Some(HelloInterval::new(5).unwrap()),
+                            csnp_interval: None,
+                            hello_multiplier: None,
+                            is_ipv4: false,
+                            is_ipv6: true,
+                        },
+                    },
+                ),
+            ]),
+        },
+        ..Default::default()
+    };
+
+    dump_and_check(&config, function_name!());
+}
+
+#[test_with::executable(vtysh)]
+#[test]
+fn test_vtysh_isis_full_featured() {
+    let config = FrrConfig {
+        isis: IsisFrrConfig {
+            router: BTreeMap::from([(
+                IsisRouterName::new(word("CORE")),
+                IsisRouter {
+                    net: "49.0001.1921.6800.1001.00".parse().unwrap(),
+                    log_adjacency_changes: Some(true),
+                    redistribute: Some(Redistribute {
+                        ipv4_connected: IsisLevel::Level1,
+                        ipv6_connected: IsisLevel::Level2,
+                    }),
+                    custom_frr_config: vec![
+                        " metric-style wide".to_string(),
+                        " is-type level-1-2".to_string(),
+                    ],
+                },
+            )]),
+            interfaces: BTreeMap::from([
+                (
+                    iface("eth0"),
+                    Interface {
+                        addresses_v4: vec![Ipv4Cidr::new([10, 0, 0, 1], 24).unwrap()],
+                        addresses_v6: vec![Ipv6Cidr::new(
+                            "fd00::1".parse::<std::net::Ipv6Addr>().unwrap(),
+                            64,
+                        )
+                        .unwrap()],
+                        properties: IsisInterface {
+                            domain: IsisRouterName::new(word("CORE")),
+                            is_ipv4: true,
+                            is_ipv6: true,
+                            custom_frr_config: vec![" isis circuit-type level-1-2".to_string()],
+                        },
+                    },
+                ),
+                (
+                    iface("eth1"),
+                    Interface {
+                        addresses_v4: vec![Ipv4Cidr::new([10, 0, 1, 1], 24).unwrap()],
+                        addresses_v6: vec![],
+                        properties: IsisInterface {
+                            domain: IsisRouterName::new(word("CORE")),
+                            is_ipv4: true,
+                            is_ipv6: false,
+                            custom_frr_config: vec![],
+                        },
+                    },
+                ),
+            ]),
+        },
+        ..Default::default()
+    };
+
+    dump_and_check(&config, function_name!());
+}
+
+#[test_with::executable(vtysh)]
+#[test]
+fn test_vtysh_isis_plus_openfabric() {
+    let config = FrrConfig {
+        isis: IsisFrrConfig {
+            router: BTreeMap::from([(
+                IsisRouterName::new(word("isisnet")),
+                IsisRouter {
+                    net: "49.0001.1921.6800.1001.00".parse().unwrap(),
+                    log_adjacency_changes: Some(true),
+                    redistribute: Some(Redistribute {
+                        ipv4_connected: IsisLevel::Level1,
+                        ipv6_connected: IsisLevel::Level1,
+                    }),
+                    custom_frr_config: vec![],
+                },
+            )]),
+            interfaces: BTreeMap::from([(
+                iface("eth0"),
+                Interface {
+                    addresses_v4: vec![Ipv4Cidr::new([10, 0, 0, 1], 24).unwrap()],
+                    addresses_v6: vec![],
+                    properties: IsisInterface {
+                        domain: IsisRouterName::new(word("isisnet")),
+                        is_ipv4: true,
+                        is_ipv6: false,
+                        custom_frr_config: vec![],
+                    },
+                },
+            )]),
+        },
+        openfabric: OpenfabricFrrConfig {
+            router: BTreeMap::from([(
+                OpenfabricRouterName::new(word("ofnet")),
+                OpenfabricRouter::new("49.0002.1921.6800.1001.00".parse().unwrap()),
+            )]),
+            interfaces: BTreeMap::from([(
+                iface("eth1"),
+                Interface {
+                    addresses_v4: vec![Ipv4Cidr::new([10, 1, 0, 1], 24).unwrap()],
+                    addresses_v6: vec![],
+                    properties: OpenfabricInterface {
+                        fabric_id: OpenfabricRouterName::new(word("ofnet")),
+                        passive: Some(false),
+                        hello_interval: None,
+                        csnp_interval: None,
+                        hello_multiplier: None,
+                        is_ipv4: true,
+                        is_ipv6: false,
+                    },
+                },
+            )]),
+        },
+        ..Default::default()
+    };
+
+    dump_and_check(&config, function_name!());
+}
+
+#[test_with::executable(vtysh)]
+#[test]
+fn test_vtysh_ospf_all_network_types() {
+    let config = FrrConfig {
+        ospf: OspfFrrConfig {
+            router: Some(OspfRouter::new("10.0.0.1".parse().unwrap())),
+            interfaces: BTreeMap::from([
+                (
+                    iface("eth0"),
+                    Interface {
+                        addresses_v4: vec![Ipv4Cidr::new([10, 0, 0, 1], 24).unwrap()],
+                        addresses_v6: vec![],
+                        properties: OspfInterface {
+                            area: Area::new(word("0")).unwrap(),
+                            passive: None,
+                            network_type: Some(NetworkType::Broadcast),
+                        },
+                    },
+                ),
+                (
+                    iface("eth1"),
+                    Interface {
+                        addresses_v4: vec![Ipv4Cidr::new([10, 0, 1, 1], 32).unwrap()],
+                        addresses_v6: vec![],
+                        properties: OspfInterface {
+                            area: Area::new(word("0")).unwrap(),
+                            passive: None,
+                            network_type: Some(NetworkType::PointToPoint),
+                        },
+                    },
+                ),
+                (
+                    iface("eth2"),
+                    Interface {
+                        addresses_v4: vec![Ipv4Cidr::new([10, 0, 2, 1], 24).unwrap()],
+                        addresses_v6: vec![],
+                        properties: OspfInterface {
+                            area: Area::new(word("0")).unwrap(),
+                            passive: None,
+                            network_type: Some(NetworkType::NonBroadcast),
+                        },
+                    },
+                ),
+                (
+                    iface("eth3"),
+                    Interface {
+                        addresses_v4: vec![Ipv4Cidr::new([10, 0, 3, 1], 24).unwrap()],
+                        addresses_v6: vec![],
+                        properties: OspfInterface {
+                            area: Area::new(word("0")).unwrap(),
+                            passive: None,
+                            network_type: Some(NetworkType::PointToMultipoint),
+                        },
+                    },
+                ),
+            ]),
+        },
+        ..Default::default()
+    };
+
+    dump_and_check(&config, function_name!());
+}
diff --git a/proxmox-frr/tests/template_validation.rs b/proxmox-frr/tests/template_validation.rs
new file mode 100644
index 000000000000..80ed73ecc2e4
--- /dev/null
+++ b/proxmox-frr/tests/template_validation.rs
@@ -0,0 +1,676 @@
+mod common;
+
+use std::collections::BTreeMap;
+
+use proxmox_network_types::ip_address::{Cidr, Ipv4Cidr, Ipv6Cidr};
+
+use proxmox_frr::ser::bgp::{
+    AddressFamilies, AddressFamilyNeighbor, BgpRouter, CommonAddressFamilyOptions,
+    DefaultOriginate, Ipv4UnicastAF, Ipv6UnicastAF, L2vpnEvpnAF, NeighborGroup, NeighborRemoteAs,
+    RedistributeProtocol, Redistribution, Vrf,
+};
+use proxmox_frr::ser::isis::{IsisInterface, IsisLevel, IsisRouter, IsisRouterName, Redistribute};
+use proxmox_frr::ser::openfabric::{OpenfabricInterface, OpenfabricRouter, OpenfabricRouterName};
+use proxmox_frr::ser::ospf::{Area, NetworkType, OspfInterface, OspfRouter};
+use proxmox_frr::ser::route_map::{
+    AccessAction, AccessListName, AccessListOrPrefixList, AccessListRule, PrefixListName,
+    PrefixListRule, RouteMapEntry, RouteMapMatch, RouteMapMatchInner, RouteMapName, RouteMapSet,
+};
+use proxmox_frr::ser::serializer::{create_env, dump, TEMPLATES};
+use proxmox_frr::ser::{
+    BgpFrrConfig, FrrConfig, FrrProtocol, Interface, IpOrInterface, IpProtocolRouteMap, IpRoute,
+    OpenfabricFrrConfig, OspfFrrConfig, VrfName,
+};
+
+use common::{dump_and_check, iface, word};
+
+#[test]
+fn test_all_templates_load_and_parse() {
+    let env = create_env();
+
+    for (name, _) in TEMPLATES.iter() {
+        let result = env.get_template(name);
+        assert!(
+            result.is_ok(),
+            "Failed to load and parse template '{name}': {:?}",
+            result.err()
+        );
+    }
+}
+
+#[test]
+fn test_empty_config_renders() {
+    let config = FrrConfig::default();
+    let result = dump(&config);
+    assert!(
+        result.is_ok(),
+        "Empty config failed to render: {:?}",
+        result.err()
+    );
+}
+
+#[test_with::executable(vtysh)]
+#[test]
+fn test_vtysh_empty_config() {
+    let config = FrrConfig::default();
+    dump_and_check(&config, function_name!());
+}
+
+#[test_with::executable(vtysh)]
+#[test]
+fn test_vtysh_bgp_simple() {
+    let config = FrrConfig {
+        bgp: BgpFrrConfig {
+            vrf_router: BTreeMap::from([(
+                VrfName::Default,
+                BgpRouter {
+                    asn: 65000,
+                    router_id: "10.0.0.1".parse().unwrap(),
+                    coalesce_time: None,
+                    default_ipv4_unicast: Some(false),
+                    hard_administrative_reset: None,
+                    graceful_restart_notification: None,
+                    disable_ebgp_connected_route_check: None,
+                    bestpath_as_path_multipath_relax: None,
+                    neighbor_groups: vec![],
+                    address_families: AddressFamilies::default(),
+                    custom_frr_config: vec![],
+                },
+            )]),
+            ..Default::default()
+        },
+        ..Default::default()
+    };
+
+    dump_and_check(&config, function_name!());
+}
+
+#[test_with::executable(vtysh)]
+#[test]
+fn test_vtysh_bgp_with_neighbors_and_address_families() {
+    let config = FrrConfig {
+        bgp: BgpFrrConfig {
+            vrf_router: BTreeMap::from([(
+                VrfName::Default,
+                BgpRouter {
+                    asn: 65000,
+                    router_id: "10.0.0.1".parse().unwrap(),
+                    coalesce_time: Some(1000),
+                    default_ipv4_unicast: Some(false),
+                    hard_administrative_reset: Some(false),
+                    graceful_restart_notification: Some(false),
+                    disable_ebgp_connected_route_check: Some(true),
+                    bestpath_as_path_multipath_relax: Some(true),
+                    neighbor_groups: vec![NeighborGroup {
+                        name: word("PEERS"),
+                        bfd: true,
+                        local_as: None,
+                        remote_as: NeighborRemoteAs::External,
+                        ips: vec!["10.0.0.2".parse().unwrap()],
+                        interfaces: vec![],
+                        ebgp_multihop: Some(2),
+                        update_source: Some(iface("lo")),
+                    }],
+                    address_families: AddressFamilies {
+                        ipv4_unicast: Some(Ipv4UnicastAF {
+                            common_options: CommonAddressFamilyOptions {
+                                import_vrf: vec![],
+                                neighbors: vec![AddressFamilyNeighbor {
+                                    name: "PEERS".to_string(),
+                                    soft_reconfiguration_inbound: Some(true),
+                                    route_map_in: None,
+                                    route_map_out: None,
+                                }],
+                                custom_frr_config: vec![],
+                            },
+                            networks: vec![Ipv4Cidr::new([10, 0, 0, 0], 24).unwrap()],
+                            redistribute: vec![Redistribution {
+                                protocol: RedistributeProtocol::Connected,
+                                metric: None,
+                                route_map: None,
+                            }],
+                        }),
+                        ipv6_unicast: Some(Ipv6UnicastAF {
+                            common_options: CommonAddressFamilyOptions {
+                                import_vrf: vec![],
+                                neighbors: vec![AddressFamilyNeighbor {
+                                    name: "PEERS".to_string(),
+                                    soft_reconfiguration_inbound: None,
+                                    route_map_in: None,
+                                    route_map_out: None,
+                                }],
+                                custom_frr_config: vec![],
+                            },
+                            networks: vec![Ipv6Cidr::new(
+                                "fd00::".parse::<std::net::Ipv6Addr>().unwrap(),
+                                64,
+                            )
+                            .unwrap()],
+                            redistribute: vec![Redistribution {
+                                protocol: RedistributeProtocol::Connected,
+                                metric: None,
+                                route_map: None,
+                            }],
+                        }),
+                        l2vpn_evpn: None,
+                    },
+                    custom_frr_config: vec![],
+                },
+            )]),
+            ..Default::default()
+        },
+        ..Default::default()
+    };
+
+    dump_and_check(&config, function_name!());
+}
+
+#[test_with::executable(vtysh)]
+#[test]
+fn test_vtysh_bgp_with_vrf() {
+    let config = FrrConfig {
+        bgp: BgpFrrConfig {
+            vrfs: BTreeMap::from([(
+                iface("vrf-test"),
+                Vrf {
+                    vni: Some(100),
+                    ip_routes: vec![],
+                    custom_frr_config: vec![],
+                },
+            )]),
+            vrf_router: BTreeMap::from([
+                (
+                    VrfName::Default,
+                    BgpRouter {
+                        asn: 65000,
+                        router_id: "10.0.0.1".parse().unwrap(),
+                        coalesce_time: None,
+                        default_ipv4_unicast: None,
+                        hard_administrative_reset: None,
+                        graceful_restart_notification: None,
+                        disable_ebgp_connected_route_check: None,
+                        bestpath_as_path_multipath_relax: None,
+                        neighbor_groups: vec![],
+                        address_families: AddressFamilies::default(),
+                        custom_frr_config: vec![],
+                    },
+                ),
+                (
+                    VrfName::Custom("vrf-test".to_string()),
+                    BgpRouter {
+                        asn: 65000,
+                        router_id: "10.0.0.1".parse().unwrap(),
+                        coalesce_time: None,
+                        default_ipv4_unicast: None,
+                        hard_administrative_reset: None,
+                        graceful_restart_notification: None,
+                        disable_ebgp_connected_route_check: None,
+                        bestpath_as_path_multipath_relax: None,
+                        neighbor_groups: vec![],
+                        address_families: AddressFamilies {
+                            ipv4_unicast: None,
+                            ipv6_unicast: None,
+                            l2vpn_evpn: Some(L2vpnEvpnAF {
+                                common_options: CommonAddressFamilyOptions {
+                                    import_vrf: vec![],
+                                    neighbors: vec![],
+                                    custom_frr_config: vec![],
+                                },
+                                advertise_all_vni: Some(true),
+                                advertise_default_gw: Some(true),
+                                default_originate: vec![
+                                    DefaultOriginate::Ipv4,
+                                    DefaultOriginate::Ipv6,
+                                ],
+                                advertise_ipv4_unicast: Some(true),
+                                advertise_ipv6_unicast: Some(true),
+                                autort_as: None,
+                                route_targets: None,
+                            }),
+                        },
+                        custom_frr_config: vec![],
+                    },
+                ),
+            ]),
+            ..Default::default()
+        },
+        ..Default::default()
+    };
+
+    dump_and_check(&config, function_name!());
+}
+
+#[test_with::executable(vtysh)]
+#[test]
+fn test_vtysh_ospf() {
+    let config = FrrConfig {
+        ospf: OspfFrrConfig {
+            router: Some(OspfRouter::new("10.0.0.1".parse().unwrap())),
+            interfaces: BTreeMap::from([(
+                iface("eth0"),
+                Interface {
+                    addresses_v4: vec![Ipv4Cidr::new([10, 0, 0, 1], 24).unwrap()],
+                    addresses_v6: vec![],
+                    properties: OspfInterface {
+                        area: Area::new(word("0")).unwrap(),
+                        passive: Some(true),
+                        network_type: Some(NetworkType::PointToPoint),
+                    },
+                },
+            )]),
+        },
+        ..Default::default()
+    };
+
+    dump_and_check(&config, function_name!());
+}
+
+#[test_with::executable(vtysh)]
+#[test]
+fn test_vtysh_openfabric() {
+    let config = FrrConfig {
+        openfabric: OpenfabricFrrConfig {
+            router: BTreeMap::from([(
+                OpenfabricRouterName::new(word("fabric1")),
+                OpenfabricRouter::new("49.0001.1921.6800.1001.00".parse().unwrap()),
+            )]),
+            interfaces: BTreeMap::from([(
+                iface("eth0"),
+                Interface {
+                    addresses_v4: vec![Ipv4Cidr::new([10, 0, 0, 1], 24).unwrap()],
+                    addresses_v6: vec![],
+                    properties: OpenfabricInterface {
+                        fabric_id: OpenfabricRouterName::new(word("fabric1")),
+                        passive: Some(false),
+                        hello_interval: None,
+                        csnp_interval: None,
+                        hello_multiplier: None,
+                        is_ipv4: true,
+                        is_ipv6: false,
+                    },
+                },
+            )]),
+        },
+        ..Default::default()
+    };
+
+    dump_and_check(&config, function_name!());
+}
+
+#[test_with::executable(vtysh)]
+#[test]
+fn test_vtysh_isis() {
+    let config = FrrConfig {
+        isis: proxmox_frr::ser::IsisFrrConfig {
+            router: BTreeMap::from([(
+                IsisRouterName::new(word("myisis")),
+                IsisRouter {
+                    net: "49.0001.1921.6800.1001.00".parse().unwrap(),
+                    log_adjacency_changes: Some(true),
+                    redistribute: Some(Redistribute {
+                        ipv4_connected: IsisLevel::Level1,
+                        ipv6_connected: IsisLevel::Level1,
+                    }),
+                    custom_frr_config: vec![],
+                },
+            )]),
+            interfaces: BTreeMap::from([(
+                iface("eth0"),
+                Interface {
+                    addresses_v4: vec![Ipv4Cidr::new([10, 0, 0, 1], 24).unwrap()],
+                    addresses_v6: vec![],
+                    properties: IsisInterface {
+                        domain: IsisRouterName::new(word("myisis")),
+                        is_ipv4: true,
+                        is_ipv6: false,
+                        custom_frr_config: vec![],
+                    },
+                },
+            )]),
+        },
+        ..Default::default()
+    };
+
+    dump_and_check(&config, function_name!());
+}
+
+#[test_with::executable(vtysh)]
+#[test]
+fn test_vtysh_access_lists_and_prefix_lists() {
+    let config = FrrConfig {
+        access_lists: BTreeMap::from([(
+            AccessListName::new("MYACL".to_string()),
+            vec![
+                AccessListRule {
+                    action: AccessAction::Permit,
+                    network: Cidr::new_v4([10, 0, 0, 0], 24).unwrap(),
+                    seq: Some(10),
+                    is_ipv6: false,
+                },
+                AccessListRule {
+                    action: AccessAction::Deny,
+                    network: Cidr::new_v4([192, 168, 0, 0], 16).unwrap(),
+                    seq: Some(20),
+                    is_ipv6: false,
+                },
+            ],
+        )]),
+        prefix_lists: BTreeMap::from([(
+            PrefixListName::new("MYPFX".to_string()),
+            vec![PrefixListRule {
+                action: AccessAction::Permit,
+                network: Cidr::new_v4([10, 0, 0, 0], 8).unwrap(),
+                seq: Some(10),
+                le: Some(24),
+                ge: Some(16),
+                is_ipv6: false,
+            }],
+        )]),
+        ..Default::default()
+    };
+
+    dump_and_check(&config, function_name!());
+}
+
+#[test_with::executable(vtysh)]
+#[test]
+fn test_vtysh_route_maps() {
+    let config = FrrConfig {
+        prefix_lists: BTreeMap::from([(
+            PrefixListName::new("MYPFX".to_string()),
+            vec![PrefixListRule {
+                action: AccessAction::Permit,
+                network: Cidr::new_v4([10, 0, 0, 0], 8).unwrap(),
+                seq: Some(10),
+                le: Some(24),
+                ge: None,
+                is_ipv6: false,
+            }],
+        )]),
+        routemaps: BTreeMap::from([(
+            RouteMapName::new("MYMAP".to_string()),
+            vec![
+                RouteMapEntry {
+                    seq: 10,
+                    action: AccessAction::Permit,
+                    matches: vec![RouteMapMatch::V4(RouteMapMatchInner::Address(
+                        AccessListOrPrefixList::PrefixList(PrefixListName::new(
+                            "MYPFX".to_string(),
+                        )),
+                    ))],
+                    sets: vec![RouteMapSet::LocalPreference(200), RouteMapSet::Metric(100)],
+                    custom_frr_config: vec![],
+                },
+                RouteMapEntry {
+                    seq: 20,
+                    action: AccessAction::Deny,
+                    matches: vec![],
+                    sets: vec![],
+                    custom_frr_config: vec![],
+                },
+            ],
+        )]),
+        ..Default::default()
+    };
+
+    dump_and_check(&config, function_name!());
+}
+
+#[test_with::executable(vtysh)]
+#[test]
+fn test_vtysh_ip_routes() {
+    let config = FrrConfig {
+        ip_routes: vec![
+            IpRoute {
+                is_ipv6: false,
+                prefix: Cidr::new_v4([10, 10, 0, 0], 24).unwrap(),
+                via: IpOrInterface::Ip("10.0.0.1".parse().unwrap()),
+                vrf: None,
+            },
+            IpRoute {
+                is_ipv6: true,
+                prefix: Cidr::new_v6("fd00:1::".parse::<std::net::Ipv6Addr>().unwrap(), 64)
+                    .unwrap(),
+                via: IpOrInterface::Ip("fd00::1".parse().unwrap()),
+                vrf: None,
+            },
+        ],
+        ..Default::default()
+    };
+
+    dump_and_check(&config, function_name!());
+}
+
+#[test_with::executable(vtysh)]
+#[test]
+fn test_vtysh_protocol_routemaps() {
+    let config = FrrConfig {
+        routemaps: BTreeMap::from([(
+            RouteMapName::new("OSPF_MAP".to_string()),
+            vec![RouteMapEntry {
+                seq: 10,
+                action: AccessAction::Permit,
+                matches: vec![],
+                sets: vec![],
+                custom_frr_config: vec![],
+            }],
+        )]),
+        protocol_routemaps: BTreeMap::from([(
+            FrrProtocol::Ospf,
+            IpProtocolRouteMap {
+                v4: Some(RouteMapName::new("OSPF_MAP".to_string())),
+                v6: None,
+            },
+        )]),
+        ..Default::default()
+    };
+
+    dump_and_check(&config, function_name!());
+}
+
+fn make_evpn_bgp_router(
+    neighbor_group_name: &str,
+    route_map_in: Option<RouteMapName>,
+    route_map_out: Option<RouteMapName>,
+) -> BgpRouter {
+    BgpRouter {
+        asn: 65000,
+        router_id: "10.0.0.1".parse().unwrap(),
+        coalesce_time: None,
+        default_ipv4_unicast: Some(false),
+        hard_administrative_reset: None,
+        graceful_restart_notification: None,
+        disable_ebgp_connected_route_check: None,
+        bestpath_as_path_multipath_relax: Some(true),
+        neighbor_groups: vec![NeighborGroup {
+            name: word(neighbor_group_name),
+            bfd: true,
+            local_as: None,
+            remote_as: NeighborRemoteAs::External,
+            ips: vec!["10.0.0.2".parse().unwrap(), "10.0.0.3".parse().unwrap()],
+            interfaces: vec![],
+            ebgp_multihop: Some(3),
+            update_source: Some(iface("lo")),
+        }],
+        address_families: AddressFamilies {
+            ipv4_unicast: Some(Ipv4UnicastAF {
+                common_options: CommonAddressFamilyOptions {
+                    import_vrf: vec![],
+                    neighbors: vec![AddressFamilyNeighbor {
+                        name: neighbor_group_name.to_string(),
+                        soft_reconfiguration_inbound: Some(true),
+                        route_map_in,
+                        route_map_out,
+                    }],
+                    custom_frr_config: vec![],
+                },
+                networks: vec![Ipv4Cidr::new([10, 0, 0, 0], 24).unwrap()],
+                redistribute: vec![
+                    Redistribution {
+                        protocol: RedistributeProtocol::Connected,
+                        metric: None,
+                        route_map: None,
+                    },
+                    Redistribution {
+                        protocol: RedistributeProtocol::Static,
+                        metric: Some(100),
+                        route_map: None,
+                    },
+                ],
+            }),
+            ipv6_unicast: None,
+            l2vpn_evpn: Some(L2vpnEvpnAF {
+                common_options: CommonAddressFamilyOptions {
+                    import_vrf: vec![],
+                    neighbors: vec![AddressFamilyNeighbor {
+                        name: neighbor_group_name.to_string(),
+                        soft_reconfiguration_inbound: None,
+                        route_map_in: None,
+                        route_map_out: None,
+                    }],
+                    custom_frr_config: vec![],
+                },
+                advertise_all_vni: Some(true),
+                advertise_default_gw: None,
+                default_originate: vec![],
+                advertise_ipv4_unicast: Some(true),
+                advertise_ipv6_unicast: None,
+                autort_as: None,
+                route_targets: None,
+            }),
+        },
+        custom_frr_config: vec![],
+    }
+}
+
+#[test_with::executable(vtysh)]
+#[test]
+fn test_vtysh_full_combined_config() {
+    let config = FrrConfig {
+        bgp: BgpFrrConfig {
+            vrf_router: BTreeMap::from([
+                (
+                    VrfName::Default,
+                    make_evpn_bgp_router(
+                        "EVPN",
+                        Some(RouteMapName::new("INBOUND".to_string())),
+                        Some(RouteMapName::new("OUTBOUND".to_string())),
+                    ),
+                ),
+                (
+                    VrfName::Custom("cool".to_string()),
+                    make_evpn_bgp_router(
+                        "EVPN",
+                        Some(RouteMapName::new("INBOUND".to_string())),
+                        Some(RouteMapName::new("OUTBOUND".to_string())),
+                    ),
+                ),
+            ]),
+            ..Default::default()
+        },
+        ospf: OspfFrrConfig {
+            router: Some(OspfRouter::new("10.0.0.1".parse().unwrap())),
+            interfaces: BTreeMap::from([(
+                iface("eth1"),
+                Interface {
+                    addresses_v4: vec![Ipv4Cidr::new([10, 1, 0, 1], 24).unwrap()],
+                    addresses_v6: vec![],
+                    properties: OspfInterface {
+                        area: Area::new(word("0")).unwrap(),
+                        passive: None,
+                        network_type: None,
+                    },
+                },
+            )]),
+        },
+        openfabric: OpenfabricFrrConfig {
+            router: BTreeMap::from([(
+                OpenfabricRouterName::new(word("uwu")),
+                OpenfabricRouter::new("49.0001.1000.0000.1001.00".parse().unwrap()),
+            )]),
+            interfaces: BTreeMap::from([(
+                iface("eth2"),
+                Interface {
+                    addresses_v4: vec![],
+                    addresses_v6: vec![Ipv6Cidr::new(
+                        "fd00::1".parse::<std::net::Ipv6Addr>().unwrap(),
+                        64,
+                    )
+                    .unwrap()],
+                    properties: OpenfabricInterface {
+                        fabric_id: OpenfabricRouterName::new(word("uwu")),
+                        passive: None,
+                        hello_interval: None,
+                        csnp_interval: None,
+                        hello_multiplier: None,
+                        is_ipv4: false,
+                        is_ipv6: true,
+                    },
+                },
+            )]),
+        },
+        access_lists: BTreeMap::from([(
+            AccessListName::new("MYACL".to_string()),
+            vec![AccessListRule {
+                action: AccessAction::Permit,
+                network: Cidr::new_v4([10, 0, 0, 0], 8).unwrap(),
+                seq: Some(10),
+                is_ipv6: false,
+            }],
+        )]),
+        prefix_lists: BTreeMap::from([(
+            PrefixListName::new("MYPFX".to_string()),
+            vec![PrefixListRule {
+                action: AccessAction::Permit,
+                network: Cidr::new_v4([10, 0, 0, 0], 8).unwrap(),
+                seq: Some(10),
+                le: Some(24),
+                ge: None,
+                is_ipv6: false,
+            }],
+        )]),
+        routemaps: BTreeMap::from([
+            (
+                RouteMapName::new("INBOUND".to_string()),
+                vec![RouteMapEntry {
+                    seq: 10,
+                    action: AccessAction::Permit,
+                    matches: vec![RouteMapMatch::V4(RouteMapMatchInner::Address(
+                        AccessListOrPrefixList::PrefixList(PrefixListName::new(
+                            "MYPFX".to_string(),
+                        )),
+                    ))],
+                    sets: vec![RouteMapSet::LocalPreference(200)],
+                    custom_frr_config: vec![],
+                }],
+            ),
+            (
+                RouteMapName::new("OUTBOUND".to_string()),
+                vec![RouteMapEntry {
+                    seq: 10,
+                    action: AccessAction::Permit,
+                    matches: vec![],
+                    sets: vec![RouteMapSet::Metric(50)],
+                    custom_frr_config: vec![],
+                }],
+            ),
+        ]),
+        ip_routes: vec![IpRoute {
+            is_ipv6: false,
+            prefix: Cidr::new_v4([172, 16, 0, 0], 16).unwrap(),
+            via: IpOrInterface::Ip("10.0.0.254".parse().unwrap()),
+            vrf: None,
+        }],
+        isis: proxmox_frr::ser::IsisFrrConfig::default(),
+        protocol_routemaps: BTreeMap::from([(
+            FrrProtocol::Ospf,
+            IpProtocolRouteMap {
+                v4: Some(RouteMapName::new("INBOUND".to_string())),
+                v6: None,
+            },
+        )]),
+        custom_frr_config: vec![],
+    };
+
+    dump_and_check(&config, function_name!());
+}
diff --git a/proxmox-frr/tests/weird_combinations.rs b/proxmox-frr/tests/weird_combinations.rs
new file mode 100644
index 000000000000..867a72dac553
--- /dev/null
+++ b/proxmox-frr/tests/weird_combinations.rs
@@ -0,0 +1,1225 @@
+mod common;
+
+use std::collections::BTreeMap;
+
+use proxmox_network_types::ip_address::{Ipv4Cidr, Ipv6Cidr};
+
+use proxmox_frr::ser::bgp::{
+    AddressFamilies, AddressFamilyNeighbor, BgpRouter, CommonAddressFamilyOptions,
+    DefaultOriginate, Ipv4UnicastAF, Ipv6UnicastAF, L2vpnEvpnAF, NeighborGroup, NeighborRemoteAs,
+    RedistributeProtocol, Redistribution, Vrf,
+};
+use proxmox_frr::ser::isis::{IsisInterface, IsisLevel, IsisRouter, IsisRouterName, Redistribute};
+use proxmox_frr::ser::openfabric::{OpenfabricInterface, OpenfabricRouter, OpenfabricRouterName};
+use proxmox_frr::ser::ospf::{Area, NetworkType, OspfInterface, OspfRouter};
+use proxmox_frr::ser::route_map::{
+    AccessAction, AccessListName, AccessListOrPrefixList, AccessListRule, PrefixListName,
+    PrefixListRule, RouteMapEntry, RouteMapMatch, RouteMapMatchInner, RouteMapName, RouteMapSet,
+};
+use proxmox_frr::ser::{
+    BgpFrrConfig, FrrConfig, FrrProtocol, Interface, IpOrInterface, IpProtocolRouteMap, IpRoute,
+    IsisFrrConfig, OpenfabricFrrConfig, OspfFrrConfig, VrfName,
+};
+
+use common::{dump_and_check, iface, word};
+
+#[test_with::executable(vtysh)]
+#[test]
+fn test_vtysh_all_protocols_stacked() {
+    let config = FrrConfig {
+        bgp: BgpFrrConfig {
+            vrf_router: BTreeMap::from([(
+                VrfName::Default,
+                BgpRouter {
+                    asn: 65000,
+                    router_id: "10.0.0.1".parse().unwrap(),
+                    coalesce_time: None,
+                    default_ipv4_unicast: Some(false),
+                    hard_administrative_reset: None,
+                    graceful_restart_notification: None,
+                    disable_ebgp_connected_route_check: None,
+                    bestpath_as_path_multipath_relax: Some(true),
+                    neighbor_groups: vec![NeighborGroup {
+                        name: word("PEERS"),
+                        bfd: true,
+                        local_as: None,
+                        remote_as: NeighborRemoteAs::External,
+                        ips: vec!["10.0.0.2".parse().unwrap()],
+                        interfaces: vec![],
+                        ebgp_multihop: Some(2),
+                        update_source: Some(iface("lo")),
+                    }],
+                    address_families: AddressFamilies {
+                        ipv4_unicast: Some(Ipv4UnicastAF {
+                            common_options: CommonAddressFamilyOptions {
+                                import_vrf: vec![],
+                                neighbors: vec![AddressFamilyNeighbor {
+                                    name: "PEERS".to_string(),
+                                    soft_reconfiguration_inbound: Some(true),
+                                    route_map_in: None,
+                                    route_map_out: None,
+                                }],
+                                custom_frr_config: vec![],
+                            },
+                            networks: vec![Ipv4Cidr::new([10, 0, 0, 0], 24).unwrap()],
+                            redistribute: vec![
+                                Redistribution {
+                                    protocol: RedistributeProtocol::Connected,
+                                    metric: None,
+                                    route_map: None,
+                                },
+                                Redistribution {
+                                    protocol: RedistributeProtocol::Ospf,
+                                    metric: Some(100),
+                                    route_map: None,
+                                },
+                            ],
+                        }),
+                        ipv6_unicast: None,
+                        l2vpn_evpn: Some(L2vpnEvpnAF {
+                            common_options: CommonAddressFamilyOptions {
+                                import_vrf: vec![],
+                                neighbors: vec![AddressFamilyNeighbor {
+                                    name: "PEERS".to_string(),
+                                    soft_reconfiguration_inbound: None,
+                                    route_map_in: None,
+                                    route_map_out: None,
+                                }],
+                                custom_frr_config: vec![],
+                            },
+                            advertise_all_vni: Some(true),
+                            advertise_default_gw: None,
+                            default_originate: vec![],
+                            advertise_ipv4_unicast: Some(true),
+                            advertise_ipv6_unicast: None,
+                            autort_as: None,
+                            route_targets: None,
+                        }),
+                    },
+                    custom_frr_config: vec![],
+                },
+            )]),
+            ..Default::default()
+        },
+        ospf: OspfFrrConfig {
+            router: Some(OspfRouter::new("10.0.0.1".parse().unwrap())),
+            interfaces: BTreeMap::from([(
+                iface("eth1"),
+                Interface {
+                    addresses_v4: vec![Ipv4Cidr::new([10, 1, 0, 1], 24).unwrap()],
+                    addresses_v6: vec![],
+                    properties: OspfInterface {
+                        area: Area::new(word("0")).unwrap(),
+                        passive: None,
+                        network_type: Some(NetworkType::PointToPoint),
+                    },
+                },
+            )]),
+        },
+        openfabric: OpenfabricFrrConfig {
+            router: BTreeMap::from([(
+                OpenfabricRouterName::new(word("fab1")),
+                OpenfabricRouter::new("49.0001.1000.0000.0001.00".parse().unwrap()),
+            )]),
+            interfaces: BTreeMap::from([(
+                iface("eth2"),
+                Interface {
+                    addresses_v4: vec![Ipv4Cidr::new([10, 2, 0, 1], 24).unwrap()],
+                    addresses_v6: vec![],
+                    properties: OpenfabricInterface {
+                        fabric_id: OpenfabricRouterName::new(word("fab1")),
+                        passive: None,
+                        hello_interval: None,
+                        csnp_interval: None,
+                        hello_multiplier: None,
+                        is_ipv4: true,
+                        is_ipv6: false,
+                    },
+                },
+            )]),
+        },
+        isis: IsisFrrConfig {
+            router: BTreeMap::from([(
+                IsisRouterName::new(word("isis1")),
+                IsisRouter {
+                    net: "49.0001.1921.6800.1001.00".parse().unwrap(),
+                    log_adjacency_changes: Some(true),
+                    redistribute: Some(Redistribute {
+                        ipv4_connected: IsisLevel::Level1,
+                        ipv6_connected: IsisLevel::Level1,
+                    }),
+                    custom_frr_config: vec![],
+                },
+            )]),
+            interfaces: BTreeMap::from([(
+                iface("eth3"),
+                Interface {
+                    addresses_v4: vec![Ipv4Cidr::new([10, 3, 0, 1], 24).unwrap()],
+                    addresses_v6: vec![],
+                    properties: IsisInterface {
+                        domain: IsisRouterName::new(word("isis1")),
+                        is_ipv4: true,
+                        is_ipv6: false,
+                        custom_frr_config: vec![],
+                    },
+                },
+            )]),
+        },
+        ip_routes: vec![IpRoute {
+            is_ipv6: false,
+            prefix: proxmox_network_types::Cidr::new_v4([172, 16, 0, 0], 16).unwrap(),
+            via: IpOrInterface::Ip("10.0.0.254".parse().unwrap()),
+            vrf: None,
+        }],
+        ..Default::default()
+    };
+
+    dump_and_check(&config, function_name!());
+}
+
+#[test_with::executable(vtysh)]
+#[test]
+fn test_vtysh_custom_frr_config_everywhere() {
+    let config = FrrConfig {
+        bgp: BgpFrrConfig {
+            vrfs: BTreeMap::from([(
+                iface("vrf-custom"),
+                Vrf {
+                    vni: Some(500),
+                    ip_routes: vec![],
+                    custom_frr_config: vec![],
+                },
+            )]),
+            vrf_router: BTreeMap::from([(
+                VrfName::Default,
+                BgpRouter {
+                    asn: 65000,
+                    router_id: "10.0.0.1".parse().unwrap(),
+                    coalesce_time: None,
+                    default_ipv4_unicast: Some(false),
+                    hard_administrative_reset: None,
+                    graceful_restart_notification: None,
+                    disable_ebgp_connected_route_check: None,
+                    bestpath_as_path_multipath_relax: None,
+                    neighbor_groups: vec![],
+                    address_families: AddressFamilies {
+                        ipv4_unicast: Some(Ipv4UnicastAF {
+                            common_options: CommonAddressFamilyOptions {
+                                import_vrf: vec![],
+                                neighbors: vec![],
+                                custom_frr_config: vec![
+                                    "  maximum-paths 8".to_string(),
+                                    "  maximum-paths ibgp 4".to_string(),
+                                ],
+                            },
+                            networks: vec![Ipv4Cidr::new([10, 0, 0, 0], 24).unwrap()],
+                            redistribute: vec![Redistribution {
+                                protocol: RedistributeProtocol::Connected,
+                                metric: None,
+                                route_map: None,
+                            }],
+                        }),
+                        ipv6_unicast: None,
+                        l2vpn_evpn: None,
+                    },
+                    custom_frr_config: vec![
+                        " bgp log-neighbor-changes".to_string(),
+                        " timers bgp 10 30".to_string(),
+                    ],
+                },
+            )]),
+            ..Default::default()
+        },
+        isis: IsisFrrConfig {
+            router: BTreeMap::from([(
+                IsisRouterName::new(word("customisis")),
+                IsisRouter {
+                    net: "49.0001.1921.6800.1001.00".parse().unwrap(),
+                    log_adjacency_changes: None,
+                    redistribute: Some(Redistribute {
+                        ipv4_connected: IsisLevel::Level1,
+                        ipv6_connected: IsisLevel::Level1,
+                    }),
+                    custom_frr_config: vec![
+                        " metric-style wide".to_string(),
+                        " is-type level-1".to_string(),
+                    ],
+                },
+            )]),
+            interfaces: BTreeMap::from([(
+                iface("eth0"),
+                Interface {
+                    addresses_v4: vec![Ipv4Cidr::new([10, 0, 0, 1], 24).unwrap()],
+                    addresses_v6: vec![],
+                    properties: IsisInterface {
+                        domain: IsisRouterName::new(word("customisis")),
+                        is_ipv4: true,
+                        is_ipv6: false,
+                        custom_frr_config: vec![
+                            " isis circuit-type level-1".to_string(),
+                            " isis metric 100".to_string(),
+                        ],
+                    },
+                },
+            )]),
+        },
+        routemaps: BTreeMap::from([(
+            RouteMapName::new("CUSTOMMAP".to_string()),
+            vec![RouteMapEntry {
+                seq: 10,
+                action: AccessAction::Permit,
+                matches: vec![],
+                sets: vec![RouteMapSet::Metric(42)],
+                custom_frr_config: vec![" on-match next".to_string()],
+            }],
+        )]),
+        custom_frr_config: vec![
+            "log syslog informational".to_string(),
+            "hostname test-router".to_string(),
+            "service integrated-vtysh-config".to_string(),
+        ],
+        ..Default::default()
+    };
+
+    dump_and_check(&config, function_name!());
+}
+
+#[test_with::executable(vtysh)]
+#[test]
+fn test_vtysh_bgp_evpn_only_no_unicast() {
+    let config = FrrConfig {
+        bgp: BgpFrrConfig {
+            vrf_router: BTreeMap::from([(
+                VrfName::Default,
+                BgpRouter {
+                    asn: 65000,
+                    router_id: "10.0.0.1".parse().unwrap(),
+                    coalesce_time: None,
+                    default_ipv4_unicast: Some(false),
+                    hard_administrative_reset: None,
+                    graceful_restart_notification: None,
+                    disable_ebgp_connected_route_check: None,
+                    bestpath_as_path_multipath_relax: None,
+                    neighbor_groups: vec![NeighborGroup {
+                        name: word("EVPN"),
+                        bfd: true,
+                        local_as: None,
+                        remote_as: NeighborRemoteAs::External,
+                        ips: vec!["10.0.0.2".parse().unwrap()],
+                        interfaces: vec![],
+                        ebgp_multihop: Some(3),
+                        update_source: Some(iface("lo")),
+                    }],
+                    address_families: AddressFamilies {
+                        ipv4_unicast: None,
+                        ipv6_unicast: None,
+                        l2vpn_evpn: Some(L2vpnEvpnAF {
+                            common_options: CommonAddressFamilyOptions {
+                                import_vrf: vec![],
+                                neighbors: vec![AddressFamilyNeighbor {
+                                    name: "EVPN".to_string(),
+                                    soft_reconfiguration_inbound: None,
+                                    route_map_in: None,
+                                    route_map_out: None,
+                                }],
+                                custom_frr_config: vec![],
+                            },
+                            advertise_all_vni: Some(true),
+                            advertise_default_gw: Some(true),
+                            default_originate: vec![DefaultOriginate::Ipv4, DefaultOriginate::Ipv6],
+                            advertise_ipv4_unicast: Some(true),
+                            advertise_ipv6_unicast: Some(true),
+                            autort_as: None,
+                            route_targets: None,
+                        }),
+                    },
+                    custom_frr_config: vec![],
+                },
+            )]),
+            ..Default::default()
+        },
+        ..Default::default()
+    };
+
+    dump_and_check(&config, function_name!());
+}
+
+#[test_with::executable(vtysh)]
+#[test]
+fn test_vtysh_bgp_ipv6_only() {
+    let config = FrrConfig {
+        bgp: BgpFrrConfig {
+            vrf_router: BTreeMap::from([(
+                VrfName::Default,
+                BgpRouter {
+                    asn: 65000,
+                    router_id: "10.0.0.1".parse().unwrap(),
+                    coalesce_time: None,
+                    default_ipv4_unicast: Some(false),
+                    hard_administrative_reset: None,
+                    graceful_restart_notification: None,
+                    disable_ebgp_connected_route_check: None,
+                    bestpath_as_path_multipath_relax: None,
+                    neighbor_groups: vec![NeighborGroup {
+                        name: word("V6PEERS"),
+                        bfd: false,
+                        local_as: None,
+                        remote_as: NeighborRemoteAs::Internal,
+                        ips: vec!["fd00::2".parse().unwrap(), "fd00::3".parse().unwrap()],
+                        interfaces: vec![],
+                        ebgp_multihop: None,
+                        update_source: Some(iface("lo")),
+                    }],
+                    address_families: AddressFamilies {
+                        ipv4_unicast: None,
+                        ipv6_unicast: Some(Ipv6UnicastAF {
+                            common_options: CommonAddressFamilyOptions {
+                                import_vrf: vec![],
+                                neighbors: vec![AddressFamilyNeighbor {
+                                    name: "V6PEERS".to_string(),
+                                    soft_reconfiguration_inbound: Some(true),
+                                    route_map_in: None,
+                                    route_map_out: None,
+                                }],
+                                custom_frr_config: vec!["  maximum-paths 4".to_string()],
+                            },
+                            networks: vec![
+                                Ipv6Cidr::new("fd00::".parse::<std::net::Ipv6Addr>().unwrap(), 64)
+                                    .unwrap(),
+                                Ipv6Cidr::new(
+                                    "2001:db8::".parse::<std::net::Ipv6Addr>().unwrap(),
+                                    48,
+                                )
+                                .unwrap(),
+                            ],
+                            redistribute: vec![Redistribution {
+                                protocol: RedistributeProtocol::Connected,
+                                metric: None,
+                                route_map: None,
+                            }],
+                        }),
+                        l2vpn_evpn: None,
+                    },
+                    custom_frr_config: vec![],
+                },
+            )]),
+            ..Default::default()
+        },
+        ..Default::default()
+    };
+
+    dump_and_check(&config, function_name!());
+}
+
+#[test_with::executable(vtysh)]
+#[test]
+fn test_vtysh_only_routemaps_and_acls() {
+    let config = FrrConfig {
+        access_lists: BTreeMap::from([
+            (
+                AccessListName::new("ACL1".to_string()),
+                vec![
+                    AccessListRule {
+                        action: AccessAction::Permit,
+                        network: proxmox_network_types::Cidr::new_v4([10, 0, 0, 0], 8).unwrap(),
+                        seq: Some(10),
+                        is_ipv6: false,
+                    },
+                    AccessListRule {
+                        action: AccessAction::Deny,
+                        network: proxmox_network_types::Cidr::new_v4([0, 0, 0, 0], 0).unwrap(),
+                        seq: Some(20),
+                        is_ipv6: false,
+                    },
+                ],
+            ),
+            (
+                AccessListName::new("ACL_V6".to_string()),
+                vec![AccessListRule {
+                    action: AccessAction::Permit,
+                    network: proxmox_network_types::Cidr::new_v6(
+                        "fd00::".parse::<std::net::Ipv6Addr>().unwrap(),
+                        64,
+                    )
+                    .unwrap(),
+                    seq: Some(10),
+                    is_ipv6: true,
+                }],
+            ),
+        ]),
+        prefix_lists: BTreeMap::from([
+            (
+                PrefixListName::new("PFX1".to_string()),
+                vec![
+                    PrefixListRule {
+                        action: AccessAction::Permit,
+                        network: proxmox_network_types::Cidr::new_v4([10, 0, 0, 0], 8).unwrap(),
+                        seq: Some(5),
+                        le: Some(24),
+                        ge: Some(16),
+                        is_ipv6: false,
+                    },
+                    PrefixListRule {
+                        action: AccessAction::Deny,
+                        network: proxmox_network_types::Cidr::new_v4([0, 0, 0, 0], 0).unwrap(),
+                        seq: Some(100),
+                        le: Some(32),
+                        ge: None,
+                        is_ipv6: false,
+                    },
+                ],
+            ),
+            (
+                PrefixListName::new("PFX_V6".to_string()),
+                vec![PrefixListRule {
+                    action: AccessAction::Permit,
+                    network: proxmox_network_types::Cidr::new_v6(
+                        "2001:db8::".parse::<std::net::Ipv6Addr>().unwrap(),
+                        32,
+                    )
+                    .unwrap(),
+                    seq: Some(10),
+                    le: Some(48),
+                    ge: None,
+                    is_ipv6: true,
+                }],
+            ),
+        ]),
+        routemaps: BTreeMap::from([
+            (
+                RouteMapName::new("MAP_V4".to_string()),
+                vec![
+                    RouteMapEntry {
+                        seq: 10,
+                        action: AccessAction::Permit,
+                        matches: vec![RouteMapMatch::V4(RouteMapMatchInner::Address(
+                            AccessListOrPrefixList::PrefixList(PrefixListName::new(
+                                "PFX1".to_string(),
+                            )),
+                        ))],
+                        sets: vec![RouteMapSet::LocalPreference(200), RouteMapSet::Metric(50)],
+                        custom_frr_config: vec![],
+                    },
+                    RouteMapEntry {
+                        seq: 20,
+                        action: AccessAction::Permit,
+                        matches: vec![RouteMapMatch::V4(RouteMapMatchInner::Address(
+                            AccessListOrPrefixList::AccessList(AccessListName::new(
+                                "ACL1".to_string(),
+                            )),
+                        ))],
+                        sets: vec![RouteMapSet::Metric(100)],
+                        custom_frr_config: vec![],
+                    },
+                    RouteMapEntry {
+                        seq: 100,
+                        action: AccessAction::Deny,
+                        matches: vec![],
+                        sets: vec![],
+                        custom_frr_config: vec![],
+                    },
+                ],
+            ),
+            (
+                RouteMapName::new("MAP_V6".to_string()),
+                vec![RouteMapEntry {
+                    seq: 10,
+                    action: AccessAction::Permit,
+                    matches: vec![RouteMapMatch::V6(RouteMapMatchInner::Address(
+                        AccessListOrPrefixList::PrefixList(PrefixListName::new(
+                            "PFX_V6".to_string(),
+                        )),
+                    ))],
+                    sets: vec![RouteMapSet::LocalPreference(300)],
+                    custom_frr_config: vec![],
+                }],
+            ),
+        ]),
+        custom_frr_config: vec!["hostname acl-only-router".to_string()],
+        ..Default::default()
+    };
+
+    dump_and_check(&config, function_name!());
+}
+
+#[test_with::executable(vtysh)]
+#[test]
+fn test_vtysh_many_static_routes() {
+    let config = FrrConfig {
+        ip_routes: vec![
+            IpRoute {
+                is_ipv6: false,
+                prefix: proxmox_network_types::Cidr::new_v4([10, 10, 0, 0], 24).unwrap(),
+                via: IpOrInterface::Ip("10.0.0.1".parse().unwrap()),
+                vrf: None,
+            },
+            IpRoute {
+                is_ipv6: false,
+                prefix: proxmox_network_types::Cidr::new_v4([10, 20, 0, 0], 16).unwrap(),
+                via: IpOrInterface::Ip("10.0.0.2".parse().unwrap()),
+                vrf: None,
+            },
+            IpRoute {
+                is_ipv6: false,
+                prefix: proxmox_network_types::Cidr::new_v4([192, 168, 0, 0], 16).unwrap(),
+                via: IpOrInterface::Interface(iface("eth0")),
+                vrf: None,
+            },
+            IpRoute {
+                is_ipv6: true,
+                prefix: proxmox_network_types::Cidr::new_v6(
+                    "fd00:1::".parse::<std::net::Ipv6Addr>().unwrap(),
+                    64,
+                )
+                .unwrap(),
+                via: IpOrInterface::Ip("fd00::1".parse().unwrap()),
+                vrf: None,
+            },
+            IpRoute {
+                is_ipv6: true,
+                prefix: proxmox_network_types::Cidr::new_v6(
+                    "2001:db8:1::".parse::<std::net::Ipv6Addr>().unwrap(),
+                    48,
+                )
+                .unwrap(),
+                via: IpOrInterface::Ip("fd00::2".parse().unwrap()),
+                vrf: None,
+            },
+        ],
+        ..Default::default()
+    };
+
+    dump_and_check(&config, function_name!());
+}
+
+#[test_with::executable(vtysh)]
+#[test]
+fn test_vtysh_border_gateway_combo() {
+    let config = FrrConfig {
+        bgp: BgpFrrConfig {
+            vrf_router: BTreeMap::from([(
+                VrfName::Default,
+                BgpRouter {
+                    asn: 65000,
+                    router_id: "10.0.0.1".parse().unwrap(),
+                    coalesce_time: None,
+                    default_ipv4_unicast: Some(false),
+                    hard_administrative_reset: None,
+                    graceful_restart_notification: None,
+                    disable_ebgp_connected_route_check: Some(true),
+                    bestpath_as_path_multipath_relax: Some(true),
+                    neighbor_groups: vec![
+                        NeighborGroup {
+                            name: word("UPSTREAM"),
+                            bfd: true,
+                            local_as: None,
+                            remote_as: NeighborRemoteAs::Asn(64999),
+                            ips: vec!["10.0.0.100".parse().unwrap()],
+                            interfaces: vec![],
+                            ebgp_multihop: Some(2),
+                            update_source: None,
+                        },
+                        NeighborGroup {
+                            name: word("INTERNAL"),
+                            bfd: false,
+                            local_as: None,
+                            remote_as: NeighborRemoteAs::Internal,
+                            ips: vec!["10.0.0.2".parse().unwrap(), "10.0.0.3".parse().unwrap()],
+                            interfaces: vec![],
+                            ebgp_multihop: None,
+                            update_source: Some(iface("lo")),
+                        },
+                    ],
+                    address_families: AddressFamilies {
+                        ipv4_unicast: Some(Ipv4UnicastAF {
+                            common_options: CommonAddressFamilyOptions {
+                                import_vrf: vec![],
+                                neighbors: vec![
+                                    AddressFamilyNeighbor {
+                                        name: "UPSTREAM".to_string(),
+                                        soft_reconfiguration_inbound: Some(true),
+                                        route_map_in: Some(RouteMapName::new(
+                                            "FROM_UPSTREAM".to_string(),
+                                        )),
+                                        route_map_out: Some(RouteMapName::new(
+                                            "TO_UPSTREAM".to_string(),
+                                        )),
+                                    },
+                                    AddressFamilyNeighbor {
+                                        name: "INTERNAL".to_string(),
+                                        soft_reconfiguration_inbound: Some(true),
+                                        route_map_in: None,
+                                        route_map_out: None,
+                                    },
+                                ],
+                                custom_frr_config: vec![],
+                            },
+                            networks: vec![Ipv4Cidr::new([10, 0, 0, 0], 8).unwrap()],
+                            redistribute: vec![
+                                Redistribution {
+                                    protocol: RedistributeProtocol::Connected,
+                                    metric: None,
+                                    route_map: None,
+                                },
+                                Redistribution {
+                                    protocol: RedistributeProtocol::Ospf,
+                                    metric: Some(150),
+                                    route_map: Some(RouteMapName::new("OSPF_INTO_BGP".to_string())),
+                                },
+                                Redistribution {
+                                    protocol: RedistributeProtocol::Static,
+                                    metric: Some(200),
+                                    route_map: None,
+                                },
+                            ],
+                        }),
+                        ipv6_unicast: None,
+                        l2vpn_evpn: None,
+                    },
+                    custom_frr_config: vec![" bgp log-neighbor-changes".to_string()],
+                },
+            )]),
+            ..Default::default()
+        },
+        ospf: OspfFrrConfig {
+            router: Some(OspfRouter::new("10.0.0.1".parse().unwrap())),
+            interfaces: BTreeMap::from([
+                (
+                    iface("eth0"),
+                    Interface {
+                        addresses_v4: vec![Ipv4Cidr::new([10, 0, 0, 1], 24).unwrap()],
+                        addresses_v6: vec![],
+                        properties: OspfInterface {
+                            area: Area::new(word("0")).unwrap(),
+                            passive: None,
+                            network_type: None,
+                        },
+                    },
+                ),
+                (
+                    iface("eth1"),
+                    Interface {
+                        addresses_v4: vec![Ipv4Cidr::new([10, 1, 0, 1], 24).unwrap()],
+                        addresses_v6: vec![],
+                        properties: OspfInterface {
+                            area: Area::new(word("1")).unwrap(),
+                            passive: Some(true),
+                            network_type: None,
+                        },
+                    },
+                ),
+            ]),
+        },
+        prefix_lists: BTreeMap::from([(
+            PrefixListName::new("UPSTREAM_NETS".to_string()),
+            vec![PrefixListRule {
+                action: AccessAction::Permit,
+                network: proxmox_network_types::Cidr::new_v4([0, 0, 0, 0], 0).unwrap(),
+                seq: Some(10),
+                le: Some(24),
+                ge: None,
+                is_ipv6: false,
+            }],
+        )]),
+        routemaps: BTreeMap::from([
+            (
+                RouteMapName::new("FROM_UPSTREAM".to_string()),
+                vec![
+                    RouteMapEntry {
+                        seq: 10,
+                        action: AccessAction::Permit,
+                        matches: vec![RouteMapMatch::V4(RouteMapMatchInner::Address(
+                            AccessListOrPrefixList::PrefixList(PrefixListName::new(
+                                "UPSTREAM_NETS".to_string(),
+                            )),
+                        ))],
+                        sets: vec![RouteMapSet::LocalPreference(200)],
+                        custom_frr_config: vec![],
+                    },
+                    RouteMapEntry {
+                        seq: 100,
+                        action: AccessAction::Deny,
+                        matches: vec![],
+                        sets: vec![],
+                        custom_frr_config: vec![],
+                    },
+                ],
+            ),
+            (
+                RouteMapName::new("TO_UPSTREAM".to_string()),
+                vec![RouteMapEntry {
+                    seq: 10,
+                    action: AccessAction::Permit,
+                    matches: vec![],
+                    sets: vec![
+                        RouteMapSet::Metric(100),
+                        RouteMapSet::Community("65000:100".to_string()),
+                    ],
+                    custom_frr_config: vec![],
+                }],
+            ),
+            (
+                RouteMapName::new("OSPF_INTO_BGP".to_string()),
+                vec![RouteMapEntry {
+                    seq: 10,
+                    action: AccessAction::Permit,
+                    matches: vec![],
+                    sets: vec![RouteMapSet::LocalPreference(50)],
+                    custom_frr_config: vec![],
+                }],
+            ),
+        ]),
+        ip_routes: vec![IpRoute {
+            is_ipv6: false,
+            prefix: proxmox_network_types::Cidr::new_v4([0, 0, 0, 0], 0).unwrap(),
+            via: IpOrInterface::Ip("10.0.0.100".parse().unwrap()),
+            vrf: None,
+        }],
+        protocol_routemaps: BTreeMap::from([
+            (
+                FrrProtocol::Ospf,
+                IpProtocolRouteMap {
+                    v4: Some(RouteMapName::new("OSPF_INTO_BGP".to_string())),
+                    v6: None,
+                },
+            ),
+            (
+                FrrProtocol::Bgp,
+                IpProtocolRouteMap {
+                    v4: Some(RouteMapName::new("FROM_UPSTREAM".to_string())),
+                    v6: None,
+                },
+            ),
+        ]),
+        custom_frr_config: vec!["log syslog informational".to_string()],
+        ..Default::default()
+    };
+
+    dump_and_check(&config, function_name!());
+}
+
+#[test_with::executable(vtysh)]
+#[test]
+fn test_vtysh_bgp_neighbor_local_as() {
+    let config = FrrConfig {
+        bgp: BgpFrrConfig {
+            vrf_router: BTreeMap::from([(
+                VrfName::Default,
+                BgpRouter {
+                    asn: 65000,
+                    router_id: "10.0.0.1".parse().unwrap(),
+                    coalesce_time: None,
+                    default_ipv4_unicast: Some(false),
+                    hard_administrative_reset: None,
+                    graceful_restart_notification: None,
+                    disable_ebgp_connected_route_check: None,
+                    bestpath_as_path_multipath_relax: None,
+                    neighbor_groups: vec![NeighborGroup {
+                        name: word("MIGRATION"),
+                        bfd: false,
+                        local_as: Some(65100),
+                        remote_as: NeighborRemoteAs::Asn(65200),
+                        ips: vec!["10.0.0.50".parse().unwrap()],
+                        interfaces: vec![],
+                        ebgp_multihop: Some(4),
+                        update_source: None,
+                    }],
+                    address_families: AddressFamilies {
+                        ipv4_unicast: Some(Ipv4UnicastAF {
+                            common_options: CommonAddressFamilyOptions {
+                                import_vrf: vec![],
+                                neighbors: vec![AddressFamilyNeighbor {
+                                    name: "MIGRATION".to_string(),
+                                    soft_reconfiguration_inbound: Some(true),
+                                    route_map_in: None,
+                                    route_map_out: None,
+                                }],
+                                custom_frr_config: vec![],
+                            },
+                            networks: vec![],
+                            redistribute: vec![Redistribution {
+                                protocol: RedistributeProtocol::Connected,
+                                metric: None,
+                                route_map: None,
+                            }],
+                        }),
+                        ipv6_unicast: None,
+                        l2vpn_evpn: None,
+                    },
+                    custom_frr_config: vec![],
+                },
+            )]),
+            ..Default::default()
+        },
+        ..Default::default()
+    };
+
+    dump_and_check(&config, function_name!());
+}
+
+#[test_with::executable(vtysh)]
+#[test]
+fn test_vtysh_vrf_with_routes_and_global_routes() {
+    let config = FrrConfig {
+        bgp: BgpFrrConfig {
+            vrfs: BTreeMap::from([(
+                iface("vrf-routes"),
+                Vrf {
+                    vni: Some(100),
+                    ip_routes: vec![
+                        IpRoute {
+                            is_ipv6: false,
+                            prefix: proxmox_network_types::Cidr::new_v4([10, 100, 0, 0], 24)
+                                .unwrap(),
+                            via: IpOrInterface::Ip("10.0.0.254".parse().unwrap()),
+                            vrf: None,
+                        },
+                        IpRoute {
+                            is_ipv6: true,
+                            prefix: proxmox_network_types::Cidr::new_v6(
+                                "fd10::".parse::<std::net::Ipv6Addr>().unwrap(),
+                                64,
+                            )
+                            .unwrap(),
+                            via: IpOrInterface::Ip("fd00::1".parse().unwrap()),
+                            vrf: None,
+                        },
+                    ],
+                    custom_frr_config: vec![],
+                },
+            )]),
+            vrf_router: BTreeMap::from([(
+                VrfName::Default,
+                BgpRouter {
+                    asn: 65000,
+                    router_id: "10.0.0.1".parse().unwrap(),
+                    coalesce_time: None,
+                    default_ipv4_unicast: None,
+                    hard_administrative_reset: None,
+                    graceful_restart_notification: None,
+                    disable_ebgp_connected_route_check: None,
+                    bestpath_as_path_multipath_relax: None,
+                    neighbor_groups: vec![],
+                    address_families: AddressFamilies::default(),
+                    custom_frr_config: vec![],
+                },
+            )]),
+            ..Default::default()
+        },
+        ip_routes: vec![
+            IpRoute {
+                is_ipv6: false,
+                prefix: proxmox_network_types::Cidr::new_v4([172, 16, 0, 0], 12).unwrap(),
+                via: IpOrInterface::Ip("10.0.0.1".parse().unwrap()),
+                vrf: None,
+            },
+            IpRoute {
+                is_ipv6: true,
+                prefix: proxmox_network_types::Cidr::new_v6(
+                    "2001:db8::".parse::<std::net::Ipv6Addr>().unwrap(),
+                    32,
+                )
+                .unwrap(),
+                via: IpOrInterface::Ip("fd00::1".parse().unwrap()),
+                vrf: None,
+            },
+        ],
+        ..Default::default()
+    };
+
+    dump_and_check(&config, function_name!());
+}
+
+#[test_with::executable(vtysh)]
+#[test]
+fn test_vtysh_bgp_maximal() {
+    let config = FrrConfig {
+        bgp: BgpFrrConfig {
+            vrf_router: BTreeMap::from([(
+                VrfName::Default,
+                BgpRouter {
+                    asn: 65000,
+                    router_id: "10.0.0.1".parse().unwrap(),
+                    coalesce_time: Some(2000),
+                    default_ipv4_unicast: Some(false),
+                    hard_administrative_reset: Some(false),
+                    graceful_restart_notification: Some(false),
+                    disable_ebgp_connected_route_check: Some(true),
+                    bestpath_as_path_multipath_relax: Some(true),
+                    neighbor_groups: vec![
+                        NeighborGroup {
+                            name: word("GROUP_A"),
+                            bfd: true,
+                            local_as: Some(65001),
+                            remote_as: NeighborRemoteAs::External,
+                            ips: vec!["10.0.0.2".parse().unwrap(), "10.0.0.3".parse().unwrap()],
+                            interfaces: vec![iface("swp1")],
+                            ebgp_multihop: Some(5),
+                            update_source: Some(iface("lo")),
+                        },
+                        NeighborGroup {
+                            name: word("GROUP_B"),
+                            bfd: false,
+                            local_as: None,
+                            remote_as: NeighborRemoteAs::Internal,
+                            ips: vec!["10.0.0.10".parse().unwrap()],
+                            interfaces: vec![],
+                            ebgp_multihop: None,
+                            update_source: None,
+                        },
+                    ],
+                    address_families: AddressFamilies {
+                        ipv4_unicast: Some(Ipv4UnicastAF {
+                            common_options: CommonAddressFamilyOptions {
+                                import_vrf: vec![],
+                                neighbors: vec![
+                                    AddressFamilyNeighbor {
+                                        name: "GROUP_A".to_string(),
+                                        soft_reconfiguration_inbound: Some(true),
+                                        route_map_in: Some(RouteMapName::new("IN4".to_string())),
+                                        route_map_out: Some(RouteMapName::new("OUT4".to_string())),
+                                    },
+                                    AddressFamilyNeighbor {
+                                        name: "GROUP_B".to_string(),
+                                        soft_reconfiguration_inbound: Some(true),
+                                        route_map_in: None,
+                                        route_map_out: None,
+                                    },
+                                ],
+                                custom_frr_config: vec!["  maximum-paths 16".to_string()],
+                            },
+                            networks: vec![
+                                Ipv4Cidr::new([10, 0, 0, 0], 8).unwrap(),
+                                Ipv4Cidr::new([172, 16, 0, 0], 12).unwrap(),
+                            ],
+                            redistribute: vec![
+                                Redistribution {
+                                    protocol: RedistributeProtocol::Connected,
+                                    metric: None,
+                                    route_map: None,
+                                },
+                                Redistribution {
+                                    protocol: RedistributeProtocol::Static,
+                                    metric: Some(50),
+                                    route_map: None,
+                                },
+                            ],
+                        }),
+                        ipv6_unicast: Some(Ipv6UnicastAF {
+                            common_options: CommonAddressFamilyOptions {
+                                import_vrf: vec![],
+                                neighbors: vec![AddressFamilyNeighbor {
+                                    name: "GROUP_A".to_string(),
+                                    soft_reconfiguration_inbound: Some(true),
+                                    route_map_in: None,
+                                    route_map_out: None,
+                                }],
+                                custom_frr_config: vec![],
+                            },
+                            networks: vec![Ipv6Cidr::new(
+                                "fd00::".parse::<std::net::Ipv6Addr>().unwrap(),
+                                48,
+                            )
+                            .unwrap()],
+                            redistribute: vec![Redistribution {
+                                protocol: RedistributeProtocol::Connected,
+                                metric: None,
+                                route_map: None,
+                            }],
+                        }),
+                        l2vpn_evpn: Some(L2vpnEvpnAF {
+                            common_options: CommonAddressFamilyOptions {
+                                import_vrf: vec![],
+                                neighbors: vec![AddressFamilyNeighbor {
+                                    name: "GROUP_A".to_string(),
+                                    soft_reconfiguration_inbound: None,
+                                    route_map_in: None,
+                                    route_map_out: None,
+                                }],
+                                custom_frr_config: vec!["  advertise-svi-ip".to_string()],
+                            },
+                            advertise_all_vni: Some(true),
+                            advertise_default_gw: Some(true),
+                            default_originate: vec![DefaultOriginate::Ipv4, DefaultOriginate::Ipv6],
+                            advertise_ipv4_unicast: Some(true),
+                            advertise_ipv6_unicast: Some(true),
+                            autort_as: None,
+                            route_targets: None,
+                        }),
+                    },
+                    custom_frr_config: vec![
+                        " bgp log-neighbor-changes".to_string(),
+                        " timers bgp 10 30".to_string(),
+                        " bgp graceful-restart".to_string(),
+                    ],
+                },
+            )]),
+            ..Default::default()
+        },
+        routemaps: BTreeMap::from([
+            (
+                RouteMapName::new("IN4".to_string()),
+                vec![RouteMapEntry {
+                    seq: 10,
+                    action: AccessAction::Permit,
+                    matches: vec![],
+                    sets: vec![RouteMapSet::LocalPreference(200)],
+                    custom_frr_config: vec![],
+                }],
+            ),
+            (
+                RouteMapName::new("OUT4".to_string()),
+                vec![RouteMapEntry {
+                    seq: 10,
+                    action: AccessAction::Permit,
+                    matches: vec![],
+                    sets: vec![RouteMapSet::Metric(100)],
+                    custom_frr_config: vec![],
+                }],
+            ),
+        ]),
+        custom_frr_config: vec![
+            "log syslog informational".to_string(),
+            "service integrated-vtysh-config".to_string(),
+        ],
+        ..Default::default()
+    };
+
+    dump_and_check(&config, function_name!());
+}
+
+#[test_with::executable(vtysh)]
+#[test]
+fn test_vtysh_routemap_nexthop_match_community_set() {
+    let config = FrrConfig {
+        routemaps: BTreeMap::from([(
+            RouteMapName::new("NEXTHOP_MATCH".to_string()),
+            vec![
+                RouteMapEntry {
+                    seq: 10,
+                    action: AccessAction::Permit,
+                    matches: vec![RouteMapMatch::V4(RouteMapMatchInner::NextHop(
+                        "10.0.0.1".to_string(),
+                    ))],
+                    sets: vec![
+                        RouteMapSet::Community("65000:100".to_string()),
+                        RouteMapSet::LocalPreference(300),
+                    ],
+                    custom_frr_config: vec![" on-match next".to_string()],
+                },
+                RouteMapEntry {
+                    seq: 20,
+                    action: AccessAction::Permit,
+                    matches: vec![RouteMapMatch::V4(RouteMapMatchInner::NextHop(
+                        "10.0.0.2".to_string(),
+                    ))],
+                    sets: vec![RouteMapSet::Metric(50)],
+                    custom_frr_config: vec![],
+                },
+                RouteMapEntry {
+                    seq: 100,
+                    action: AccessAction::Deny,
+                    matches: vec![],
+                    sets: vec![],
+                    custom_frr_config: vec![],
+                },
+            ],
+        )]),
+        custom_frr_config: vec![],
+        ..Default::default()
+    };
+
+    dump_and_check(&config, function_name!());
+}
+
+#[test_with::executable(vtysh)]
+#[test]
+fn test_vtysh_protocol_routemaps_multiple() {
+    let config = FrrConfig {
+        routemaps: BTreeMap::from([
+            (
+                RouteMapName::new("OSPF_POLICY".to_string()),
+                vec![RouteMapEntry {
+                    seq: 10,
+                    action: AccessAction::Permit,
+                    matches: vec![],
+                    sets: vec![],
+                    custom_frr_config: vec![],
+                }],
+            ),
+            (
+                RouteMapName::new("BGP_POLICY".to_string()),
+                vec![RouteMapEntry {
+                    seq: 10,
+                    action: AccessAction::Permit,
+                    matches: vec![],
+                    sets: vec![],
+                    custom_frr_config: vec![],
+                }],
+            ),
+            (
+                RouteMapName::new("FABRIC_POLICY".to_string()),
+                vec![RouteMapEntry {
+                    seq: 10,
+                    action: AccessAction::Permit,
+                    matches: vec![],
+                    sets: vec![],
+                    custom_frr_config: vec![],
+                }],
+            ),
+        ]),
+        protocol_routemaps: BTreeMap::from([
+            (
+                FrrProtocol::Ospf,
+                IpProtocolRouteMap {
+                    v4: Some(RouteMapName::new("OSPF_POLICY".to_string())),
+                    v6: None,
+                },
+            ),
+            (
+                FrrProtocol::Bgp,
+                IpProtocolRouteMap {
+                    v4: Some(RouteMapName::new("BGP_POLICY".to_string())),
+                    v6: Some(RouteMapName::new("BGP_POLICY".to_string())),
+                },
+            ),
+            (
+                FrrProtocol::Openfabric,
+                IpProtocolRouteMap {
+                    v4: None,
+                    v6: Some(RouteMapName::new("FABRIC_POLICY".to_string())),
+                },
+            ),
+        ]),
+        ..Default::default()
+    };
+
+    dump_and_check(&config, function_name!());
+}
+
+#[test_with::executable(vtysh)]
+#[test]
+fn test_vtysh_routemap_set_src() {
+    let config = FrrConfig {
+        routemaps: BTreeMap::from([(
+            RouteMapName::new("SET_SRC".to_string()),
+            vec![RouteMapEntry {
+                seq: 10,
+                action: AccessAction::Permit,
+                matches: vec![],
+                sets: vec![RouteMapSet::Src("10.0.0.1".parse().unwrap())],
+                custom_frr_config: vec![],
+            }],
+        )]),
+        protocol_routemaps: BTreeMap::from([(
+            FrrProtocol::Ospf,
+            IpProtocolRouteMap {
+                v4: Some(RouteMapName::new("SET_SRC".to_string())),
+                v6: None,
+            },
+        )]),
+        ..Default::default()
+    };
+
+    dump_and_check(&config, function_name!());
+}
diff --git a/proxmox-sdn-types/src/openfabric.rs b/proxmox-sdn-types/src/openfabric.rs
index c79e2d9a2935..908ca793b2e8 100644
--- a/proxmox-sdn-types/src/openfabric.rs
+++ b/proxmox-sdn-types/src/openfabric.rs
@@ -1,7 +1,7 @@
 use serde::{Deserialize, Serialize};
 use std::fmt::Display;
 
-use proxmox_schema::{api, UpdaterType};
+use proxmox_schema::{api, ApiType, Schema, UpdaterType};
 
 /// The OpenFabric CSNP Interval.
 ///
@@ -16,6 +16,16 @@ use proxmox_schema::{api, UpdaterType};
 #[serde(transparent)]
 pub struct CsnpInterval(#[serde(deserialize_with = "proxmox_serde::perl::deserialize_u16")] u16);
 
+impl CsnpInterval {
+    pub fn new(interval: u16) -> Result<Self, anyhow::Error> {
+        match CsnpInterval::API_SCHEMA {
+            Schema::Integer(s) => s.check_constraints(interval as i64),
+            _ => unreachable!(),
+        }
+        .map(|_| Self(interval))
+    }
+}
+
 impl UpdaterType for CsnpInterval {
     type Updater = Option<CsnpInterval>;
 }
@@ -39,6 +49,16 @@ impl Display for CsnpInterval {
 #[serde(transparent)]
 pub struct HelloInterval(#[serde(deserialize_with = "proxmox_serde::perl::deserialize_u16")] u16);
 
+impl HelloInterval {
+    pub fn new(interval: u16) -> Result<Self, anyhow::Error> {
+        match HelloInterval::API_SCHEMA {
+            Schema::Integer(s) => s.check_constraints(interval as i64),
+            _ => unreachable!(),
+        }
+        .map(|_| Self(interval))
+    }
+}
+
 impl UpdaterType for HelloInterval {
     type Updater = Option<HelloInterval>;
 }
@@ -61,6 +81,16 @@ impl Display for HelloInterval {
 #[serde(transparent)]
 pub struct HelloMultiplier(#[serde(deserialize_with = "proxmox_serde::perl::deserialize_u16")] u16);
 
+impl HelloMultiplier {
+    pub fn new(interval: u16) -> Result<Self, anyhow::Error> {
+        match HelloMultiplier::API_SCHEMA {
+            Schema::Integer(s) => s.check_constraints(interval as i64),
+            _ => unreachable!(),
+        }
+        .map(|_| Self(interval))
+    }
+}
+
 impl UpdaterType for HelloMultiplier {
     type Updater = Option<HelloMultiplier>;
 }
-- 
2.47.3





  parent reply	other threads:[~2026-03-23 14:26 UTC|newest]

Thread overview: 40+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-03-23 13:48 [PATCH manager/network/proxmox{-ve-rs,-perl-rs} v7 00/21] Generate frr config using jinja templates and rust types Gabriel Goller
2026-03-23 13:48 ` [PATCH proxmox-ve-rs v7 01/21] ve-config: firewall: cargo fmt Gabriel Goller
2026-03-23 13:48 ` [PATCH proxmox-ve-rs v7 02/21] frr: add proxmox-frr-templates package that contains templates Gabriel Goller
2026-03-26 15:15   ` Shannon Sterz
2026-03-27  9:07     ` Gabriel Goller
2026-03-27  9:12       ` Gabriel Goller
2026-03-27 10:10         ` Thomas Lamprecht
2026-03-27 10:17           ` Gabriel Goller
2026-03-23 13:48 ` [PATCH proxmox-ve-rs v7 03/21] ve-config: remove FrrConfigBuilder struct Gabriel Goller
2026-03-23 13:48 ` [PATCH proxmox-ve-rs v7 04/21] sdn-types: support variable-length NET identifier Gabriel Goller
2026-03-26 15:15   ` Shannon Sterz
2026-03-27  9:25     ` Gabriel Goller
2026-03-23 13:48 ` [PATCH proxmox-ve-rs v7 05/21] frr: add template serializer and serialize fabrics using templates Gabriel Goller
2026-03-27  0:37   ` Thomas Lamprecht
2026-03-27  9:45     ` Gabriel Goller
2026-03-23 13:48 ` [PATCH proxmox-ve-rs v7 06/21] frr: add isis configuration and templates Gabriel Goller
2026-03-27  0:41   ` Thomas Lamprecht
2026-03-27  9:53     ` Gabriel Goller
2026-03-23 13:48 ` [PATCH proxmox-ve-rs v7 07/21] frr: support custom frr configuration lines Gabriel Goller
2026-03-23 13:48 ` [PATCH proxmox-ve-rs v7 08/21] frr: add bgp support with templates and serialization Gabriel Goller
2026-03-27  0:50   ` Thomas Lamprecht
2026-03-27 10:05     ` Gabriel Goller
2026-03-23 13:48 ` [PATCH proxmox-ve-rs v7 09/21] frr: enable minijinja strict undefined behavior mode Gabriel Goller
2026-03-23 13:49 ` Gabriel Goller [this message]
2026-03-24  9:03   ` [PATCH proxmox-ve-rs v7 10/21] frr: add vtysh integration tests for proxmox-frr Gabriel Goller
2026-03-23 13:49 ` [PATCH proxmox-perl-rs v7 11/21] sdn: add function to generate the frr config for all daemons Gabriel Goller
2026-03-23 13:49 ` [PATCH pve-network v7 12/21] tests: use Test::Differences to make test assertions Gabriel Goller
2026-03-23 13:49 ` [PATCH pve-network v7 13/21] test: add tests for frr.conf.local merging Gabriel Goller
2026-03-23 13:49 ` [PATCH pve-network v7 14/21] test: bgp: add some various integration tests Gabriel Goller
2026-03-23 13:49 ` [PATCH pve-network v7 15/21] sdn: write structured frr config that can be rendered using templates Gabriel Goller
2026-03-23 13:49 ` [PATCH pve-network v7 16/21] sdn: remove duplicate comment line '!' in frr config Gabriel Goller
2026-03-23 13:49 ` [PATCH pve-network v7 17/21] tests: rearrange some statements in the " Gabriel Goller
2026-03-23 13:49 ` [PATCH pve-network v7 18/21] sdn: adjust frr.conf.local merging to rust template types Gabriel Goller
2026-03-23 13:49 ` [PATCH pve-network v7 19/21] test: adjust frr_local_merge test for new template generation Gabriel Goller
2026-03-23 13:49 ` [PATCH pve-network v7 20/21] api: add dry-run endpoint for sdn apply to preview changes Gabriel Goller
2026-03-27  1:07   ` Thomas Lamprecht
2026-03-23 13:49 ` [PATCH pve-manager v7 21/21] sdn: add dry-run diff view for sdn apply Gabriel Goller
2026-03-26 13:40 ` [PATCH manager/network/proxmox{-ve-rs,-perl-rs} v7 00/21] Generate frr config using jinja templates and rust types Wolfgang Bumiller
2026-03-27  1:11 ` Thomas Lamprecht
2026-03-27 10:06   ` Gabriel Goller

Reply instructions:

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

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

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

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

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

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

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is 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