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 v4 3/9] ve-config: sdn: add IPv6 router advertisement settings
Date: Thu, 29 Oct 2026 15:44:02 +0100	[thread overview]
Message-ID: <20261029144408.2646288-4-h.laimer@proxmox.com> (raw)
In-Reply-To: <20261029144408.2646288-1-h.laimer@proxmox.com>

Guests can only configure IPv6 addresses with SLAAC when their
gateway sends router advertisements. Parsing the running config now
picks up the RA settings of a vnet and the prefix settings of its
subnets. The FRR ND config of a vnet's interface is built from the
vnet and its subnets, with one prefix per IPv6 subnet. A vnet without
RAs enabled or without an IPv6 subnet gets none.

An RA goes out on the vnet's interface and carries one prefix option
per subnet. So the vnet holds the RA settings and each subnet those
of its prefix. Prefixes are on-link by default. They are autonomous
by default only as a /64, since SLAAC needs one.

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 2f30cf2bd77c..83a5fc42051c 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::{
+        SdnNameError, SubnetName, VnetName, ZoneName,
+        nd::{Ipv6RaConfig, NdPrefixConfig},
+    },
 };
 
 #[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 2133396145bd..457a9ec3af5f 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 000000000000..c1b743428979
--- /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 000000000000..b875e10a9b3b
--- /dev/null
+++ b/proxmox-ve-config/tests/nd/main.rs
@@ -0,0 +1,164 @@
+#![cfg(feature = "frr")]
+
+use proxmox_frr::ser::{FrrConfig, InterfaceName, serializer::dump};
+use proxmox_network_types::ip_address::Cidr;
+use proxmox_ve_config::sdn::{
+    SubnetName, VnetName, ZoneName,
+    config::{SubnetConfig, VnetConfig},
+    nd::{Ipv6RaConfig, NdPrefixConfig, frr::build_nd_interface},
+};
+
+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 000000000000..8abcd0760bae
--- /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 000000000000..bf50e301f353
--- /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 000000000000..b8767ebfd671
--- /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 000000000000..57b12f88311d
--- /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 000000000000..a15f2adb1f94
--- /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 000000000000..f7d34b9457ce
--- /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 000000000000..44b72b283944
--- /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 000000000000..52b2e5b5fb46
--- /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-10-29 14:45 UTC|newest]

Thread overview: 11+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-10-29 14:43 [PATCH docs/manager/network/proxmox{-perl-rs,-ve-rs} v4 0/9] add IPv6 RA / SLAAC support to EVPN zones Hannes Laimer
2026-10-29 14:44 ` [PATCH proxmox-perl-rs v4 1/9] pve-rs: sdn: add IPv6 RA builder binding Hannes Laimer
2026-10-29 14:44 ` [PATCH proxmox-ve-rs v4 2/9] frr: add IPv6 router advertisement support Hannes Laimer
2026-10-29 14:44 ` Hannes Laimer [this message]
2026-10-29 14:44 ` [PATCH pve-manager v4 4/9] ui: sdn: add IPv6 RA / SLAAC support Hannes Laimer
2026-10-29 14:44 ` [PATCH pve-network v4 5/9] sdn: evpn: derive IP version from CIDR for gateway-less subnets Hannes Laimer
2026-10-29 14:44 ` [PATCH pve-network v4 6/9] sdn: evpn: accept untracked IPv6 neighbor advertisements Hannes Laimer
2026-10-29 14:44 ` [PATCH pve-network v4 7/9] sdn: evpn: add IPv6 RA / SLAAC support Hannes Laimer
2026-10-29 14:44 ` [PATCH pve-network v4 8/9] api: vnets: include the zone type in the vnet list Hannes Laimer
2026-10-29 14:44 ` [PATCH pve-docs v4 9/9] sdn: add IPv6 RA / SLAAC section Hannes Laimer
2026-10-29 15:42 ` superseded: [PATCH docs/manager/network/proxmox{-perl-rs,-ve-rs} v4 0/9] add IPv6 RA / SLAAC support to EVPN zones 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=20261029144408.2646288-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 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