public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
* [PATCH docs/manager/network/proxmox{-perl-rs,-ve-rs} v3 0/9] add IPv6 RA / SLAAC support to EVPN zones
@ 2026-06-23 12:56 Hannes Laimer
  2026-06-23 12:56 ` [PATCH proxmox-perl-rs v3 1/9] pve-rs: sdn: add IPv6 RA builder binding Hannes Laimer
                   ` (8 more replies)
  0 siblings, 9 replies; 10+ messages in thread
From: Hannes Laimer @ 2026-06-23 12:56 UTC (permalink / raw)
  To: pve-devel

This series adds IPv6 Router Advertisement / SLAAC support to EVPN zones.

RA settings sit on the vnet (`vnets.cfg`), per-prefix overrides on the subnet
(`subnets.cfg`). Defaults are chosen so that most IPv6 subnets work without
explicit per-prefix configuration.

It also enables `accept_untracked_na=2` on every EVPN vnet bridge that has an
IPv6 subnet, to keep first-packet IPv6 return traffic reliable in the
distributed anycast-gateway topology. The dedicated commit carries the full
rationale.

v3, thanks @Lukas S!:
 - validation no longer rejects valid IPv6 subnets: non-/64 prefixes and IPv6
   subnets using SNAT are accepted and editable again
 - improve FRR config generation so a single malformed subnet or a non-member
   node can no longer break it, and so configured prefix lifetimes are no
   longer silently dropped
 - cleaner EVPN gating and better per-prefix defaults, so most IPv6 subnets
   work without explicit configuration
 - cleanup UI, only persists meaningful settings
 - some smaller fixups


proxmox-perl-rs:

Hannes Laimer (1):
  pve-rs: sdn: add IPv6 RA builder binding

 pve-rs/Makefile                    |  1 +
 pve-rs/src/bindings/sdn/ipv6_ra.rs | 50 ++++++++++++++++++++++++++++++
 pve-rs/src/bindings/sdn/mod.rs     |  1 +
 3 files changed, 52 insertions(+)
 create mode 100644 pve-rs/src/bindings/sdn/ipv6_ra.rs


proxmox-ve-rs:

Hannes Laimer (2):
  frr: add IPv6 router advertisement support
  ve-config: add per-vnet IPv6 RA configuration

 .../templates/frr.conf.jinja                  |   1 +
 .../templates/nd_interfaces.jinja             |  28 +++
 proxmox-frr/src/ser/mod.rs                    |   6 +
 proxmox-frr/src/ser/nd.rs                     |  75 ++++++++
 proxmox-frr/src/ser/serializer.rs             |   6 +-
 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 +
 17 files changed, 630 insertions(+), 8 deletions(-)
 create mode 100644 proxmox-frr-templates/templates/nd_interfaces.jinja
 create mode 100644 proxmox-frr/src/ser/nd.rs
 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


pve-manager:

Hannes Laimer (1):
  ui: sdn: add IPv6 RA / SLAAC support

 www/manager6/form/SDNVnetSelector.js |   2 +-
 www/manager6/sdn/SubnetEdit.js       | 130 ++++++++++++++++-
 www/manager6/sdn/SubnetView.js       |   7 +-
 www/manager6/sdn/VnetEdit.js         | 202 ++++++++++++++++++++++++++-
 www/manager6/sdn/VnetView.js         |   6 +-
 5 files changed, 339 insertions(+), 8 deletions(-)


pve-network:

Hannes Laimer (4):
  sdn: evpn: add IPv6 RA / SLAAC support
  sdn: evpn: derive IP version from CIDR for gateway-less subnets
  sdn: evpn: accept untracked IPv6 NA on EVPN vnet bridges
  api: vnet: include zone-type in vnet list

 src/PVE/API2/Network/SDN/Vnets.pm             | 13 +++
 src/PVE/Network/SDN.pm                        |  1 +
 src/PVE/Network/SDN/Controllers.pm            | 21 ++++-
 src/PVE/Network/SDN/Controllers/EvpnPlugin.pm | 22 ++++-
 src/PVE/Network/SDN/Controllers/Plugin.pm     |  2 +-
 src/PVE/Network/SDN/SubnetPlugin.pm           | 68 +++++++++++++++
 src/PVE/Network/SDN/VnetPlugin.pm             | 85 +++++++++++++++++++
 src/PVE/Network/SDN/Zones/EvpnPlugin.pm       | 24 ++++--
 src/PVE/Network/SDN/Zones/Plugin.pm           |  6 ++
 .../expected_sdn_interfaces                   |  1 +
 .../exitnode_snat/expected_sdn_interfaces     |  1 +
 .../exitnodenullroute/expected_sdn_interfaces |  1 +
 .../evpn/ipv4ipv6/expected_sdn_interfaces     |  1 +
 .../ipv4ipv6nogateway/expected_sdn_interfaces |  1 +
 .../zones/evpn/ipv6/expected_sdn_interfaces   |  1 +
 .../evpn/ipv6underlay/expected_sdn_interfaces |  1 +
 .../evpn/slaac/expected_controller_config     | 53 ++++++++++++
 .../zones/evpn/slaac/expected_sdn_interfaces  | 44 ++++++++++
 src/test/zones/evpn/slaac/interfaces          |  7 ++
 src/test/zones/evpn/slaac/sdn_config          | 41 +++++++++
 20 files changed, 384 insertions(+), 10 deletions(-)
 create mode 100644 src/test/zones/evpn/slaac/expected_controller_config
 create mode 100644 src/test/zones/evpn/slaac/expected_sdn_interfaces
 create mode 100644 src/test/zones/evpn/slaac/interfaces
 create mode 100644 src/test/zones/evpn/slaac/sdn_config


pve-docs:

Hannes Laimer (1):
  sdn: add IPv6 RA / SLAAC section

 pvesdn.adoc | 68 +++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 68 insertions(+)


Summary over all repositories:
  46 files changed, 1473 insertions(+), 26 deletions(-)

-- 
Generated by murpp 0.12.0




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

* [PATCH proxmox-perl-rs v3 1/9] pve-rs: sdn: add IPv6 RA builder binding
  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 ` Hannes Laimer
  2026-06-23 12:56 ` [PATCH proxmox-ve-rs v3 2/9] frr: add IPv6 router advertisement support Hannes Laimer
                   ` (7 subsequent siblings)
  8 siblings, 0 replies; 10+ messages in thread
From: Hannes Laimer @ 2026-06-23 12:56 UTC (permalink / raw)
  To: pve-devel

Expose the typed FRR pipeline for IPv6 Router Advertisements to perl,
so the EVPN controller plugin can fold a vnet's RA settings and its
subnets' per-prefix overrides into the FRR config it assembles.

Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
 pve-rs/Makefile                    |  1 +
 pve-rs/src/bindings/sdn/ipv6_ra.rs | 50 ++++++++++++++++++++++++++++++
 pve-rs/src/bindings/sdn/mod.rs     |  1 +
 3 files changed, 52 insertions(+)
 create mode 100644 pve-rs/src/bindings/sdn/ipv6_ra.rs

diff --git a/pve-rs/Makefile b/pve-rs/Makefile
index bb1cd2d..0dfd672 100644
--- a/pve-rs/Makefile
+++ b/pve-rs/Makefile
@@ -33,6 +33,7 @@ PERLMOD_PACKAGES := \
 	  PVE::RS::ResourceScheduling::Static \
 	  PVE::RS::ResourceScheduling::Dynamic \
 	  PVE::RS::SDN::Fabrics \
+	  PVE::RS::SDN::IPv6RA \
 	  PVE::RS::SDN::PrefixLists \
 	  PVE::RS::SDN::RouteMaps \
 	  PVE::RS::SDN::WireGuard::PrivateKeys \
diff --git a/pve-rs/src/bindings/sdn/ipv6_ra.rs b/pve-rs/src/bindings/sdn/ipv6_ra.rs
new file mode 100644
index 0000000..144a5f3
--- /dev/null
+++ b/pve-rs/src/bindings/sdn/ipv6_ra.rs
@@ -0,0 +1,50 @@
+#[perlmod::package(name = "PVE::RS::SDN::IPv6RA", lib = "pve_rs")]
+pub mod pve_rs_sdn_ipv6_ra {
+    //! The `PVE::RS::SDN::IPv6RA` package.
+    //!
+    //! Builds the per-vnet IPv6 Router Advertisement / Neighbor Discovery configuration
+    //! the FRR template renders. Per-RA settings (M / O flags, RDNSS list, optional
+    //! timing knobs) live on the vnet running config. Per-prefix settings (autonomous,
+    //! on-link, lifetimes) live on the subnet running config. The perl side passes both
+    //! as raw running-config hashes, this module converts them into the typed pipeline
+    //! and returns the rendered `NdInterface`.
+
+    use std::collections::BTreeMap;
+
+    use anyhow::{Context, Error};
+
+    use proxmox_frr::ser::nd::NdInterface;
+    use proxmox_ve_config::sdn::{
+        SubnetName, VnetName,
+        config::{SubnetConfig, SubnetRunningConfig, VnetConfig, VnetRunningConfig},
+        nd::frr::build_nd_interface,
+    };
+
+    /// Build the [`NdInterface`] for one vnet.
+    ///
+    /// Returns `None` when the vnet has no `ipv6-ra` master toggle set, or when no subnet
+    /// contributes a prefix (none are IPv6).
+    ///
+    /// `subnets` is the running-config slice for the subnets belonging to this vnet, in
+    /// the same shape the perl side already has them. The map is iterated in key order
+    /// so the emitted prefix list is stable.
+    #[export]
+    pub fn build_interface(
+        vnet_name: VnetName,
+        vnet: VnetRunningConfig,
+        subnets: BTreeMap<String, SubnetRunningConfig>,
+    ) -> Result<Option<NdInterface>, Error> {
+        let mut vnet_config = VnetConfig::new(vnet_name, None);
+        vnet_config.set_ipv6_ra(vnet.ipv6_ra_config());
+
+        let mut subnet_configs: Vec<SubnetConfig> = Vec::with_capacity(subnets.len());
+        for (id, running) in subnets {
+            let name: SubnetName = id
+                .parse()
+                .with_context(|| format!("invalid subnet id: {id}"))?;
+            subnet_configs.push(SubnetConfig::try_from_running_config(name, running)?);
+        }
+
+        Ok(build_nd_interface(&vnet_config, subnet_configs.iter()))
+    }
+}
diff --git a/pve-rs/src/bindings/sdn/mod.rs b/pve-rs/src/bindings/sdn/mod.rs
index dcae046..fb50526 100644
--- a/pve-rs/src/bindings/sdn/mod.rs
+++ b/pve-rs/src/bindings/sdn/mod.rs
@@ -1,4 +1,5 @@
 pub(crate) mod fabrics;
+pub(crate) mod ipv6_ra;
 pub(crate) mod prefix_lists;
 pub(crate) mod route_maps;
 pub(crate) mod wireguard;
-- 
2.47.3





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

* [PATCH proxmox-ve-rs v3 2/9] frr: add IPv6 router advertisement support
  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 ` Hannes Laimer
  2026-06-23 12:56 ` [PATCH proxmox-ve-rs v3 3/9] ve-config: add per-vnet IPv6 RA configuration Hannes Laimer
                   ` (6 subsequent siblings)
  8 siblings, 0 replies; 10+ messages in thread
From: Hannes Laimer @ 2026-06-23 12:56 UTC (permalink / raw)
  To: pve-devel

Add typed configuration for emitting IPv6 Router Advertisements from
FRR, alongside the existing per-protocol configs. The shape mirrors
the protocol's two layers, keeping interface-level fields separate
from per-prefix flags so neither overloads the other.

Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---

Notes:
    v3:
     - keep prefix lifetimes on no-autoconfig prefixes (were dropped)
     - emit ra-interval before ra-lifetime
     - single prefix line instead of duplicated autoconfig branches

 .../templates/frr.conf.jinja                  |  1 +
 .../templates/nd_interfaces.jinja             | 28 +++++++
 proxmox-frr/src/ser/mod.rs                    |  6 ++
 proxmox-frr/src/ser/nd.rs                     | 75 +++++++++++++++++++
 proxmox-frr/src/ser/serializer.rs             |  6 +-
 5 files changed, 115 insertions(+), 1 deletion(-)
 create mode 100644 proxmox-frr-templates/templates/nd_interfaces.jinja
 create mode 100644 proxmox-frr/src/ser/nd.rs

diff --git a/proxmox-frr-templates/templates/frr.conf.jinja b/proxmox-frr-templates/templates/frr.conf.jinja
index 1f98489..8b07088 100644
--- a/proxmox-frr-templates/templates/frr.conf.jinja
+++ b/proxmox-frr-templates/templates/frr.conf.jinja
@@ -10,3 +10,4 @@
 {% include "route_maps.jinja" %}
 {% include "ip_routes.jinja" %}
 {% include "protocol_routemaps.jinja" %}
+{% include "nd_interfaces.jinja" %}
diff --git a/proxmox-frr-templates/templates/nd_interfaces.jinja b/proxmox-frr-templates/templates/nd_interfaces.jinja
new file mode 100644
index 0000000..dd047a9
--- /dev/null
+++ b/proxmox-frr-templates/templates/nd_interfaces.jinja
@@ -0,0 +1,28 @@
+{% for name, iface in nd_interfaces|items %}
+!
+interface {{ name }}
+ no ipv6 nd suppress-ra
+{% if iface.managed_config_flag %}
+ ipv6 nd managed-config-flag
+{% endif %}
+{% if iface.other_config_flag %}
+ ipv6 nd other-config-flag
+{% endif %}
+{% if iface.interval is not none %}
+ ipv6 nd ra-interval {{ iface.interval }}
+{% endif %}
+{% if iface.router_lifetime is not none %}
+ ipv6 nd ra-lifetime {{ iface.router_lifetime }}
+{% endif %}
+{% if iface.mtu is not none %}
+ ipv6 nd mtu {{ iface.mtu }}
+{% endif %}
+{% for rdnss in iface.rdnss %}
+ ipv6 nd rdnss {{ rdnss }}
+{% endfor %}
+{% for prefix in iface.prefixes %}
+ ipv6 nd prefix {{ prefix.cidr }} {{ prefix.valid }} {{ prefix.preferred }}{% if not prefix.autonomous %} no-autoconfig{% endif %}{% if not prefix.on_link %} off-link{% endif %}
+
+{% endfor %}
+exit
+{% endfor %}
diff --git a/proxmox-frr/src/ser/mod.rs b/proxmox-frr/src/ser/mod.rs
index b651121..a65ee46 100644
--- a/proxmox-frr/src/ser/mod.rs
+++ b/proxmox-frr/src/ser/mod.rs
@@ -1,5 +1,6 @@
 pub mod bgp;
 pub mod isis;
+pub mod nd;
 pub mod openfabric;
 pub mod ospf;
 pub mod route_map;
@@ -234,6 +235,11 @@ pub struct FrrConfig {
     #[serde(default)]
     pub prefix_lists: BTreeMap<PrefixListName, Vec<PrefixListRule>>,
 
+    /// `interface <name> / ipv6 nd ...` blocks emitted for subnets with Router
+    /// Advertisements enabled. Presence of an entry implies `no ipv6 nd suppress-ra`.
+    #[serde(default)]
+    pub nd_interfaces: BTreeMap<InterfaceName, nd::NdInterface>,
+
     #[serde(default)]
     pub custom_frr_config: Vec<String>,
 }
diff --git a/proxmox-frr/src/ser/nd.rs b/proxmox-frr/src/ser/nd.rs
new file mode 100644
index 0000000..d9c5545
--- /dev/null
+++ b/proxmox-frr/src/ser/nd.rs
@@ -0,0 +1,75 @@
+use std::net::Ipv6Addr;
+
+use proxmox_network_types::ip_address::Ipv6Cidr;
+use serde::{Deserialize, Serialize};
+
+fn default_true() -> bool {
+    true
+}
+
+/// A single prefix advertised in Router Advertisements on an interface.
+///
+/// The valid and preferred lifetimes are always emitted; they apply to the prefix
+/// information option independently of the autonomous flag. The caller must ensure
+/// `preferred <= valid`, FRR rejects the prefix otherwise.
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+pub struct NdPrefix {
+    pub cidr: Ipv6Cidr,
+    /// Autonomous (A) flag. Defaults to `true`. Clear to emit the prefix with the
+    /// no-autoconfig modifier so hosts use it for on-link/routing decisions but do not
+    /// derive addresses from it via SLAAC.
+    #[serde(
+        default = "default_true",
+        deserialize_with = "proxmox_serde::perl::deserialize_bool"
+    )]
+    pub autonomous: bool,
+    /// On-link (L) flag. Defaults to `true`. Clear to emit the prefix with the off-link
+    /// modifier so hosts reach addresses in the prefix only via the router rather than
+    /// directly on the link.
+    #[serde(
+        default = "default_true",
+        deserialize_with = "proxmox_serde::perl::deserialize_bool"
+    )]
+    pub on_link: bool,
+    /// Valid lifetime (seconds) of the prefix information.
+    #[serde(deserialize_with = "proxmox_serde::perl::deserialize_u32")]
+    pub valid: u32,
+    /// Preferred lifetime (seconds) of the prefix information. Must not exceed `valid`.
+    #[serde(deserialize_with = "proxmox_serde::perl::deserialize_u32")]
+    pub preferred: u32,
+}
+
+/// IPv6 Neighbor Discovery / Router Advertisement configuration for an interface.
+///
+/// Presence of an [`NdInterface`] for an interface implies RAs are enabled on it
+/// (i.e. the generated config emits `no ipv6 nd suppress-ra`). The remaining fields map
+/// 1:1 to FRR `ipv6 nd ...` interface commands.
+#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+pub struct NdInterface {
+    /// Sets the `M` bit in emitted RAs. Guests should obtain addresses via DHCPv6.
+    #[serde(default, deserialize_with = "proxmox_serde::perl::deserialize_bool")]
+    pub managed_config_flag: bool,
+    /// Sets the `O` bit in emitted RAs. Guests should obtain other configuration via DHCPv6.
+    #[serde(default, deserialize_with = "proxmox_serde::perl::deserialize_bool")]
+    pub other_config_flag: bool,
+    /// RDNSS entries to advertise. Each produces its own `ipv6 nd rdnss <addr>` line.
+    #[serde(default)]
+    pub rdnss: Vec<Ipv6Addr>,
+    /// Default-router lifetime (seconds) advertised in RAs. `0` tells hosts the emitter is not
+    /// a default router. `None` lets FRR use its built-in default (1800s).
+    ///
+    /// Rendered after `interval`: FRR validates a non-zero lifetime against the interval
+    /// configured at that point (matching FRR's own config-write order), so a non-zero value
+    /// must be at least `interval` (or 600 if unset).
+    #[serde(default)]
+    pub router_lifetime: Option<u32>,
+    /// Maximum interval between unsolicited RAs (seconds). `None` keeps the FRR default (600s).
+    #[serde(default)]
+    pub interval: Option<u32>,
+    /// MTU advertised in the RA. `None` omits the MTU option from the RA.
+    #[serde(default)]
+    pub mtu: Option<u32>,
+    /// Prefix advertisements emitted on this interface, in declaration order.
+    #[serde(default)]
+    pub prefixes: Vec<NdPrefix>,
+}
diff --git a/proxmox-frr/src/ser/serializer.rs b/proxmox-frr/src/ser/serializer.rs
index 2ac85d8..5b5d5a5 100644
--- a/proxmox-frr/src/ser/serializer.rs
+++ b/proxmox-frr/src/ser/serializer.rs
@@ -5,7 +5,7 @@ use crate::ser::FrrConfig;
 use proxmox_sortable_macro::sortable;
 
 #[sortable]
-pub static TEMPLATES: [(&str, &str); 12] = sorted!([
+pub static TEMPLATES: [(&str, &str); 13] = sorted!([
     (
         "fabricd.jinja",
         include_str!("/usr/share/proxmox-frr/templates/fabricd.jinja"),
@@ -50,6 +50,10 @@ pub static TEMPLATES: [(&str, &str); 12] = sorted!([
         "protocol_routemaps.jinja",
         include_str!("/usr/share/proxmox-frr/templates/protocol_routemaps.jinja"),
     ),
+    (
+        "nd_interfaces.jinja",
+        include_str!("/usr/share/proxmox-frr/templates/nd_interfaces.jinja"),
+    ),
     (
         "frr.conf.jinja",
         include_str!("/usr/share/proxmox-frr/templates/frr.conf.jinja"),
-- 
2.47.3





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

* [PATCH proxmox-ve-rs v3 3/9] ve-config: add per-vnet IPv6 RA configuration
  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
  2026-06-23 12:56 ` [PATCH pve-manager v3 4/9] ui: sdn: add IPv6 RA / SLAAC support Hannes Laimer
                   ` (5 subsequent siblings)
  8 siblings, 0 replies; 10+ messages in thread
From: Hannes Laimer @ 2026-06-23 12:56 UTC (permalink / raw)
  To: pve-devel

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





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

* [PATCH pve-manager v3 4/9] ui: sdn: add IPv6 RA / SLAAC support
  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
                   ` (2 preceding siblings ...)
  2026-06-23 12:56 ` [PATCH proxmox-ve-rs v3 3/9] ve-config: add per-vnet IPv6 RA configuration Hannes Laimer
@ 2026-06-23 12:56 ` Hannes Laimer
  2026-06-23 12:56 ` [PATCH pve-network v3 5/9] sdn: evpn: " Hannes Laimer
                   ` (4 subsequent siblings)
  8 siblings, 0 replies; 10+ messages in thread
From: Hannes Laimer @ 2026-06-23 12:56 UTC (permalink / raw)
  To: pve-devel

Expose the per-vnet RA settings and per-subnet per-prefix overrides
in the SDN edit dialogs, matching the schema split.

Add an "IPv6 Router Advertisement" tab to the vnet edit, enabled only
for EVPN zones, and an "IPv6 Prefix Options" tab to the subnet edit,
enabled only for IPv6 subnets, with the autonomous (SLAAC) flag
further gated on /64.

Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
 www/manager6/form/SDNVnetSelector.js |   2 +-
 www/manager6/sdn/SubnetEdit.js       | 130 ++++++++++++++++-
 www/manager6/sdn/SubnetView.js       |   7 +-
 www/manager6/sdn/VnetEdit.js         | 202 ++++++++++++++++++++++++++-
 www/manager6/sdn/VnetView.js         |   6 +-
 5 files changed, 339 insertions(+), 8 deletions(-)

diff --git a/www/manager6/form/SDNVnetSelector.js b/www/manager6/form/SDNVnetSelector.js
index 9e54159c..5ccc3cfb 100644
--- a/www/manager6/form/SDNVnetSelector.js
+++ b/www/manager6/form/SDNVnetSelector.js
@@ -52,7 +52,7 @@ Ext.define(
     function () {
         Ext.define('pve-sdn-vnet', {
             extend: 'Ext.data.Model',
-            fields: ['alias', 'tag', 'type', 'vnet', 'zone'],
+            fields: ['alias', 'tag', 'type', 'vnet', 'zone', 'zone-type'],
             proxy: {
                 type: 'proxmox',
                 url: '/api2/json/cluster/sdn/vnets',
diff --git a/www/manager6/sdn/SubnetEdit.js b/www/manager6/sdn/SubnetEdit.js
index a3608428..020df23e 100644
--- a/www/manager6/sdn/SubnetEdit.js
+++ b/www/manager6/sdn/SubnetEdit.js
@@ -24,6 +24,14 @@ Ext.define('PVE.sdn.SubnetInputPanel', {
             flex: 1,
             allowBlank: false,
             fieldLabel: gettext('Subnet'),
+            listeners: {
+                change: function (field, value) {
+                    let ipv6 = field.up('window')?.down('#ipv6Panel');
+                    if (ipv6) {
+                        ipv6.updateForCidr(value);
+                    }
+                },
+            },
         },
         {
             xtype: 'proxmoxtextfield',
@@ -59,6 +67,102 @@ Ext.define('PVE.sdn.SubnetInputPanel', {
     ],
 });
 
+Ext.define('PVE.sdn.SubnetIPv6Panel', {
+    extend: 'Proxmox.panel.InputPanel',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    updateForCidr: function (cidr) {
+        let me = this;
+
+        let addr = cidr ? cidr.split('/')[0] : '';
+        let mask = cidr ? parseInt(cidr.split('/')[1], 10) : NaN;
+        let isV6 = !!addr && Proxmox.Utils.IP6_match.test(addr);
+        let isSlash64 = isV6 && mask === 64;
+
+        me.setDisabled(!isV6);
+        if (!isV6) {
+            return;
+        }
+
+        let autonomous = me.down('[name=nd-prefix-autonomous]');
+        if (autonomous) {
+            autonomous.setDisabled(!isSlash64);
+            if (!isSlash64) {
+                if (autonomous.getValue()) {
+                    me.autonomousAutoCleared = true;
+                    autonomous.setValue(false);
+                }
+            } else if (me.autonomousAutoCleared) {
+                me.autonomousAutoCleared = false;
+                autonomous.setValue(true);
+            }
+        }
+    },
+
+    onGetValues: function (values) {
+        let me = this;
+
+        if (me.isCreate) {
+            for (let name of ['nd-prefix-autonomous', 'nd-prefix-on-link']) {
+                if (String(values[name]) === '1') {
+                    delete values[name];
+                }
+            }
+        }
+
+        return values;
+    },
+
+    items: [
+        {
+            xtype: 'proxmoxcheckbox',
+            name: 'nd-prefix-autonomous',
+            uncheckedValue: 0,
+            defaultValue: '1',
+            checked: true,
+            fieldLabel: gettext('SLAAC (A)'),
+            cbind: {
+                deleteDefaultValue: '{!isCreate}',
+            },
+        },
+        {
+            xtype: 'proxmoxcheckbox',
+            name: 'nd-prefix-on-link',
+            uncheckedValue: 0,
+            defaultValue: '1',
+            checked: true,
+            fieldLabel: gettext('On-link (L)'),
+            cbind: {
+                deleteDefaultValue: '{!isCreate}',
+            },
+        },
+        {
+            xtype: 'proxmoxintegerfield',
+            name: 'nd-prefix-valid-lifetime',
+            fieldLabel: gettext('Valid Lifetime (s)'),
+            minValue: 0,
+            maxValue: 4294967295,
+            allowBlank: true,
+            emptyText: '2592000',
+            cbind: {
+                deleteEmpty: '{!isCreate}',
+            },
+        },
+        {
+            xtype: 'proxmoxintegerfield',
+            name: 'nd-prefix-preferred-lifetime',
+            fieldLabel: gettext('Preferred Lifetime (s)'),
+            minValue: 0,
+            maxValue: 4294967295,
+            allowBlank: true,
+            emptyText: '604800',
+            cbind: {
+                deleteEmpty: '{!isCreate}',
+            },
+        },
+    ],
+});
+
 Ext.define('PVE.sdn.SubnetDhcpRangePanel', {
     extend: 'Ext.form.FieldContainer',
     mixins: ['Ext.form.field.Field'],
@@ -238,13 +342,16 @@ Ext.define('PVE.sdn.SubnetDhcpRangePanel', {
 Ext.define('PVE.sdn.SubnetEdit', {
     extend: 'Proxmox.window.Edit',
 
+    onlineHelp: 'pvesdn_config_subnet',
     subject: gettext('Subnet'),
 
     subnet: undefined,
+    cidr: undefined,
 
     width: 350,
 
     base_url: undefined,
+    zoneType: undefined,
 
     bodyPadding: 0,
 
@@ -272,22 +379,41 @@ Ext.define('PVE.sdn.SubnetEdit', {
             name: 'dhcp-range',
         });
 
+        let tabItems = [ipanel, dhcpPanel];
+        let ipv6Panel;
+        let showIpv6Panel = me.zoneType === 'evpn';
+        if (showIpv6Panel && !me.isCreate && me.cidr) {
+            let addr = me.cidr.split('/')[0];
+            showIpv6Panel = Proxmox.Utils.IP6_match.test(addr);
+        }
+        if (showIpv6Panel) {
+            ipv6Panel = Ext.create('PVE.sdn.SubnetIPv6Panel', {
+                isCreate: me.isCreate,
+                itemId: 'ipv6Panel',
+                title: gettext('IPv6 Prefix Options'),
+            });
+            tabItems.push(ipv6Panel);
+        }
+
         Ext.apply(me, {
             items: [
                 {
                     xtype: 'tabpanel',
                     bodyPadding: 10,
-                    items: [ipanel, dhcpPanel],
+                    items: tabItems,
                 },
             ],
         });
 
         me.callParent();
 
-        if (!me.isCreate) {
+        if (me.isCreate) {
+            ipv6Panel?.updateForCidr(undefined);
+        } else {
             me.load({
                 success: function (response, options) {
                     me.setValues(response.result.data);
+                    ipv6Panel?.updateForCidr(response.result.data.cidr);
                 },
             });
         }
diff --git a/www/manager6/sdn/SubnetView.js b/www/manager6/sdn/SubnetView.js
index c61458e0..e9e601bb 100644
--- a/www/manager6/sdn/SubnetView.js
+++ b/www/manager6/sdn/SubnetView.js
@@ -8,13 +8,15 @@ Ext.define(
         stateId: 'grid-sdn-subnet',
 
         base_url: undefined,
+        zone_type: undefined,
 
         remove_btn: undefined,
 
-        setBaseUrl: function (url) {
+        setBaseUrl: function (url, zoneType) {
             let me = this;
 
             me.base_url = url;
+            me.zone_type = zoneType;
 
             if (url === undefined) {
                 me.store.removeAll();
@@ -49,7 +51,9 @@ Ext.define(
                 let win = Ext.create('PVE.sdn.SubnetEdit', {
                     autoShow: true,
                     subnet: rec.data.subnet,
+                    cidr: rec.data.cidr,
                     base_url: me.base_url,
+                    zoneType: me.zone_type,
                 });
                 win.on('destroy', reload);
             };
@@ -62,6 +66,7 @@ Ext.define(
                         autoShow: true,
                         base_url: me.base_url,
                         type: 'subnet',
+                        zoneType: me.zone_type,
                     });
                     win.on('destroy', reload);
                 },
diff --git a/www/manager6/sdn/VnetEdit.js b/www/manager6/sdn/VnetEdit.js
index 1c2f5293..a475d140 100644
--- a/www/manager6/sdn/VnetEdit.js
+++ b/www/manager6/sdn/VnetEdit.js
@@ -80,6 +80,9 @@ Ext.define('PVE.sdn.VnetInputPanel', {
 
                     let panel = me.up('panel');
                     panel.setZoneType(zoneType);
+
+                    let raPanel = me.up('window').down('#ipv6RaPanel');
+                    raPanel?.setDisabled(zoneType !== 'evpn');
                 },
             },
         },
@@ -145,14 +148,193 @@ Ext.define('PVE.sdn.VnetInputPanel', {
     },
 });
 
+Ext.define('PVE.sdn.VnetIPv6RAPanel', {
+    extend: 'Proxmox.panel.InputPanel',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    initComponent: function () {
+        let me = this;
+        me.callParent();
+        me.updateGatedState();
+    },
+
+    onGetValues: function (values) {
+        let me = this;
+
+        if (values['ipv6-ra-rdnss']) {
+            values['ipv6-ra-rdnss'] = values['ipv6-ra-rdnss']
+                .split(/[\s,]+/)
+                .filter((v) => v.length > 0);
+        }
+
+        if (me.isCreate) {
+            for (let name of ['ipv6-ra', 'ipv6-ra-managed', 'ipv6-ra-other']) {
+                if (String(values[name]) === '0') {
+                    delete values[name];
+                }
+            }
+            return values;
+        }
+
+        let addDelete = (key) => {
+            if (values.delete) {
+                if (Ext.isArray(values.delete)) {
+                    values.delete.push(key);
+                } else {
+                    values.delete = [values.delete, key];
+                }
+            } else {
+                values.delete = [key];
+            }
+            delete values[key];
+        };
+
+        for (let name of [
+            'ipv6-ra',
+            'ipv6-ra-managed',
+            'ipv6-ra-other',
+            'ipv6-ra-rdnss',
+            'ipv6-ra-router-lifetime',
+            'ipv6-ra-interval',
+            'ipv6-ra-mtu',
+        ]) {
+            if (me.down(`[name=${name}]`).isDisabled()) {
+                addDelete(name);
+            }
+        }
+
+        return values;
+    },
+
+    setValues: function (values) {
+        let me = this;
+
+        if (Ext.isArray(values['ipv6-ra-rdnss'])) {
+            values = Ext.apply({}, values);
+            values['ipv6-ra-rdnss'] = values['ipv6-ra-rdnss'].join(', ');
+        }
+        me.callParent([values]);
+    },
+
+    updateGatedState: function () {
+        let me = this;
+
+        let raEnabled = !!me.down('[name=ipv6-ra]').getValue();
+        for (let name of [
+            'ipv6-ra-managed',
+            'ipv6-ra-other',
+            'ipv6-ra-rdnss',
+            'ipv6-ra-router-lifetime',
+            'ipv6-ra-interval',
+            'ipv6-ra-mtu',
+        ]) {
+            let field = me.down(`[name=${name}]`);
+            if (field) {
+                field.setDisabled(!raEnabled);
+            }
+        }
+    },
+
+    items: [
+        {
+            xtype: 'proxmoxcheckbox',
+            name: 'ipv6-ra',
+            uncheckedValue: 0,
+            defaultValue: 0,
+            checked: false,
+            fieldLabel: gettext('Send Router Advertisements'),
+            cbind: {
+                deleteDefaultValue: '{!isCreate}',
+            },
+            listeners: {
+                change: function (field) {
+                    field.up('inputpanel').updateGatedState();
+                },
+            },
+        },
+        {
+            xtype: 'proxmoxcheckbox',
+            name: 'ipv6-ra-managed',
+            uncheckedValue: 0,
+            defaultValue: 0,
+            checked: false,
+            fieldLabel: gettext('DHCP Managed (M)'),
+            cbind: {
+                deleteDefaultValue: '{!isCreate}',
+            },
+        },
+        {
+            xtype: 'proxmoxcheckbox',
+            name: 'ipv6-ra-other',
+            uncheckedValue: 0,
+            defaultValue: 0,
+            checked: false,
+            fieldLabel: gettext('DHCP Other (O)'),
+            cbind: {
+                deleteDefaultValue: '{!isCreate}',
+            },
+        },
+        {
+            xtype: 'proxmoxtextfield',
+            name: 'ipv6-ra-rdnss',
+            fieldLabel: 'RDNSS',
+            emptyText: gettext('Comma-separated IPv6 addresses'),
+            cbind: {
+                deleteEmpty: '{!isCreate}',
+            },
+        },
+    ],
+    advancedItems: [
+        {
+            xtype: 'proxmoxintegerfield',
+            name: 'ipv6-ra-router-lifetime',
+            fieldLabel: gettext('Router Lifetime (s)'),
+            emptyText: '1800',
+            minValue: 0,
+            maxValue: 9000,
+            allowBlank: true,
+            cbind: {
+                deleteEmpty: '{!isCreate}',
+            },
+        },
+        {
+            xtype: 'proxmoxintegerfield',
+            name: 'ipv6-ra-interval',
+            fieldLabel: gettext('RA Interval (s)'),
+            emptyText: '600',
+            minValue: 4,
+            maxValue: 1800,
+            allowBlank: true,
+            cbind: {
+                deleteEmpty: '{!isCreate}',
+            },
+        },
+        {
+            xtype: 'proxmoxintegerfield',
+            name: 'ipv6-ra-mtu',
+            fieldLabel: gettext('Advertised MTU'),
+            minValue: 1280,
+            maxValue: 65535,
+            allowBlank: true,
+            cbind: {
+                deleteEmpty: '{!isCreate}',
+            },
+        },
+    ],
+});
+
 Ext.define('PVE.sdn.VnetEdit', {
     extend: 'Proxmox.window.Edit',
 
+    onlineHelp: 'pvesdn_config_vnet',
     subject: gettext('VNet'),
 
     vnet: undefined,
+    zoneType: undefined,
+
+    width: 400,
 
-    width: 350,
+    bodyPadding: 0,
 
     initComponent: function () {
         var me = this;
@@ -169,10 +351,24 @@ Ext.define('PVE.sdn.VnetEdit', {
 
         let ipanel = Ext.create('PVE.sdn.VnetInputPanel', {
             isCreate: me.isCreate,
+            title: gettext('General'),
+        });
+
+        let raPanel = Ext.create('PVE.sdn.VnetIPv6RAPanel', {
+            isCreate: me.isCreate,
+            itemId: 'ipv6RaPanel',
+            title: gettext('IPv6 Router Advertisement'),
+            disabled: me.zoneType !== 'evpn',
         });
 
         Ext.apply(me, {
-            items: [ipanel],
+            items: [
+                {
+                    xtype: 'tabpanel',
+                    bodyPadding: 10,
+                    items: [ipanel, raPanel],
+                },
+            ],
         });
 
         me.callParent();
@@ -182,6 +378,8 @@ Ext.define('PVE.sdn.VnetEdit', {
                 success: function (response, options) {
                     let values = response.result.data;
                     ipanel.setValues(values);
+                    raPanel.setValues(values);
+                    raPanel.updateGatedState();
                 },
             });
         }
diff --git a/www/manager6/sdn/VnetView.js b/www/manager6/sdn/VnetView.js
index 1c576db6..ce8c314e 100644
--- a/www/manager6/sdn/VnetView.js
+++ b/www/manager6/sdn/VnetView.js
@@ -36,6 +36,7 @@ Ext.define('PVE.sdn.VnetView', {
                 autoShow: true,
                 onlineHelp: 'pvesdn_config_vnet',
                 vnet: rec.data.vnet,
+                zoneType: rec.data['zone-type'],
             });
             win.on('destroy', reload);
         };
@@ -141,10 +142,11 @@ Ext.define('PVE.sdn.VnetView', {
                 show: reload,
                 select: function (_sm, rec) {
                     let url = `/cluster/sdn/vnets/${rec.data.vnet}/subnets`;
-                    me.subnetview_panel.setBaseUrl(url);
+                    let zoneType = rec.data['zone-type'];
+                    me.subnetview_panel.setBaseUrl(url, zoneType);
                 },
                 deselect: function () {
-                    me.subnetview_panel.setBaseUrl(undefined);
+                    me.subnetview_panel.setBaseUrl(undefined, undefined);
                 },
             },
         });
-- 
2.47.3





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

* [PATCH pve-network v3 5/9] sdn: evpn: add IPv6 RA / SLAAC support
  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
                   ` (3 preceding siblings ...)
  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 ` 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
                   ` (3 subsequent siblings)
  8 siblings, 0 replies; 10+ messages in thread
From: Hannes Laimer @ 2026-06-23 12:56 UTC (permalink / raw)
  To: pve-devel

Configure IPv6 Router Advertisements on EVPN vnets so guests can
autoconfigure addresses via SLAAC and pick up DNS / DHCP hints.

The configuration is split between the VNet and the subnet, matching
the protocol's per-RA / per-prefix layers. Defaults are picked so
the typical SLAAC case needs no per-subnet configuration.

The API rejects each option where it does not apply: the RA toggle
outside EVPN zones, per-prefix overrides on IPv4 subnets, and
explicitly enabling the autonomous flag on non-/64 prefixes.

Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---

Notes:
    v3:
     - accept valid IPv6 subnets again, non-/64 and SNAT no longer rejected
     - reject autonomous only when explicitly set on a non-/64
     - validate preferred not above valid, cap both at u32 max
     - name the offending field in validation errors
     - gate RA via a supports_ipv6_ra predicate, breaks the load cycle
     - reject a router lifetime below the RA interval
     - read subnets once from the running snapshot, no pending leak
     - eval guard RA generation, gate it on zone node membership
     - register ipv6-ra-rdnss in encode_value

 src/PVE/Network/SDN.pm                        |  1 +
 src/PVE/Network/SDN/Controllers.pm            | 21 ++++-
 src/PVE/Network/SDN/Controllers/EvpnPlugin.pm | 22 ++++-
 src/PVE/Network/SDN/Controllers/Plugin.pm     |  2 +-
 src/PVE/Network/SDN/SubnetPlugin.pm           | 68 +++++++++++++++
 src/PVE/Network/SDN/VnetPlugin.pm             | 85 +++++++++++++++++++
 src/PVE/Network/SDN/Zones/EvpnPlugin.pm       | 14 ++-
 src/PVE/Network/SDN/Zones/Plugin.pm           |  6 ++
 .../evpn/slaac/expected_controller_config     | 53 ++++++++++++
 .../zones/evpn/slaac/expected_sdn_interfaces  | 43 ++++++++++
 src/test/zones/evpn/slaac/interfaces          |  7 ++
 src/test/zones/evpn/slaac/sdn_config          | 41 +++++++++
 12 files changed, 354 insertions(+), 9 deletions(-)
 create mode 100644 src/test/zones/evpn/slaac/expected_controller_config
 create mode 100644 src/test/zones/evpn/slaac/expected_sdn_interfaces
 create mode 100644 src/test/zones/evpn/slaac/interfaces
 create mode 100644 src/test/zones/evpn/slaac/sdn_config

diff --git a/src/PVE/Network/SDN.pm b/src/PVE/Network/SDN.pm
index 33a3cf3..8f30fe7 100644
--- a/src/PVE/Network/SDN.pm
+++ b/src/PVE/Network/SDN.pm
@@ -516,6 +516,7 @@ sub encode_value {
         || $key eq 'allowed_ips'
         || $key eq 'secondary-controllers'
         || $key eq 'redistribute'
+        || $key eq 'ipv6-ra-rdnss'
     ) {
         if (ref($value) eq 'HASH') {
             return join(',', sort keys(%$value));
diff --git a/src/PVE/Network/SDN/Controllers.pm b/src/PVE/Network/SDN/Controllers.pm
index 4336b86..5a4cb93 100644
--- a/src/PVE/Network/SDN/Controllers.pm
+++ b/src/PVE/Network/SDN/Controllers.pm
@@ -8,6 +8,7 @@ use JSON;
 use PVE::Tools qw(extract_param dir_glob_regex run_command);
 use PVE::Cluster qw(cfs_read_file cfs_write_file cfs_lock_file);
 
+use PVE::Network::SDN::Subnets;
 use PVE::Network::SDN::Vnets;
 use PVE::Network::SDN::Zones;
 
@@ -153,6 +154,18 @@ sub generate_frr_config {
         }
     }
 
+    # Bucket subnets by vnet once, from the same config snapshot the rest of the FRR
+    # config is generated from: the committed running config on apply, the compiled
+    # pending config for the dry-run diff. This keeps frr.conf consistent with the
+    # generated /etc/network/interfaces.d/sdn.
+    my $subnets_by_vnet = {};
+    my $subnet_cfg = $sdn_config->{subnets} // { ids => {} };
+    for my $subnetid (sort keys %{ $subnet_cfg->{ids} }) {
+        my $subnet = PVE::Network::SDN::Subnets::sdn_subnets_config($subnet_cfg, $subnetid);
+        next if !$subnet->{vnet};
+        $subnets_by_vnet->{ $subnet->{vnet} }->{$subnetid} = $subnet;
+    }
+
     foreach my $id (sort keys %{ $vnet_cfg->{ids} }) {
         my $plugin_config = $vnet_cfg->{ids}->{$id};
         my $zoneid = $plugin_config->{zone};
@@ -169,7 +182,13 @@ sub generate_frr_config {
             my $controller_plugin =
                 PVE::Network::SDN::Controllers::Plugin->lookup($controller->{type});
             $controller_plugin->generate_vnet_frr_config(
-                $plugin_config, $controller, $zone, $zoneid, $id, $frr_config,
+                $plugin_config,
+                $controller,
+                $zone,
+                $zoneid,
+                $id,
+                $frr_config,
+                $subnets_by_vnet->{$id} // {},
             );
 
             if (!$zone->{'rt-import'}) {
diff --git a/src/PVE/Network/SDN/Controllers/EvpnPlugin.pm b/src/PVE/Network/SDN/Controllers/EvpnPlugin.pm
index 4220cb6..b9dcb9f 100644
--- a/src/PVE/Network/SDN/Controllers/EvpnPlugin.pm
+++ b/src/PVE/Network/SDN/Controllers/EvpnPlugin.pm
@@ -11,6 +11,7 @@ use PVE::RESTEnvironment qw(log_warn);
 use PVE::Network::SDN::Controllers::Plugin;
 use PVE::Network::SDN::Zones::Plugin;
 use PVE::Network::SDN::Fabrics;
+use PVE::RS::SDN::IPv6RA;
 use Net::IP;
 
 use base('PVE::Network::SDN::Controllers::Plugin');
@@ -584,19 +585,34 @@ sub generate_zone_frr_config {
 }
 
 sub generate_vnet_frr_config {
-    my ($class, $plugin_config, $controller, $zone, $zoneid, $vnetid, $config) = @_;
+    my ($class, $plugin_config, $controller, $zone, $zoneid, $vnetid, $config, $subnets) = @_;
+
+    $subnets //= {};
+
+    my $local_node = PVE::INotify::nodename();
+
+    # Only emit ND/RA config on nodes that are part of the zone, mirroring the node
+    # filtering of the bridge generation in the zones plugin.
+    my $is_zone_member = !defined($zone->{nodes}) || $zone->{nodes}->{$local_node};
+
+    if ($plugin_config->{'ipv6-ra'} && $is_zone_member) {
+        my $nd_iface =
+            eval { PVE::RS::SDN::IPv6RA::build_interface($vnetid, $plugin_config, $subnets); };
+        if (my $err = $@) {
+            log_warn("vnet $vnetid: could not generate IPv6 RA configuration: $err");
+        }
+        $config->{frr}->{nd_interfaces}->{$vnetid} = $nd_iface if $nd_iface;
+    }
 
     my $exitnodes = $zone->{'exitnodes'};
     my $exitnodes_local_routing = $zone->{'exitnodes-local-routing'};
 
     return if !$exitnodes_local_routing;
 
-    my $local_node = PVE::INotify::nodename();
     my $is_gateway = $exitnodes->{$local_node};
 
     return if !$is_gateway;
 
-    my $subnets = PVE::Network::SDN::Vnets::get_subnets($vnetid, 1);
     $config->{frr}->{ip_routes} //= [];
     foreach my $subnetid (sort keys %{$subnets}) {
         my $subnet = $subnets->{$subnetid};
diff --git a/src/PVE/Network/SDN/Controllers/Plugin.pm b/src/PVE/Network/SDN/Controllers/Plugin.pm
index 6da3eec..45870ee 100644
--- a/src/PVE/Network/SDN/Controllers/Plugin.pm
+++ b/src/PVE/Network/SDN/Controllers/Plugin.pm
@@ -93,7 +93,7 @@ sub generate_zone_frr_config {
 }
 
 sub generate_vnet_frr_config {
-    my ($class, $plugin_config, $controller, $zoneid, $vnetid, $config) = @_;
+    my ($class, $plugin_config, $controller, $zone, $zoneid, $vnetid, $config, $subnets) = @_;
 
 }
 
diff --git a/src/PVE/Network/SDN/SubnetPlugin.pm b/src/PVE/Network/SDN/SubnetPlugin.pm
index e2a0e50..078fed7 100644
--- a/src/PVE/Network/SDN/SubnetPlugin.pm
+++ b/src/PVE/Network/SDN/SubnetPlugin.pm
@@ -177,6 +177,39 @@ sub properties {
             description => 'IP address for the DNS server',
             optional => 1,
         },
+        # Per-prefix Router Advertisement overrides. Only meaningful for IPv6 subnets
+        # whose vnet has `ipv6-ra` enabled, silently ignored otherwise.
+        'nd-prefix-autonomous' => {
+            type => 'boolean',
+            description =>
+                "Set the autonomous (A) flag for this prefix in RAs, enabling SLAAC for"
+                . " hosts on this subnet. SLAAC requires a /64 prefix, so this defaults to"
+                . " enabled on /64 subnets and disabled (and not enablable) otherwise.",
+            optional => 1,
+        },
+        'nd-prefix-on-link' => {
+            type => 'boolean',
+            description =>
+                "Set the on-link (L) flag for this prefix in RAs (default: on-link).",
+            optional => 1,
+            default => 1,
+        },
+        'nd-prefix-valid-lifetime' => {
+            type => 'integer',
+            description => "Valid lifetime for this prefix in RAs, in seconds.",
+            minimum => 0,
+            maximum => 4294967295,
+            default => 2592000,
+            optional => 1,
+        },
+        'nd-prefix-preferred-lifetime' => {
+            type => 'integer',
+            description => "Preferred lifetime for this prefix in RAs, in seconds.",
+            minimum => 0,
+            maximum => 4294967295,
+            default => 604800,
+            optional => 1,
+        },
     };
 }
 
@@ -189,6 +222,10 @@ sub options {
         dnszoneprefix => { optional => 1 },
         'dhcp-range' => { optional => 1 },
         'dhcp-dns-server' => { optional => 1 },
+        'nd-prefix-autonomous' => { optional => 1 },
+        'nd-prefix-on-link' => { optional => 1 },
+        'nd-prefix-valid-lifetime' => { optional => 1 },
+        'nd-prefix-preferred-lifetime' => { optional => 1 },
     };
 }
 
@@ -206,6 +243,37 @@ sub on_update_hook {
     my $dns = $zone->{dns};
     my $dnszone = $zone->{dnszone};
     my $reversedns = $zone->{reversedns};
+    my $is_ipv6 = PVE::JSONSchema::pve_verify_cidrv6($cidr, 1);
+
+    # Per-prefix RA overrides only apply on IPv6 subnets. Reject explicit overrides on
+    # IPv4 subnets so the user notices their config has no effect.
+    if (!$is_ipv6) {
+        for my $opt (
+            qw(nd-prefix-autonomous nd-prefix-on-link nd-prefix-valid-lifetime nd-prefix-preferred-lifetime)
+        ) {
+            raise_param_exc({ $opt => "only valid on IPv6 subnets" })
+                if defined($subnet->{$opt});
+        }
+    }
+
+    # SLAAC requires a /64 prefix (RFC 4862), so the autonomous flag defaults to enabled
+    # only on /64 subnets. Explicitly enabling it elsewhere can never work, reject it.
+    if ($is_ipv6 && $mask != 64 && $subnet->{'nd-prefix-autonomous'}) {
+        raise_param_exc(
+            { 'nd-prefix-autonomous' => "autonomous (SLAAC) flag requires a /64 prefix" });
+    }
+
+    # The preferred lifetime must not exceed the valid lifetime (RFC 4861), FRR rejects
+    # such prefix lines at config load. Mixed explicit/default cases are clamped when the
+    # FRR config is built.
+    my $nd_valid = $subnet->{'nd-prefix-valid-lifetime'};
+    my $nd_preferred = $subnet->{'nd-prefix-preferred-lifetime'};
+    if (defined($nd_valid) && defined($nd_preferred) && $nd_preferred > $nd_valid) {
+        raise_param_exc({
+            'nd-prefix-preferred-lifetime' =>
+                "preferred lifetime must not be greater than the valid lifetime ($nd_valid)",
+        });
+    }
 
     my $mac = undef;
 
diff --git a/src/PVE/Network/SDN/VnetPlugin.pm b/src/PVE/Network/SDN/VnetPlugin.pm
index e041575..2479d7c 100644
--- a/src/PVE/Network/SDN/VnetPlugin.pm
+++ b/src/PVE/Network/SDN/VnetPlugin.pm
@@ -83,6 +83,57 @@ sub properties {
                 "If true, sets the isolated property for all interfaces on the bridge of this VNet.",
             optional => 1,
         },
+        # IPv6 Router Advertisement settings (EVPN zones only). The master toggle gates
+        # the rest. Per-prefix flags live on the subnet (nd-prefix-*).
+        'ipv6-ra' => {
+            type => 'boolean',
+            description =>
+                "Emit IPv6 Router Advertisements on this VNet's bridge. Requires the VNet to belong"
+                . " to an EVPN zone with at least one IPv6 subnet.",
+            optional => 1,
+        },
+        'ipv6-ra-managed' => {
+            type => 'boolean',
+            description => "Set the managed-address (M) flag in emitted RAs.",
+            optional => 1,
+        },
+        'ipv6-ra-other' => {
+            type => 'boolean',
+            description => "Set the other-configuration (O) flag in emitted RAs.",
+            optional => 1,
+        },
+        'ipv6-ra-rdnss' => {
+            type => 'array',
+            description => "RDNSS (Recursive DNS Server) addresses advertised in RAs.",
+            optional => 1,
+            items => {
+                type => 'string',
+                format => 'ipv6',
+            },
+        },
+        'ipv6-ra-router-lifetime' => {
+            type => 'integer',
+            description =>
+                "Router lifetime advertised in RAs (seconds). 0 tells hosts not to use this"
+                . " router as a default gateway.",
+            optional => 1,
+            minimum => 0,
+            maximum => 9000,
+        },
+        'ipv6-ra-interval' => {
+            type => 'integer',
+            description => "Maximum interval between unsolicited RAs (seconds).",
+            optional => 1,
+            minimum => 4,
+            maximum => 1800,
+        },
+        'ipv6-ra-mtu' => {
+            type => 'integer',
+            description => "MTU advertised in RAs.",
+            optional => 1,
+            minimum => 1280,
+            maximum => 65535,
+        },
     };
 }
 
@@ -93,6 +144,13 @@ sub options {
         alias => { optional => 1 },
         vlanaware => { optional => 1 },
         'isolate-ports' => { optional => 1 },
+        'ipv6-ra' => { optional => 1 },
+        'ipv6-ra-managed' => { optional => 1 },
+        'ipv6-ra-other' => { optional => 1 },
+        'ipv6-ra-rdnss' => { optional => 1 },
+        'ipv6-ra-router-lifetime' => { optional => 1 },
+        'ipv6-ra-interval' => { optional => 1 },
+        'ipv6-ra-mtu' => { optional => 1 },
     };
 }
 
@@ -117,6 +175,33 @@ sub on_update_hook {
         raise_param_exc({ vlanaware => "vlanaware vnet is not compatible with subnets" })
             if $subnets;
     }
+
+    # RA settings are only consumed by zones whose plugin emits RAs (currently EVPN).
+    # Reject them elsewhere so the user notices their config has no effect. Loaded at
+    # runtime to avoid a compile-time module cycle (Zones -> Vnets -> VnetPlugin).
+    if (my @ra_opts = sort grep { /^ipv6-ra/ } keys %$vnet) {
+        require PVE::Network::SDN::Zones;
+        require PVE::Network::SDN::Zones::Plugin;
+
+        my $zone_cfg = PVE::Network::SDN::Zones::config();
+        my $zone = $zone_cfg->{ids}->{ $vnet->{zone} };
+        my $zone_plugin =
+            $zone ? eval { PVE::Network::SDN::Zones::Plugin->lookup($zone->{type}) } : undef;
+
+        raise_param_exc({ $ra_opts[0] => "IPv6 RA options are only supported on EVPN zones" })
+            if !$zone_plugin || !$zone_plugin->supports_ipv6_ra();
+    }
+
+    # RFC 4861: a non-zero router lifetime must be at least the maximum RA interval
+    # (FRR default: 600s), FRR rejects the lifetime at config load otherwise.
+    my $ra_lifetime = $vnet->{'ipv6-ra-router-lifetime'};
+    my $ra_interval = $vnet->{'ipv6-ra-interval'} // 600;
+    if (defined($ra_lifetime) && $ra_lifetime != 0 && $ra_lifetime < $ra_interval) {
+        raise_param_exc({
+            'ipv6-ra-router-lifetime' =>
+                "router lifetime must be 0 or at least the RA interval ($ra_interval seconds)",
+        });
+    }
 }
 
 1;
diff --git a/src/PVE/Network/SDN/Zones/EvpnPlugin.pm b/src/PVE/Network/SDN/Zones/EvpnPlugin.pm
index dfbd7e9..d8bfa80 100644
--- a/src/PVE/Network/SDN/Zones/EvpnPlugin.pm
+++ b/src/PVE/Network/SDN/Zones/EvpnPlugin.pm
@@ -266,12 +266,14 @@ sub generate_sdn_config {
             $enable_forward_v4 = 1 if $gateway;
         }
 
-        if ($subnet->{snat}) {
+        if ($subnet->{snat} && $is_evpn_gateway) {
 
-            #find outgoing interface
+            #find outgoing interface, but don't fail the whole vnet on a node that
+            #cannot route the check address (e.g. no IPv6 connectivity)
             my ($outip, $outiface) =
-                PVE::Network::SDN::Zones::Plugin::get_local_route_ip($checkrouteip);
-            if ($outip && $outiface && $is_evpn_gateway) {
+                eval { PVE::Network::SDN::Zones::Plugin::get_local_route_ip($checkrouteip) };
+            warn "vnet $vnetid: not generating SNAT rules for $cidr: $@" if $@;
+            if ($outip && $outiface) {
                 #use snat, faster than masquerade
                 push @iface_config,
                     "post-up $iptables -t nat -A POSTROUTING -s '$cidr' -o $outiface -j SNAT --to-source $outip";
@@ -431,5 +433,9 @@ sub vnet_update_hook {
     }
 }
 
+sub supports_ipv6_ra {
+    return 1;
+}
+
 1;
 
diff --git a/src/PVE/Network/SDN/Zones/Plugin.pm b/src/PVE/Network/SDN/Zones/Plugin.pm
index 74a3384..a0adfe9 100644
--- a/src/PVE/Network/SDN/Zones/Plugin.pm
+++ b/src/PVE/Network/SDN/Zones/Plugin.pm
@@ -163,6 +163,12 @@ sub vnet_update_hook {
     # do nothing by default
 }
 
+# Whether vnets in this zone type can emit IPv6 Router Advertisements (ipv6-ra* vnet
+# options). Zone plugins that generate RA configuration override this.
+sub supports_ipv6_ra {
+    return 0;
+}
+
 #helpers
 sub parse_tag_number_or_range {
     my ($str, $max, $tag) = @_;
diff --git a/src/test/zones/evpn/slaac/expected_controller_config b/src/test/zones/evpn/slaac/expected_controller_config
new file mode 100644
index 0000000..7d59591
--- /dev/null
+++ b/src/test/zones/evpn/slaac/expected_controller_config
@@ -0,0 +1,53 @@
+frr version 10.6.1
+frr defaults datacenter
+hostname localhost
+log syslog informational
+service integrated-vtysh-config
+!
+vrf vrf_myzone
+ vni 1000
+exit-vrf
+!
+router bgp 65000
+ bgp router-id 192.168.0.1
+ no bgp hard-administrative-reset
+ no bgp default ipv4-unicast
+ coalesce-time 1000
+ no bgp graceful-restart notification
+ neighbor VTEP peer-group
+ neighbor VTEP remote-as 65000
+ neighbor VTEP bfd
+ neighbor 192.168.0.2 peer-group VTEP
+ neighbor 192.168.0.3 peer-group VTEP
+ !
+ address-family l2vpn evpn
+  neighbor VTEP activate
+  neighbor VTEP route-map MAP_VTEP_IN in
+  neighbor VTEP route-map MAP_VTEP_OUT out
+  advertise-all-vni
+ exit-address-family
+exit
+!
+router bgp 65000 vrf vrf_myzone
+ bgp router-id 192.168.0.1
+ no bgp hard-administrative-reset
+ no bgp graceful-restart notification
+exit
+!
+route-map MAP_VTEP_IN permit 1
+exit
+!
+route-map MAP_VTEP_OUT permit 1
+exit
+!
+interface myvnet
+ no ipv6 nd suppress-ra
+ ipv6 nd managed-config-flag
+ ipv6 nd other-config-flag
+ ipv6 nd rdnss 2a08:2142:302:3::53
+ ipv6 nd prefix 2a08:2142:302:3::/64 7200 3600
+ ipv6 nd prefix fd00:1::/64 2592000 604800 no-autoconfig
+exit
+!
+line vty
+!
diff --git a/src/test/zones/evpn/slaac/expected_sdn_interfaces b/src/test/zones/evpn/slaac/expected_sdn_interfaces
new file mode 100644
index 0000000..2e479a3
--- /dev/null
+++ b/src/test/zones/evpn/slaac/expected_sdn_interfaces
@@ -0,0 +1,43 @@
+#version:1
+
+auto myvnet
+iface myvnet
+	address 2a08:2142:302:3::1/64
+	address fd00:1::1/64
+	hwaddress A2:1D:CB:1A:C0:8B
+	bridge_ports vxlan_myvnet
+	bridge_stp off
+	bridge_fd 0
+	mtu 1450
+	ip6-forward on
+	arp-accept on
+	vrf vrf_myzone
+
+auto vrf_myzone
+iface vrf_myzone
+	vrf-table auto
+	post-up ip route add vrf vrf_myzone unreachable default metric 4278198272
+
+auto vrfbr_myzone
+iface vrfbr_myzone
+	bridge-ports vrfvx_myzone
+	bridge_stp off
+	bridge_fd 0
+	mtu 1450
+	vrf vrf_myzone
+
+auto vrfvx_myzone
+iface vrfvx_myzone
+	vxlan-id 1000
+	vxlan-local-tunnelip 192.168.0.1
+	bridge-learning off
+	bridge-arp-nd-suppress on
+	mtu 1450
+
+auto vxlan_myvnet
+iface vxlan_myvnet
+	vxlan-id 100
+	vxlan-local-tunnelip 192.168.0.1
+	bridge-learning off
+	bridge-arp-nd-suppress on
+	mtu 1450
diff --git a/src/test/zones/evpn/slaac/interfaces b/src/test/zones/evpn/slaac/interfaces
new file mode 100644
index 0000000..66bb826
--- /dev/null
+++ b/src/test/zones/evpn/slaac/interfaces
@@ -0,0 +1,7 @@
+auto vmbr0
+iface vmbr0 inet static
+	address 192.168.0.1/24
+	gateway 192.168.0.254
+        bridge-ports eth0
+        bridge-stp off
+        bridge-fd 0
diff --git a/src/test/zones/evpn/slaac/sdn_config b/src/test/zones/evpn/slaac/sdn_config
new file mode 100644
index 0000000..e6294e3
--- /dev/null
+++ b/src/test/zones/evpn/slaac/sdn_config
@@ -0,0 +1,41 @@
+{
+  version => 1,
+  vnets   => {
+               ids => {
+                        myvnet => {
+                                    tag => "100",
+                                    type => "vnet",
+                                    zone => "myzone",
+                                    'ipv6-ra' => 1,
+                                    'ipv6-ra-managed' => 1,
+                                    'ipv6-ra-other' => 1,
+                                    'ipv6-ra-rdnss' => ['2a08:2142:302:3::53'],
+                                  },
+                      },
+             },
+
+  zones   => {
+               ids => { myzone => { ipam => "pve", type => "evpn", controller => "evpnctl", 'vrf-vxlan' => 1000, 'mac' => 'A2:1D:CB:1A:C0:8B' } },
+             },
+  controllers  => {
+               ids => { evpnctl => { type => "evpn", 'peers' => '192.168.0.1,192.168.0.2,192.168.0.3', asn => "65000" } },
+             },
+
+  subnets => {
+              ids => {
+                        'myzone-2a08:2142:302:3::-64' => {
+                                                           'type' => 'subnet',
+                                                           'vnet' => 'myvnet',
+                                                           'gateway' => '2a08:2142:302:3::1',
+                                                           'nd-prefix-valid-lifetime' => 7200,
+                                                           'nd-prefix-preferred-lifetime' => 3600,
+                                                         },
+                        'myzone-fd00:1::-64' => {
+                                                  'type' => 'subnet',
+                                                  'vnet' => 'myvnet',
+                                                  'gateway' => 'fd00:1::1',
+                                                  'nd-prefix-autonomous' => 0,
+                                                },
+                     }
+             }
+}
-- 
2.47.3





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

* [PATCH pve-network v3 6/9] sdn: evpn: derive IP version from CIDR for gateway-less subnets
  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
                   ` (4 preceding siblings ...)
  2026-06-23 12:56 ` [PATCH pve-network v3 5/9] sdn: evpn: " Hannes Laimer
@ 2026-06-23 12:56 ` 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
                   ` (2 subsequent siblings)
  8 siblings, 0 replies; 10+ messages in thread
From: Hannes Laimer @ 2026-06-23 12:56 UTC (permalink / raw)
  To: pve-devel

The IP version was derived from the gateway alone, falling back to
IPv4 when no gateway was set. This silently skipped the IPv6 setup
for gateway-less IPv6 subnets. Fall back to the subnet CIDR.

Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---

Notes:
    v3:
     - derive the IP version from the CIDR for gateway-less subnets

 src/PVE/Network/SDN/Zones/EvpnPlugin.pm | 7 ++++++-
 1 file changed, 6 insertions(+), 1 deletion(-)

diff --git a/src/PVE/Network/SDN/Zones/EvpnPlugin.pm b/src/PVE/Network/SDN/Zones/EvpnPlugin.pm
index d8bfa80..9c3304c 100644
--- a/src/PVE/Network/SDN/Zones/EvpnPlugin.pm
+++ b/src/PVE/Network/SDN/Zones/EvpnPlugin.pm
@@ -252,7 +252,12 @@ sub generate_sdn_config {
 
         my $iptables = undef;
         my $checkrouteip = undef;
-        my $ipversion = Net::IP::ip_is_ipv6($gateway) ? 6 : 4;
+        my $ipversion = 4;
+        if ($gateway) {
+            $ipversion = Net::IP::ip_is_ipv6($gateway) ? 6 : 4;
+        } elsif ($subnet->{network}) {
+            $ipversion = Net::IP::ip_is_ipv6($subnet->{network}) ? 6 : 4;
+        }
 
         if ($ipversion == 6) {
             $ipv6 = 1;
-- 
2.47.3





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

* [PATCH pve-network v3 7/9] sdn: evpn: accept untracked IPv6 NA on EVPN vnet bridges
  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
                   ` (5 preceding siblings ...)
  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 ` 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
  8 siblings, 0 replies; 10+ messages in thread
From: Hannes Laimer @ 2026-06-23 12:56 UTC (permalink / raw)
  To: pve-devel

In EVPN setups with per-node anycast first-hop gateways, guests may use
the shared gateway link-local address as next-hop, while return traffic
targets the guest GUA. If an exit node has no neighbor entry for that
GUA, it sends an NS.

Only the exit-node kernel tracks that NS state. Because ND traffic is
seen across EVPN nodes, a non-exit node can receive the guest's NA
without a matching local INCOMPLETE entry and can treat it as untracked.
Ignoring that NA prevents neighbor learning and can break IPv6 return
traffic.

Set `accept_untracked_na=2`[1] on EVPN vnet bridges that have IPv6
subnets so valid NA replies are accepted in this distributed gateway
topology.

Router Advertisements can trigger this, but RA presence is neither a
necessary nor a sufficient selector. Keying this to EVPN vnets with IPv6
subnets is therefore more robust, even though it is broader than just
looking at whether RAs are enabled.

Without this, deployments depend on pre-populated neighbor state (for
example guest-initiated traffic/pings first), which is fragile and
causes intermittent first-packet IPv6 failures.

[1] https://docs.kernel.org/networking/ip-sysctl.html#proc-sys-net-ipv6-variables

Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---

Notes:
    v3:
     - add the missing accept_untracked_na line to the exitnode_local_routing_ipv6 fixture

 src/PVE/Network/SDN/Zones/EvpnPlugin.pm                        | 3 +++
 .../evpn/exitnode_local_routing_ipv6/expected_sdn_interfaces   | 1 +
 src/test/zones/evpn/exitnode_snat/expected_sdn_interfaces      | 1 +
 src/test/zones/evpn/exitnodenullroute/expected_sdn_interfaces  | 1 +
 src/test/zones/evpn/ipv4ipv6/expected_sdn_interfaces           | 1 +
 src/test/zones/evpn/ipv4ipv6nogateway/expected_sdn_interfaces  | 1 +
 src/test/zones/evpn/ipv6/expected_sdn_interfaces               | 1 +
 src/test/zones/evpn/ipv6underlay/expected_sdn_interfaces       | 1 +
 src/test/zones/evpn/slaac/expected_sdn_interfaces              | 1 +
 9 files changed, 11 insertions(+)

diff --git a/src/PVE/Network/SDN/Zones/EvpnPlugin.pm b/src/PVE/Network/SDN/Zones/EvpnPlugin.pm
index 9c3304c..d8184eb 100644
--- a/src/PVE/Network/SDN/Zones/EvpnPlugin.pm
+++ b/src/PVE/Network/SDN/Zones/EvpnPlugin.pm
@@ -302,6 +302,9 @@ sub generate_sdn_config {
     push @iface_config, "ip-forward on" if $enable_forward_v4;
     push @iface_config, "ip6-forward on" if $enable_forward_v6;
     push @iface_config, "arp-accept on" if $ipv4 || $ipv6;
+    push @iface_config,
+        "post-up echo 2 > /proc/sys/net/ipv6/conf/$vnetid/accept_untracked_na || true"
+        if $ipv6;
     push @iface_config, "vrf $vrf_iface" if $vrf_iface;
     push(@{ $config->{$vnetid} }, @iface_config) if !$config->{$vnetid};
 
diff --git a/src/test/zones/evpn/exitnode_local_routing_ipv6/expected_sdn_interfaces b/src/test/zones/evpn/exitnode_local_routing_ipv6/expected_sdn_interfaces
index b46d4e7..9b268f2 100644
--- a/src/test/zones/evpn/exitnode_local_routing_ipv6/expected_sdn_interfaces
+++ b/src/test/zones/evpn/exitnode_local_routing_ipv6/expected_sdn_interfaces
@@ -9,6 +9,7 @@ iface myvnet
 	mtu 1450
 	ip6-forward on
 	arp-accept on
+	post-up echo 2 > /proc/sys/net/ipv6/conf/myvnet/accept_untracked_na || true
 	vrf vrf_myzone
 
 auto vrf_myzone
diff --git a/src/test/zones/evpn/exitnode_snat/expected_sdn_interfaces b/src/test/zones/evpn/exitnode_snat/expected_sdn_interfaces
index 0d7d174..3e9b8ad 100644
--- a/src/test/zones/evpn/exitnode_snat/expected_sdn_interfaces
+++ b/src/test/zones/evpn/exitnode_snat/expected_sdn_interfaces
@@ -28,6 +28,7 @@ iface myvnet2
 	mtu 1450
 	ip6-forward on
 	arp-accept on
+	post-up echo 2 > /proc/sys/net/ipv6/conf/myvnet2/accept_untracked_na || true
 	vrf vrf_myzone
 
 auto vrf_myzone
diff --git a/src/test/zones/evpn/exitnodenullroute/expected_sdn_interfaces b/src/test/zones/evpn/exitnodenullroute/expected_sdn_interfaces
index 4bf5ccf..81a3b39 100644
--- a/src/test/zones/evpn/exitnodenullroute/expected_sdn_interfaces
+++ b/src/test/zones/evpn/exitnodenullroute/expected_sdn_interfaces
@@ -15,6 +15,7 @@ iface myvnet
 	ip-forward on
 	ip6-forward on
 	arp-accept on
+	post-up echo 2 > /proc/sys/net/ipv6/conf/myvnet/accept_untracked_na || true
 	vrf vrf_myzone
 
 auto myvnet2
diff --git a/src/test/zones/evpn/ipv4ipv6/expected_sdn_interfaces b/src/test/zones/evpn/ipv4ipv6/expected_sdn_interfaces
index 7a5d741..7b1727b 100644
--- a/src/test/zones/evpn/ipv4ipv6/expected_sdn_interfaces
+++ b/src/test/zones/evpn/ipv4ipv6/expected_sdn_interfaces
@@ -12,6 +12,7 @@ iface myvnet
 	ip-forward on
 	ip6-forward on
 	arp-accept on
+	post-up echo 2 > /proc/sys/net/ipv6/conf/myvnet/accept_untracked_na || true
 	vrf vrf_myzone
 
 auto vrf_myzone
diff --git a/src/test/zones/evpn/ipv4ipv6nogateway/expected_sdn_interfaces b/src/test/zones/evpn/ipv4ipv6nogateway/expected_sdn_interfaces
index 378fa77..4d904be 100644
--- a/src/test/zones/evpn/ipv4ipv6nogateway/expected_sdn_interfaces
+++ b/src/test/zones/evpn/ipv4ipv6nogateway/expected_sdn_interfaces
@@ -8,6 +8,7 @@ iface myvnet
 	bridge_fd 0
 	mtu 1450
 	arp-accept on
+	post-up echo 2 > /proc/sys/net/ipv6/conf/myvnet/accept_untracked_na || true
 	vrf vrf_myzone
 
 auto vrf_myzone
diff --git a/src/test/zones/evpn/ipv6/expected_sdn_interfaces b/src/test/zones/evpn/ipv6/expected_sdn_interfaces
index b2bdbfe..f776122 100644
--- a/src/test/zones/evpn/ipv6/expected_sdn_interfaces
+++ b/src/test/zones/evpn/ipv6/expected_sdn_interfaces
@@ -10,6 +10,7 @@ iface myvnet
 	mtu 1450
 	ip6-forward on
 	arp-accept on
+	post-up echo 2 > /proc/sys/net/ipv6/conf/myvnet/accept_untracked_na || true
 	vrf vrf_myzone
 
 auto vrf_myzone
diff --git a/src/test/zones/evpn/ipv6underlay/expected_sdn_interfaces b/src/test/zones/evpn/ipv6underlay/expected_sdn_interfaces
index 3b91f75..ab5988f 100644
--- a/src/test/zones/evpn/ipv6underlay/expected_sdn_interfaces
+++ b/src/test/zones/evpn/ipv6underlay/expected_sdn_interfaces
@@ -10,6 +10,7 @@ iface myvnet
 	mtu 1450
 	ip6-forward on
 	arp-accept on
+	post-up echo 2 > /proc/sys/net/ipv6/conf/myvnet/accept_untracked_na || true
 	vrf vrf_myzone
 
 auto vrf_myzone
diff --git a/src/test/zones/evpn/slaac/expected_sdn_interfaces b/src/test/zones/evpn/slaac/expected_sdn_interfaces
index 2e479a3..6853bd4 100644
--- a/src/test/zones/evpn/slaac/expected_sdn_interfaces
+++ b/src/test/zones/evpn/slaac/expected_sdn_interfaces
@@ -11,6 +11,7 @@ iface myvnet
 	mtu 1450
 	ip6-forward on
 	arp-accept on
+	post-up echo 2 > /proc/sys/net/ipv6/conf/myvnet/accept_untracked_na || true
 	vrf vrf_myzone
 
 auto vrf_myzone
-- 
2.47.3





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

* [PATCH pve-network v3 8/9] api: vnet: include zone-type in vnet list
  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
                   ` (6 preceding siblings ...)
  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 ` Hannes Laimer
  2026-06-23 12:56 ` [PATCH pve-docs v3 9/9] sdn: add IPv6 RA / SLAAC section Hannes Laimer
  8 siblings, 0 replies; 10+ messages in thread
From: Hannes Laimer @ 2026-06-23 12:56 UTC (permalink / raw)
  To: pve-devel

The UI uses this to show EVPN-specific subnet and vnet edit options
without needing a separate request to the zones endpoint per row.

Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
 src/PVE/API2/Network/SDN/Vnets.pm | 13 +++++++++++++
 1 file changed, 13 insertions(+)

diff --git a/src/PVE/API2/Network/SDN/Vnets.pm b/src/PVE/API2/Network/SDN/Vnets.pm
index b8faeac..e0b8607 100644
--- a/src/PVE/API2/Network/SDN/Vnets.pm
+++ b/src/PVE/API2/Network/SDN/Vnets.pm
@@ -155,6 +155,12 @@ __PACKAGE__->register_method({
                     optional => 0,
                     description => 'Name of the VNet.',
                 },
+                'zone-type' => {
+                    type => 'string',
+                    description => 'Type of the zone this VNet belongs to.',
+                    enum => PVE::Network::SDN::Zones::Plugin->lookup_types(),
+                    optional => 1,
+                },
                 pending => {
                     type => 'object',
                     description =>
@@ -174,15 +180,20 @@ __PACKAGE__->register_method({
         my $authuser = $rpcenv->get_user();
 
         my $cfg = {};
+        my $zone_cfg = {};
         if ($param->{pending}) {
             my $running_cfg = PVE::Network::SDN::running_config();
             my $config = PVE::Network::SDN::Vnets::config();
+            my $zone_config = PVE::Network::SDN::Zones::config();
             $cfg = PVE::Network::SDN::pending_config($running_cfg, $config, 'vnets');
+            $zone_cfg = PVE::Network::SDN::pending_config($running_cfg, $zone_config, 'zones');
         } elsif ($param->{running}) {
             my $running_cfg = PVE::Network::SDN::running_config();
             $cfg = $running_cfg->{vnets};
+            $zone_cfg = $running_cfg->{zones};
         } else {
             $cfg = PVE::Network::SDN::Vnets::config();
+            $zone_cfg = PVE::Network::SDN::Zones::config();
         }
 
         my @sids = PVE::Network::SDN::Vnets::sdn_vnets_ids($cfg);
@@ -192,6 +203,8 @@ __PACKAGE__->register_method({
             my $scfg = &$api_sdn_vnets_config($cfg, $id);
             my $zoneid = $scfg->{zone} // $scfg->{pending}->{zone};
             next if !$rpcenv->check_any($authuser, "/sdn/zones/$zoneid/$id", $privs, 1);
+            $scfg->{'zone-type'} = $zone_cfg->{ids}->{$zoneid}->{type}
+                if $zone_cfg->{ids}->{$zoneid};
 
             push @$res, $scfg;
         }
-- 
2.47.3





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

* [PATCH pve-docs v3 9/9] sdn: add IPv6 RA / SLAAC section
  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
                   ` (7 preceding siblings ...)
  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 ` Hannes Laimer
  8 siblings, 0 replies; 10+ messages in thread
From: Hannes Laimer @ 2026-06-23 12:56 UTC (permalink / raw)
  To: pve-devel

Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
 pvesdn.adoc | 68 +++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 68 insertions(+)

diff --git a/pvesdn.adoc b/pvesdn.adoc
index a09a443..d8a6a2d 100644
--- a/pvesdn.adoc
+++ b/pvesdn.adoc
@@ -409,6 +409,10 @@ xref:pvesdn_firewall_integration[VNET Firewall] to further isolate traffic in
 the VNET across nodes. For example, DROP by default and only allow traffic from
 the IP subnet to the gateway and vice versa.
 
+For VNets in an EVPN zone, IPv6 Router Advertisements can be enabled so guests
+autoconfigure via SLAAC. See
+xref:pvesdn_ipv6_ra[IPv6 Router Advertisements (SLAAC)].
+
 [[pvesdn_config_subnet]]
 Subnets
 -------
@@ -442,6 +446,70 @@ SNAT:: Enable Source NAT which allows VMs from inside a
 DNS Zone Prefix:: Add a prefix to the domain registration, like
   <hostname>.prefix.<domain>  Optional.
 
+On IPv6 subnets of an EVPN VNet with Router Advertisements enabled, additional
+per-prefix options are available. See
+xref:pvesdn_ipv6_ra[IPv6 Router Advertisements (SLAAC)].
+
+
+[[pvesdn_ipv6_ra]]
+IPv6 Router Advertisements (SLAAC)
+----------------------------------
+
+VNets in an xref:pvesdn_zone_plugin_evpn[EVPN zone] can emit IPv6 Router
+Advertisements (RAs) from their anycast gateway. Guests on the VNet then
+autoconfigure their addresses via SLAAC and learn the default gateway, DNS
+servers and other settings directly from the RA.
+
+RAs are enabled per VNet and require the VNet to have at least one IPv6 subnet.
+The configuration follows the protocol's two layers: RA-wide settings live on
+the VNet (the 'IPv6 Router Advertisement' tab), per-prefix settings on each
+IPv6 subnet (the 'IPv6 Prefix Options' tab). The defaults are chosen so that
+enabling RAs on a VNet with a /64 IPv6 subnet already lets guests
+autoconfigure, without setting any of the options below.
+
+Per-VNet options:
+
+Send Router Advertisements:: Emit RAs on this VNet's bridge. Only available for
+  VNets in an EVPN zone that have at least one IPv6 subnet. The remaining
+  options only take effect while this is enabled.
+
+DHCP Managed (M):: Set the managed-address (M) flag, telling hosts to obtain
+  their addresses via DHCPv6 in addition to, or instead of, SLAAC. Optional,
+  off by default.
+
+DHCP Other (O):: Set the other-configuration (O) flag, telling hosts to obtain
+  other information, such as DNS servers, via DHCPv6. Optional, off by default.
+
+RDNSS:: Recursive DNS server IPv6 addresses to advertise, so hosts learn their
+  resolvers from the RA itself. Optional.
+
+Router Lifetime (s):: Seconds for which hosts may use this gateway as a default
+  router, 0 to 9000. A value of 0 advertises the gateway as not being a default
+  router, for example when the RA only distributes a prefix or DNS servers. A
+  non-zero value must be at least the RA interval. Optional.
+
+RA Interval (s):: Maximum number of seconds (4 to 1800) between two unsolicited
+  RAs. Optional, defaults to 600.
+
+Advertised MTU:: Link MTU to advertise to hosts, 1280 to 65535. Optional.
+
+Per-subnet options (IPv6 subnets only):
+
+SLAAC (A):: Set the autonomous (A) flag, advertising the prefix for SLAAC so
+  hosts can derive an address from it. SLAAC requires a /64 prefix, so this
+  defaults to enabled on /64 subnets and is disabled, and cannot be enabled, on
+  others.
+
+On-link (L):: Set the on-link (L) flag, advertising the prefix as reachable
+  directly on the link. Optional, on by default.
+
+Valid Lifetime (s):: Seconds an address derived from this prefix stays valid.
+  Optional, defaults to 2592000 (30 days).
+
+Preferred Lifetime (s):: Seconds an address derived from this prefix stays
+  preferred. Must not exceed the valid lifetime. Optional, defaults to 604800
+  (7 days).
+
 
 [[pvesdn_config_controllers]]
 Controllers
-- 
2.47.3





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

end of thread, other threads:[~2026-06-23 12:58 UTC | newest]

Thread overview: 10+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
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 ` [PATCH proxmox-ve-rs v3 3/9] ve-config: add per-vnet IPv6 RA configuration Hannes Laimer
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

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