public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: Hannes Laimer <h.laimer@proxmox.com>
To: pve-devel@lists.proxmox.com
Subject: [PATCH proxmox-ve-rs v2 02/11] ve-config: add per-vnet IPv6 RA configuration
Date: Thu, 30 Apr 2026 16:29:44 +0200	[thread overview]
Message-ID: <20260430142953.315412-3-h.laimer@proxmox.com> (raw)
In-Reply-To: <20260430142953.315412-1-h.laimer@proxmox.com>

Wire IPv6 Router Advertisement and per-prefix settings into the
running-config types so the typed FRR pipeline can consume them.
Per-RA settings live on the vnet, per-prefix overrides on each
subnet, matching where each constraint applies in the protocol.

Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
 proxmox-ve-config/src/sdn/config.rs           | 122 +++++++++++++++-
 proxmox-ve-config/src/sdn/mod.rs              |   1 +
 proxmox-ve-config/src/sdn/nd.rs               | 129 +++++++++++++++++
 proxmox-ve-config/tests/nd/main.rs            | 137 ++++++++++++++++++
 .../nd__explicit_lifetimes_are_preserved.snap |   9 ++
 .../nd__mixed_subnets_under_one_vnet.snap     |  14 ++
 .../snapshots/nd__no_autoconfig_prefix.snap   |   9 ++
 .../nd__off_link_emits_off_link_modifier.snap |  10 ++
 ...vel_optional_knobs_are_passed_through.snap |  13 ++
 .../nd__slaac_with_default_lifetimes.snap     |   9 ++
 10 files changed, 446 insertions(+), 7 deletions(-)
 create mode 100644 proxmox-ve-config/src/sdn/nd.rs
 create mode 100644 proxmox-ve-config/tests/nd/main.rs
 create mode 100644 proxmox-ve-config/tests/nd/snapshots/nd__explicit_lifetimes_are_preserved.snap
 create mode 100644 proxmox-ve-config/tests/nd/snapshots/nd__mixed_subnets_under_one_vnet.snap
 create mode 100644 proxmox-ve-config/tests/nd/snapshots/nd__no_autoconfig_prefix.snap
 create mode 100644 proxmox-ve-config/tests/nd/snapshots/nd__off_link_emits_off_link_modifier.snap
 create mode 100644 proxmox-ve-config/tests/nd/snapshots/nd__ra_level_optional_knobs_are_passed_through.snap
 create mode 100644 proxmox-ve-config/tests/nd/snapshots/nd__slaac_with_default_lifetimes.snap

diff --git a/proxmox-ve-config/src/sdn/config.rs b/proxmox-ve-config/src/sdn/config.rs
index afc5175..38e6e43 100644
--- a/proxmox-ve-config/src/sdn/config.rs
+++ b/proxmox-ve-config/src/sdn/config.rs
@@ -2,7 +2,7 @@ use std::{
     collections::{BTreeMap, HashMap},
     error::Error,
     fmt::Display,
-    net::IpAddr,
+    net::{IpAddr, Ipv6Addr},
     str::FromStr,
 };
 
@@ -16,7 +16,10 @@ use crate::{
         ipset::{IpsetEntry, IpsetName, IpsetScope},
         Ipset,
     },
-    sdn::{SdnNameError, SubnetName, VnetName, ZoneName},
+    sdn::{
+        nd::{Ipv6RaConfig, NdPrefixConfig},
+        SdnNameError, SubnetName, VnetName, ZoneName,
+    },
 };
 
 #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
@@ -185,6 +188,25 @@ pub struct SubnetRunningConfig {
     snat: Option<u8>,
     #[serde(rename = "dhcp-range")]
     dhcp_range: Option<Vec<PropertyString<DhcpRange>>>,
+
+    // Per-prefix RA / SLAAC overrides. Only meaningful for IPv6 subnets whose vnet has
+    // RA enabled, silently ignored otherwise.
+    #[serde(rename = "nd-prefix-autonomous")]
+    nd_prefix_autonomous: Option<u8>,
+    #[serde(rename = "nd-prefix-on-link")]
+    nd_prefix_on_link: Option<u8>,
+    #[serde(
+        default,
+        rename = "nd-prefix-valid-lifetime",
+        deserialize_with = "proxmox_serde::perl::deserialize_u32"
+    )]
+    nd_prefix_valid_lifetime: Option<u32>,
+    #[serde(
+        default,
+        rename = "nd-prefix-preferred-lifetime",
+        deserialize_with = "proxmox_serde::perl::deserialize_u32"
+    )]
+    nd_prefix_preferred_lifetime: Option<u32>,
 }
 
 /// Struct for deserializing the subnets of the SDN running config
@@ -196,8 +218,55 @@ pub struct SubnetsRunningConfig {
 /// Struct for deserializing a vnet entry of the SDN running config
 #[derive(Clone, Debug, Deserialize, PartialEq, Eq, Hash, PartialOrd, Ord)]
 pub struct VnetRunningConfig {
+    #[serde(default, deserialize_with = "proxmox_serde::perl::deserialize_u32")]
     tag: Option<u32>,
     zone: ZoneName,
+
+    // Per-vnet IPv6 RA settings. `ipv6-ra` is the master toggle, the rest are only
+    // meaningful when it is enabled.
+    #[serde(rename = "ipv6-ra")]
+    ipv6_ra: Option<u8>,
+    #[serde(rename = "ipv6-ra-managed")]
+    ipv6_ra_managed: Option<u8>,
+    #[serde(rename = "ipv6-ra-other")]
+    ipv6_ra_other: Option<u8>,
+    #[serde(rename = "ipv6-ra-rdnss")]
+    ipv6_ra_rdnss: Option<Vec<Ipv6Addr>>,
+    #[serde(
+        default,
+        rename = "ipv6-ra-router-lifetime",
+        deserialize_with = "proxmox_serde::perl::deserialize_u32"
+    )]
+    ipv6_ra_router_lifetime: Option<u32>,
+    #[serde(
+        default,
+        rename = "ipv6-ra-interval",
+        deserialize_with = "proxmox_serde::perl::deserialize_u32"
+    )]
+    ipv6_ra_interval: Option<u32>,
+    #[serde(
+        default,
+        rename = "ipv6-ra-mtu",
+        deserialize_with = "proxmox_serde::perl::deserialize_u32"
+    )]
+    ipv6_ra_mtu: Option<u32>,
+}
+
+impl VnetRunningConfig {
+    /// Materialize the IPv6 RA settings if enabled on this vnet, otherwise return `None`.
+    pub fn ipv6_ra_config(&self) -> Option<Ipv6RaConfig> {
+        if self.ipv6_ra.unwrap_or(0) == 0 {
+            return None;
+        }
+        Some(Ipv6RaConfig {
+            managed: self.ipv6_ra_managed.unwrap_or(0) != 0,
+            other: self.ipv6_ra_other.unwrap_or(0) != 0,
+            rdnss: self.ipv6_ra_rdnss.clone().unwrap_or_default(),
+            router_lifetime: self.ipv6_ra_router_lifetime,
+            interval: self.ipv6_ra_interval,
+            mtu: self.ipv6_ra_mtu,
+        })
+    }
 }
 
 /// struct for deserializing the vnets of the SDN running config
@@ -223,6 +292,7 @@ pub struct SubnetConfig {
     gateway: Option<IpAddr>,
     snat: bool,
     dhcp_range: Vec<IpRange>,
+    nd_prefix: NdPrefixConfig,
 }
 
 impl SubnetConfig {
@@ -247,6 +317,7 @@ impl SubnetConfig {
             gateway,
             snat,
             dhcp_range: dhcp_range.into_iter().collect(),
+            nd_prefix: NdPrefixConfig::default(),
         })
     }
 
@@ -269,7 +340,23 @@ impl SubnetConfig {
             None => Vec::new(),
         };
 
-        Self::new(name, running_config.gateway, snat, dhcp_range)
+        let nd_default = NdPrefixConfig::default();
+        let nd_prefix = NdPrefixConfig {
+            autonomous: running_config
+                .nd_prefix_autonomous
+                .map(|v| v != 0)
+                .unwrap_or(nd_default.autonomous),
+            on_link: running_config
+                .nd_prefix_on_link
+                .map(|v| v != 0)
+                .unwrap_or(nd_default.on_link),
+            valid_lifetime: running_config.nd_prefix_valid_lifetime,
+            preferred_lifetime: running_config.nd_prefix_preferred_lifetime,
+        };
+
+        let mut config = Self::new(name, running_config.gateway, snat, dhcp_range)?;
+        config.nd_prefix = nd_prefix;
+        Ok(config)
     }
 
     pub fn name(&self) -> &SubnetName {
@@ -291,6 +378,16 @@ impl SubnetConfig {
     pub fn dhcp_ranges(&self) -> impl Iterator<Item = &IpRange> + '_ {
         self.dhcp_range.iter()
     }
+
+    /// Per-prefix Router Advertisement overrides. Defaults to "include the prefix in the RA
+    /// with autonomous + on-link flags set" if no explicit overrides were configured.
+    pub fn nd_prefix(&self) -> &NdPrefixConfig {
+        &self.nd_prefix
+    }
+
+    pub fn set_nd_prefix(&mut self, nd_prefix: NdPrefixConfig) {
+        self.nd_prefix = nd_prefix;
+    }
 }
 
 #[derive(Clone, Debug, Deserialize, PartialEq, Eq, Hash, PartialOrd, Ord)]
@@ -298,6 +395,7 @@ pub struct VnetConfig {
     name: VnetName,
     tag: Option<u32>,
     subnets: BTreeMap<Cidr, SubnetConfig>,
+    ipv6_ra: Option<Ipv6RaConfig>,
 }
 
 impl VnetConfig {
@@ -306,6 +404,7 @@ impl VnetConfig {
             name,
             subnets: BTreeMap::default(),
             tag,
+            ipv6_ra: None,
         }
     }
 
@@ -360,6 +459,15 @@ impl VnetConfig {
     pub fn tag(&self) -> &Option<u32> {
         &self.tag
     }
+
+    /// Per-vnet IPv6 RA settings. `None` means RAs are not emitted on this vnet's bridge.
+    pub fn ipv6_ra(&self) -> &Option<Ipv6RaConfig> {
+        &self.ipv6_ra
+    }
+
+    pub fn set_ipv6_ra(&mut self, ipv6_ra: Option<Ipv6RaConfig>) {
+        self.ipv6_ra = ipv6_ra;
+    }
 }
 
 #[derive(Clone, Debug, Deserialize, PartialEq, Eq, Hash, PartialOrd, Ord)]
@@ -635,10 +743,10 @@ impl TryFrom<RunningConfig> for SdnConfig {
 
         if let Some(running_vnets) = value.vnets.take() {
             for (name, running_config) in running_vnets.ids {
-                config.add_vnet(
-                    &running_config.zone,
-                    VnetConfig::new(name, running_config.tag),
-                )?;
+                let ipv6_ra = running_config.ipv6_ra_config();
+                let mut vnet = VnetConfig::new(name, running_config.tag);
+                vnet.set_ipv6_ra(ipv6_ra);
+                config.add_vnet(&running_config.zone, vnet)?;
             }
         }
 
diff --git a/proxmox-ve-config/src/sdn/mod.rs b/proxmox-ve-config/src/sdn/mod.rs
index 2133396..457a9ec 100644
--- a/proxmox-ve-config/src/sdn/mod.rs
+++ b/proxmox-ve-config/src/sdn/mod.rs
@@ -1,6 +1,7 @@
 pub mod config;
 pub mod fabric;
 pub mod ipam;
+pub mod nd;
 pub mod prefix_list;
 pub mod route_map;
 pub mod wireguard;
diff --git a/proxmox-ve-config/src/sdn/nd.rs b/proxmox-ve-config/src/sdn/nd.rs
new file mode 100644
index 0000000..25985d2
--- /dev/null
+++ b/proxmox-ve-config/src/sdn/nd.rs
@@ -0,0 +1,129 @@
+//! IPv6 Router Advertisement / Neighbor Discovery configuration.
+//!
+//! Hosts on an EVPN vnet can autoconfigure addresses via SLAAC when the per-node anycast
+//! gateway emits Router Advertisements. RA configuration is split across two PVE objects
+//! to match the protocol layers:
+//!
+//! * Per-vnet ([`Ipv6RaConfig`]) holds the RA-level settings (M and O flags, RDNSS list,
+//!   optional router lifetime, RA interval, advertised MTU). One per vnet.
+//! * Per-subnet ([`NdPrefixConfig`]) holds the per-prefix overrides (autonomous and
+//!   on-link flags, valid and preferred lifetimes).
+//!
+//! These are exposed as fields on [`VnetConfig`](crate::sdn::config::VnetConfig) and
+//! [`SubnetConfig`](crate::sdn::config::SubnetConfig). The FRR conversion lives in the
+//! [`frr`] submodule.
+
+use std::net::Ipv6Addr;
+
+use serde::{Deserialize, Serialize};
+
+/// Default valid lifetime (30 days) for advertised prefixes when no override is set.
+pub const DEFAULT_PREFIX_VALID_LIFETIME: u32 = 2_592_000;
+/// Default preferred lifetime (7 days) for advertised prefixes when no override is set.
+pub const DEFAULT_PREFIX_PREFERRED_LIFETIME: u32 = 604_800;
+
+/// Per-vnet IPv6 Router Advertisement configuration.
+///
+/// Presence of this struct on a [`VnetConfig`](crate::sdn::config::VnetConfig) implies the
+/// vnet has RAs enabled. Absence means RAs are suppressed for the vnet.
+#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq, Hash, PartialOrd, Ord)]
+pub struct Ipv6RaConfig {
+    pub managed: bool,
+    pub other: bool,
+    pub rdnss: Vec<Ipv6Addr>,
+    pub router_lifetime: Option<u32>,
+    pub interval: Option<u32>,
+    pub mtu: Option<u32>,
+}
+
+/// Per-subnet (per-prefix) overrides for Router Advertisements.
+///
+/// Defaults match the typical SLAAC use case: autonomous and on-link flags set, FRR's
+/// default lifetimes.
+#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Hash, PartialOrd, Ord)]
+pub struct NdPrefixConfig {
+    pub autonomous: bool,
+    pub on_link: bool,
+    pub valid_lifetime: Option<u32>,
+    pub preferred_lifetime: Option<u32>,
+}
+
+impl Default for NdPrefixConfig {
+    fn default() -> Self {
+        Self {
+            autonomous: true,
+            on_link: true,
+            valid_lifetime: None,
+            preferred_lifetime: None,
+        }
+    }
+}
+
+#[cfg(feature = "frr")]
+pub mod frr {
+    //! FRR conversion for IPv6 RA / ND configuration.
+    //!
+    //! Folds the per-vnet [`Ipv6RaConfig`] and the per-subnet [`NdPrefixConfig`] of every
+    //! IPv6 subnet under the vnet into a single
+    //! [`NdInterface`](proxmox_frr::ser::nd::NdInterface) keyed by the vnet name in
+    //! [`FrrConfig::nd_interfaces`](proxmox_frr::ser::FrrConfig::nd_interfaces).
+
+    use super::*;
+
+    use proxmox_frr::ser::nd::{NdInterface, NdPrefix, NdPrefixMode};
+    use proxmox_network_types::ip_address::Cidr;
+
+    use crate::sdn::config::{SubnetConfig, VnetConfig};
+
+    /// Build an [`NdInterface`] for the given vnet from its RA settings and per-subnet
+    /// prefix overrides.
+    ///
+    /// Returns `None` when:
+    /// * the vnet has no [`Ipv6RaConfig`] (RAs are disabled), or
+    /// * the vnet has no IPv6 subnet (no prefix to advertise).
+    pub fn build_nd_interface<'a>(
+        vnet: &'a VnetConfig,
+        subnets: impl IntoIterator<Item = &'a SubnetConfig>,
+    ) -> Option<NdInterface> {
+        let ra = vnet.ipv6_ra().as_ref()?;
+
+        let mut prefixes = Vec::new();
+        for subnet in subnets {
+            let &Cidr::Ipv6(cidr) = subnet.cidr() else {
+                continue;
+            };
+            let nd = subnet.nd_prefix();
+
+            let mode = if nd.autonomous {
+                NdPrefixMode::Autoconfig {
+                    valid: nd.valid_lifetime.unwrap_or(DEFAULT_PREFIX_VALID_LIFETIME),
+                    preferred: nd
+                        .preferred_lifetime
+                        .unwrap_or(DEFAULT_PREFIX_PREFERRED_LIFETIME),
+                }
+            } else {
+                NdPrefixMode::NoAutoconfig
+            };
+
+            prefixes.push(NdPrefix {
+                cidr,
+                on_link: nd.on_link,
+                mode,
+            });
+        }
+
+        if prefixes.is_empty() {
+            return None;
+        }
+
+        Some(NdInterface {
+            managed_config_flag: ra.managed,
+            other_config_flag: ra.other,
+            rdnss: ra.rdnss.clone(),
+            router_lifetime: ra.router_lifetime,
+            interval: ra.interval,
+            mtu: ra.mtu,
+            prefixes,
+        })
+    }
+}
diff --git a/proxmox-ve-config/tests/nd/main.rs b/proxmox-ve-config/tests/nd/main.rs
new file mode 100644
index 0000000..9eeedf8
--- /dev/null
+++ b/proxmox-ve-config/tests/nd/main.rs
@@ -0,0 +1,137 @@
+#![cfg(feature = "frr")]
+
+use proxmox_frr::ser::{serializer::dump, FrrConfig, InterfaceName};
+use proxmox_network_types::ip_address::Cidr;
+use proxmox_ve_config::sdn::{
+    config::{SubnetConfig, VnetConfig},
+    nd::{frr::build_nd_interface, Ipv6RaConfig, NdPrefixConfig},
+    SubnetName, VnetName, ZoneName,
+};
+
+fn vnet(name: &str, ra: Option<Ipv6RaConfig>) -> VnetConfig {
+    let mut v = VnetConfig::new(VnetName::new(name.to_owned()).unwrap(), Some(100));
+    v.set_ipv6_ra(ra);
+    v
+}
+
+fn subnet(zone: &str, cidr: &str, nd: NdPrefixConfig) -> SubnetConfig {
+    let cidr: Cidr = cidr.parse().unwrap();
+    let zone = ZoneName::new(zone.to_owned()).unwrap();
+    let name = SubnetName::new(zone, cidr);
+    let mut s = SubnetConfig::new(name, None, false, std::iter::empty()).unwrap();
+    s.set_nd_prefix(nd);
+    s
+}
+
+fn render(vname: &str, iface: proxmox_frr::ser::nd::NdInterface) -> String {
+    let mut config = FrrConfig::default();
+    let name: InterfaceName = vname.to_owned().try_into().unwrap();
+    config.nd_interfaces.insert(name, iface);
+    dump(&config).expect("renders")
+}
+
+#[test]
+fn slaac_with_default_lifetimes() {
+    let v = vnet("vrnet100", Some(Ipv6RaConfig::default()));
+    let s = [subnet("zone", "fd00:1::/64", NdPrefixConfig::default())];
+    let iface = build_nd_interface(&v, &s).expect("ra enabled");
+    insta::assert_snapshot!(render("vrnet100", iface));
+}
+
+#[test]
+fn no_autoconfig_prefix() {
+    let v = vnet("vrnet200", Some(Ipv6RaConfig::default()));
+    let s = [subnet(
+        "zone",
+        "fd00:2::/64",
+        NdPrefixConfig {
+            autonomous: false,
+            ..Default::default()
+        },
+    )];
+    let iface = build_nd_interface(&v, &s).expect("ra enabled");
+    insta::assert_snapshot!(render("vrnet200", iface));
+}
+
+#[test]
+fn mixed_subnets_under_one_vnet() {
+    let ra = Ipv6RaConfig {
+        managed: true,
+        other: true,
+        rdnss: vec![
+            "2001:db8::1".parse().unwrap(),
+            "2001:db8::2".parse().unwrap(),
+        ],
+        ..Default::default()
+    };
+    let v = vnet("vrnet300", Some(ra));
+    let s = [
+        // SLAAC-eligible /64
+        subnet("zone", "fd00:1::/64", NdPrefixConfig::default()),
+        // /96 announced but not autoconfig
+        subnet(
+            "zone",
+            "fd00:2::/96",
+            NdPrefixConfig {
+                autonomous: false,
+                ..Default::default()
+            },
+        ),
+        // IPv4 subnet must be skipped silently
+        subnet("zone", "10.0.0.0/24", NdPrefixConfig::default()),
+    ];
+    let iface = build_nd_interface(&v, &s).expect("ra enabled");
+    insta::assert_snapshot!(render("vrnet300", iface));
+}
+
+#[test]
+fn explicit_lifetimes_are_preserved() {
+    let v = vnet("vrnet400", Some(Ipv6RaConfig::default()));
+    let s = [subnet(
+        "zone",
+        "fd00:1::/64",
+        NdPrefixConfig {
+            valid_lifetime: Some(3600),
+            preferred_lifetime: Some(1800),
+            ..Default::default()
+        },
+    )];
+    let iface = build_nd_interface(&v, &s).expect("ra enabled");
+    insta::assert_snapshot!(render("vrnet400", iface));
+}
+
+#[test]
+fn ra_disabled_returns_none() {
+    let v = vnet("vrnet500", None);
+    let s = [subnet("zone", "fd00:1::/64", NdPrefixConfig::default())];
+    assert!(build_nd_interface(&v, &s).is_none());
+}
+
+#[test]
+fn off_link_emits_off_link_modifier() {
+    let v = vnet("vrnet600", Some(Ipv6RaConfig::default()));
+    let s = [subnet(
+        "zone",
+        "fd00:1::/64",
+        NdPrefixConfig {
+            on_link: false,
+            ..Default::default()
+        },
+    )];
+    let iface = build_nd_interface(&v, &s).expect("ra enabled");
+    insta::assert_snapshot!(render("vrnet600", iface));
+}
+
+#[test]
+fn ra_level_optional_knobs_are_passed_through() {
+    let ra = Ipv6RaConfig {
+        router_lifetime: Some(0),
+        interval: Some(60),
+        mtu: Some(1450),
+        ..Default::default()
+    };
+    let v = vnet("vrnet700", Some(ra));
+    let s = [subnet("zone", "fd00:1::/64", NdPrefixConfig::default())];
+    let iface = build_nd_interface(&v, &s).expect("ra enabled");
+    insta::assert_snapshot!(render("vrnet700", iface));
+}
diff --git a/proxmox-ve-config/tests/nd/snapshots/nd__explicit_lifetimes_are_preserved.snap b/proxmox-ve-config/tests/nd/snapshots/nd__explicit_lifetimes_are_preserved.snap
new file mode 100644
index 0000000..8abcd07
--- /dev/null
+++ b/proxmox-ve-config/tests/nd/snapshots/nd__explicit_lifetimes_are_preserved.snap
@@ -0,0 +1,9 @@
+---
+source: proxmox-ve-config/tests/nd/main.rs
+expression: "render(\"vrnet400\", iface)"
+---
+!
+interface vrnet400
+ no ipv6 nd suppress-ra
+ ipv6 nd prefix fd00:1::/64 3600 1800
+exit
diff --git a/proxmox-ve-config/tests/nd/snapshots/nd__mixed_subnets_under_one_vnet.snap b/proxmox-ve-config/tests/nd/snapshots/nd__mixed_subnets_under_one_vnet.snap
new file mode 100644
index 0000000..2e86e71
--- /dev/null
+++ b/proxmox-ve-config/tests/nd/snapshots/nd__mixed_subnets_under_one_vnet.snap
@@ -0,0 +1,14 @@
+---
+source: proxmox-ve-config/tests/nd/main.rs
+expression: "render(\"vrnet300\", iface)"
+---
+!
+interface vrnet300
+ no ipv6 nd suppress-ra
+ ipv6 nd managed-config-flag
+ ipv6 nd other-config-flag
+ ipv6 nd rdnss 2001:db8::1
+ ipv6 nd rdnss 2001:db8::2
+ ipv6 nd prefix fd00:1::/64 2592000 604800
+ ipv6 nd prefix fd00:2::/96 no-autoconfig
+exit
diff --git a/proxmox-ve-config/tests/nd/snapshots/nd__no_autoconfig_prefix.snap b/proxmox-ve-config/tests/nd/snapshots/nd__no_autoconfig_prefix.snap
new file mode 100644
index 0000000..43668db
--- /dev/null
+++ b/proxmox-ve-config/tests/nd/snapshots/nd__no_autoconfig_prefix.snap
@@ -0,0 +1,9 @@
+---
+source: proxmox-ve-config/tests/nd/main.rs
+expression: "render(\"vrnet200\", iface)"
+---
+!
+interface vrnet200
+ no ipv6 nd suppress-ra
+ ipv6 nd prefix fd00:2::/64 no-autoconfig
+exit
diff --git a/proxmox-ve-config/tests/nd/snapshots/nd__off_link_emits_off_link_modifier.snap b/proxmox-ve-config/tests/nd/snapshots/nd__off_link_emits_off_link_modifier.snap
new file mode 100644
index 0000000..86d2f09
--- /dev/null
+++ b/proxmox-ve-config/tests/nd/snapshots/nd__off_link_emits_off_link_modifier.snap
@@ -0,0 +1,10 @@
+---
+source: proxmox-ve-config/tests/nd/main.rs
+assertion_line: 131
+expression: "render(\"vrnet600\", iface)"
+---
+!
+interface vrnet600
+ no ipv6 nd suppress-ra
+ ipv6 nd prefix fd00:1::/64 2592000 604800 off-link
+exit
diff --git a/proxmox-ve-config/tests/nd/snapshots/nd__ra_level_optional_knobs_are_passed_through.snap b/proxmox-ve-config/tests/nd/snapshots/nd__ra_level_optional_knobs_are_passed_through.snap
new file mode 100644
index 0000000..cc0a689
--- /dev/null
+++ b/proxmox-ve-config/tests/nd/snapshots/nd__ra_level_optional_knobs_are_passed_through.snap
@@ -0,0 +1,13 @@
+---
+source: proxmox-ve-config/tests/nd/main.rs
+assertion_line: 145
+expression: "render(\"vrnet700\", iface)"
+---
+!
+interface vrnet700
+ no ipv6 nd suppress-ra
+ ipv6 nd ra-lifetime 0
+ ipv6 nd ra-interval 60
+ ipv6 nd mtu 1450
+ ipv6 nd prefix fd00:1::/64 2592000 604800
+exit
diff --git a/proxmox-ve-config/tests/nd/snapshots/nd__slaac_with_default_lifetimes.snap b/proxmox-ve-config/tests/nd/snapshots/nd__slaac_with_default_lifetimes.snap
new file mode 100644
index 0000000..52b2e5b
--- /dev/null
+++ b/proxmox-ve-config/tests/nd/snapshots/nd__slaac_with_default_lifetimes.snap
@@ -0,0 +1,9 @@
+---
+source: proxmox-ve-config/tests/nd/main.rs
+expression: "render(\"vrnet100\", iface)"
+---
+!
+interface vrnet100
+ no ipv6 nd suppress-ra
+ ipv6 nd prefix fd00:1::/64 2592000 604800
+exit
-- 
2.47.3





  parent reply	other threads:[~2026-04-30 14:31 UTC|newest]

Thread overview: 12+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-04-30 14:29 [PATCH docs/manager/network/proxmox{-ve-rs,-perl-rs} v2 00/11] sdn: evpn: add IPv6 RA / SLAAC support Hannes Laimer
2026-04-30 14:29 ` [PATCH proxmox-ve-rs v2 01/11] frr: add IPv6 router advertisement support Hannes Laimer
2026-04-30 14:29 ` Hannes Laimer [this message]
2026-04-30 14:29 ` [PATCH proxmox-perl-rs v2 03/11] pve-rs: sdn: add IPv6 RA builder binding Hannes Laimer
2026-04-30 14:29 ` [PATCH pve-network v2 04/11] sdn: evpn: add IPv6 RA / SLAAC support Hannes Laimer
2026-04-30 14:29 ` [PATCH pve-network v2 05/11] sdn: evpn: derive IP version from CIDR for gateway-less subnets Hannes Laimer
2026-04-30 14:29 ` [PATCH pve-network v2 06/11] sdn: evpn: accept untracked IPv6 NA on EVPN vnet bridges Hannes Laimer
2026-04-30 14:29 ` [PATCH pve-network v2 07/11] api: vnet: include zone-type in vnet list Hannes Laimer
2026-04-30 14:29 ` [PATCH pve-manager v2 08/11] ui: sdn: disable SNAT for IPv6 subnets Hannes Laimer
2026-04-30 14:29 ` [PATCH pve-manager v2 09/11] ui: sdn: add IPv6 RA / SLAAC support Hannes Laimer
2026-04-30 14:29 ` [PATCH pve-docs v2 10/11] sdn: document IPv6 RA / SLAAC configuration Hannes Laimer
2026-04-30 14:29 ` [PATCH pve-docs v2 11/11] sdn: add example for IPv6 in an EVPN zone Hannes Laimer

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=20260430142953.315412-3-h.laimer@proxmox.com \
    --to=h.laimer@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