all lists on 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 v3 3/9] ve-config: add per-vnet IPv6 RA configuration
Date: Tue, 23 Jun 2026 14:56:20 +0200	[thread overview]
Message-ID: <20260623125626.1195681-4-h.laimer@proxmox.com> (raw)
In-Reply-To: <20260623125626.1195681-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>
---

Notes:
    v3:
     - clamp the default preferred lifetime to a shorter valid lifetime
     - boolean fields use Option<bool> with deserialize_bool
     - autonomous defaults by mask, on for /64
     - add a non-/64 default test

 proxmox-ve-config/src/sdn/config.rs           | 146 +++++++++++++++-
 proxmox-ve-config/src/sdn/mod.rs              |   1 +
 proxmox-ve-config/src/sdn/nd.rs               | 131 ++++++++++++++
 proxmox-ve-config/tests/nd/main.rs            | 164 ++++++++++++++++++
 .../nd__explicit_lifetimes_are_preserved.snap |   9 +
 .../nd__mixed_subnets_under_one_vnet.snap     |  14 ++
 .../snapshots/nd__no_autoconfig_prefix.snap   |   9 +
 ...sh64_prefix_defaults_to_no_autoconfig.snap |   9 +
 .../nd__off_link_emits_off_link_modifier.snap |   9 +
 ...__preferred_lifetime_clamped_to_valid.snap |   9 +
 ...vel_optional_knobs_are_passed_through.snap |  12 ++
 .../nd__slaac_with_default_lifetimes.snap     |   9 +
 12 files changed, 515 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__non_slash64_prefix_defaults_to_no_autoconfig.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__preferred_lifetime_clamped_to_valid.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 2f30cf2..67b0316 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,
         ipset::{IpsetEntry, IpsetName, IpsetScope},
     },
-    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,33 @@ 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(
+        default,
+        rename = "nd-prefix-autonomous",
+        deserialize_with = "proxmox_serde::perl::deserialize_bool"
+    )]
+    nd_prefix_autonomous: Option<bool>,
+    #[serde(
+        default,
+        rename = "nd-prefix-on-link",
+        deserialize_with = "proxmox_serde::perl::deserialize_bool"
+    )]
+    nd_prefix_on_link: Option<bool>,
+    #[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 +226,67 @@ 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(
+        default,
+        rename = "ipv6-ra",
+        deserialize_with = "proxmox_serde::perl::deserialize_bool"
+    )]
+    ipv6_ra: Option<bool>,
+    #[serde(
+        default,
+        rename = "ipv6-ra-managed",
+        deserialize_with = "proxmox_serde::perl::deserialize_bool"
+    )]
+    ipv6_ra_managed: Option<bool>,
+    #[serde(
+        default,
+        rename = "ipv6-ra-other",
+        deserialize_with = "proxmox_serde::perl::deserialize_bool"
+    )]
+    ipv6_ra_other: Option<bool>,
+    #[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(false) {
+            return None;
+        }
+        Some(Ipv6RaConfig {
+            managed: self.ipv6_ra_managed.unwrap_or(false),
+            other: self.ipv6_ra_other.unwrap_or(false),
+            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 +312,7 @@ pub struct SubnetConfig {
     gateway: Option<IpAddr>,
     snat: bool,
     dhcp_range: Vec<IpRange>,
+    nd_prefix: NdPrefixConfig,
 }
 
 impl SubnetConfig {
@@ -242,11 +332,19 @@ impl SubnetConfig {
             }
         }
 
+        // SLAAC requires a /64 prefix, so the autonomous flag only defaults to set there.
+        // The subnet schema rejects explicitly enabling it on other prefix lengths.
+        let nd_prefix = NdPrefixConfig {
+            autonomous: matches!(name.cidr(), Cidr::Ipv6(cidr) if cidr.mask() == 64),
+            ..NdPrefixConfig::default()
+        };
+
         Ok(Self {
             name,
             gateway,
             snat,
             dhcp_range: dhcp_range.into_iter().collect(),
+            nd_prefix,
         })
     }
 
@@ -269,7 +367,19 @@ impl SubnetConfig {
             None => Vec::new(),
         };
 
-        Self::new(name, running_config.gateway, snat, dhcp_range)
+        let mut config = Self::new(name, running_config.gateway, snat, dhcp_range)?;
+        let nd_default = config.nd_prefix().clone();
+        config.set_nd_prefix(NdPrefixConfig {
+            autonomous: running_config
+                .nd_prefix_autonomous
+                .unwrap_or(nd_default.autonomous),
+            on_link: running_config
+                .nd_prefix_on_link
+                .unwrap_or(nd_default.on_link),
+            valid_lifetime: running_config.nd_prefix_valid_lifetime,
+            preferred_lifetime: running_config.nd_prefix_preferred_lifetime,
+        });
+        Ok(config)
     }
 
     pub fn name(&self) -> &SubnetName {
@@ -291,6 +401,17 @@ impl SubnetConfig {
     pub fn dhcp_ranges(&self) -> impl Iterator<Item = &IpRange> + '_ {
         self.dhcp_range.iter()
     }
+
+    /// Per-prefix Router Advertisement overrides. If no explicit overrides were
+    /// configured, this defaults to "include the prefix in the RA with the on-link flag
+    /// set, and the autonomous flag set iff the prefix is a /64" (SLAAC requires /64).
+    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 +419,7 @@ pub struct VnetConfig {
     name: VnetName,
     tag: Option<u32>,
     subnets: BTreeMap<Cidr, SubnetConfig>,
+    ipv6_ra: Option<Ipv6RaConfig>,
 }
 
 impl VnetConfig {
@@ -306,6 +428,7 @@ impl VnetConfig {
             name,
             subnets: BTreeMap::default(),
             tag,
+            ipv6_ra: None,
         }
     }
 
@@ -360,6 +483,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 +767,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..c1b7434
--- /dev/null
+++ b/proxmox-ve-config/src/sdn/nd.rs
@@ -0,0 +1,131 @@
+//! 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.
+///
+/// [`Default`] matches the typical SLAAC use case: autonomous and on-link flags set,
+/// FRR's default lifetimes. Note that
+/// [`SubnetConfig::new`](crate::sdn::config::SubnetConfig::new) only applies the
+/// autonomous default to /64 prefixes, since SLAAC requires a /64.
+#[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};
+    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 valid = nd.valid_lifetime.unwrap_or(DEFAULT_PREFIX_VALID_LIFETIME);
+            // RFC 4861 requires preferred <= valid (FRR rejects the prefix otherwise), so
+            // clamp the default when only a shorter valid lifetime is configured. Explicit
+            // invalid combinations are already rejected by the subnet schema hook.
+            let preferred = nd
+                .preferred_lifetime
+                .unwrap_or(DEFAULT_PREFIX_PREFERRED_LIFETIME)
+                .min(valid);
+
+            prefixes.push(NdPrefix {
+                cidr,
+                autonomous: nd.autonomous,
+                on_link: nd.on_link,
+                valid,
+                preferred,
+            });
+        }
+
+        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..6d52ebd
--- /dev/null
+++ b/proxmox-ve-config/tests/nd/main.rs
@@ -0,0 +1,164 @@
+#![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));
+}
+
+#[test]
+fn preferred_lifetime_clamped_to_valid() {
+    let v = vnet("vrnet800", Some(Ipv6RaConfig::default()));
+    let s = [subnet(
+        "zone",
+        "fd00:1::/64",
+        NdPrefixConfig {
+            valid_lifetime: Some(3600),
+            ..Default::default()
+        },
+    )];
+    let iface = build_nd_interface(&v, &s).expect("ra enabled");
+    insta::assert_snapshot!(render("vrnet800", iface));
+}
+
+#[test]
+fn non_slash64_prefix_defaults_to_no_autoconfig() {
+    let v = vnet("vrnet900", Some(Ipv6RaConfig::default()));
+    let cidr: Cidr = "fd00:1::/96".parse().unwrap();
+    let zone = ZoneName::new("zone".to_owned()).unwrap();
+    let name = SubnetName::new(zone, cidr);
+    // no explicit NdPrefixConfig: the constructor default applies (autonomous iff /64)
+    let s = [SubnetConfig::new(name, None, false, std::iter::empty()).unwrap()];
+    let iface = build_nd_interface(&v, &s).expect("ra enabled");
+    insta::assert_snapshot!(render("vrnet900", 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..bf50e30
--- /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 2592000 604800 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..b8767eb
--- /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 2592000 604800 no-autoconfig
+exit
diff --git a/proxmox-ve-config/tests/nd/snapshots/nd__non_slash64_prefix_defaults_to_no_autoconfig.snap b/proxmox-ve-config/tests/nd/snapshots/nd__non_slash64_prefix_defaults_to_no_autoconfig.snap
new file mode 100644
index 0000000..57b12f8
--- /dev/null
+++ b/proxmox-ve-config/tests/nd/snapshots/nd__non_slash64_prefix_defaults_to_no_autoconfig.snap
@@ -0,0 +1,9 @@
+---
+source: proxmox-ve-config/tests/nd/main.rs
+expression: "render(\"vrnet900\", iface)"
+---
+!
+interface vrnet900
+ no ipv6 nd suppress-ra
+ ipv6 nd prefix fd00:1::/96 2592000 604800 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..a15f2ad
--- /dev/null
+++ b/proxmox-ve-config/tests/nd/snapshots/nd__off_link_emits_off_link_modifier.snap
@@ -0,0 +1,9 @@
+---
+source: proxmox-ve-config/tests/nd/main.rs
+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__preferred_lifetime_clamped_to_valid.snap b/proxmox-ve-config/tests/nd/snapshots/nd__preferred_lifetime_clamped_to_valid.snap
new file mode 100644
index 0000000..f7d34b9
--- /dev/null
+++ b/proxmox-ve-config/tests/nd/snapshots/nd__preferred_lifetime_clamped_to_valid.snap
@@ -0,0 +1,9 @@
+---
+source: proxmox-ve-config/tests/nd/main.rs
+expression: "render(\"vrnet800\", iface)"
+---
+!
+interface vrnet800
+ no ipv6 nd suppress-ra
+ ipv6 nd prefix fd00:1::/64 3600 3600
+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..44b72b2
--- /dev/null
+++ b/proxmox-ve-config/tests/nd/snapshots/nd__ra_level_optional_knobs_are_passed_through.snap
@@ -0,0 +1,12 @@
+---
+source: proxmox-ve-config/tests/nd/main.rs
+expression: "render(\"vrnet700\", iface)"
+---
+!
+interface vrnet700
+ no ipv6 nd suppress-ra
+ ipv6 nd ra-interval 60
+ ipv6 nd ra-lifetime 0
+ 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-06-23 12:58 UTC|newest]

Thread overview: 10+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-06-23 12:56 [PATCH docs/manager/network/proxmox{-perl-rs,-ve-rs} v3 0/9] add IPv6 RA / SLAAC support to EVPN zones Hannes Laimer
2026-06-23 12:56 ` [PATCH proxmox-perl-rs v3 1/9] pve-rs: sdn: add IPv6 RA builder binding Hannes Laimer
2026-06-23 12:56 ` [PATCH proxmox-ve-rs v3 2/9] frr: add IPv6 router advertisement support Hannes Laimer
2026-06-23 12:56 ` Hannes Laimer [this message]
2026-06-23 12:56 ` [PATCH pve-manager v3 4/9] ui: sdn: add IPv6 RA / SLAAC support Hannes Laimer
2026-06-23 12:56 ` [PATCH pve-network v3 5/9] sdn: evpn: " Hannes Laimer
2026-06-23 12:56 ` [PATCH pve-network v3 6/9] sdn: evpn: derive IP version from CIDR for gateway-less subnets Hannes Laimer
2026-06-23 12:56 ` [PATCH pve-network v3 7/9] sdn: evpn: accept untracked IPv6 NA on EVPN vnet bridges Hannes Laimer
2026-06-23 12:56 ` [PATCH pve-network v3 8/9] api: vnet: include zone-type in vnet list Hannes Laimer
2026-06-23 12:56 ` [PATCH pve-docs v3 9/9] sdn: add IPv6 RA / SLAAC section 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=20260623125626.1195681-4-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 an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal