public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
* [PATCH docs/manager/network/proxmox{-ve-rs,-perl-rs} v2 00/11] sdn: evpn: add IPv6 RA / SLAAC support
@ 2026-04-30 14:29 Hannes Laimer
  2026-04-30 14:29 ` [PATCH proxmox-ve-rs v2 01/11] frr: add IPv6 router advertisement support Hannes Laimer
                   ` (10 more replies)
  0 siblings, 11 replies; 12+ messages in thread
From: Hannes Laimer @ 2026-04-30 14:29 UTC (permalink / raw)
  To: pve-devel

It adds IPv6 Router Advertisement / SLAAC support to EVPN zones. The
series depends on the evpn-rework [2] and route-maps [3] series.

RA settings sit on the vnet (`vnets.cfg`), per-prefix overrides on
the subnet (`subnets.cfg`). Defaults are chosen so 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-gateway topology. The dedicated commit
has the full rationale.


v2:
- rebased onto the evpn-rework and route-maps series


[v1] https://lore.proxmox.com/pve-devel/20260218102350.211294-1-h.laimer@proxmox.com/
[2] https://lore.proxmox.com/pve-devel/20260414163315.419384-1-s.hanreich@proxmox.com/
[3] https://lore.proxmox.com/pve-devel/20260401143957.386809-1-s.hanreich@proxmox.com/


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             |  33 +++++
 proxmox-frr/src/ser/mod.rs                    |   6 +
 proxmox-frr/src/ser/nd.rs                     |  71 +++++++++
 proxmox-frr/src/ser/serializer.rs             |   6 +-
 proxmox-ve-config/src/sdn/config.rs           | 122 +++++++++++++++-
 proxmox-ve-config/src/sdn/mod.rs              |   1 +
 proxmox-ve-config/src/sdn/nd.rs               | 129 +++++++++++++++++
 proxmox-ve-config/tests/nd/main.rs            | 137 ++++++++++++++++++
 .../nd__explicit_lifetimes_are_preserved.snap |   9 ++
 .../nd__mixed_subnets_under_one_vnet.snap     |  14 ++
 .../snapshots/nd__no_autoconfig_prefix.snap   |   9 ++
 .../nd__off_link_emits_off_link_modifier.snap |  10 ++
 ...vel_optional_knobs_are_passed_through.snap |  13 ++
 .../nd__slaac_with_default_lifetimes.snap     |   9 ++
 15 files changed, 562 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__off_link_emits_off_link_modifier.snap
 create mode 100644 proxmox-ve-config/tests/nd/snapshots/nd__ra_level_optional_knobs_are_passed_through.snap
 create mode 100644 proxmox-ve-config/tests/nd/snapshots/nd__slaac_with_default_lifetimes.snap


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


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/Controllers/EvpnPlugin.pm | 12 +++-
 src/PVE/Network/SDN/SubnetPlugin.pm           | 67 +++++++++++++++++++
 src/PVE/Network/SDN/VnetPlugin.pm             | 67 +++++++++++++++++++
 src/PVE/Network/SDN/Zones/EvpnPlugin.pm       | 14 +++-
 .../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 ++++++++++++
 15 files changed, 322 insertions(+), 2 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-manager:

Hannes Laimer (2):
  ui: sdn: disable SNAT for IPv6 subnets
  ui: sdn: add IPv6 RA / SLAAC support

 www/manager6/form/SDNVnetSelector.js |   2 +-
 www/manager6/sdn/SubnetEdit.js       | 120 +++++++++++++++++++-
 www/manager6/sdn/SubnetView.js       |   6 +-
 www/manager6/sdn/VnetEdit.js         | 157 ++++++++++++++++++++++++++-
 www/manager6/sdn/VnetView.js         |   6 +-
 5 files changed, 283 insertions(+), 8 deletions(-)


pve-docs:

Hannes Laimer (2):
  sdn: document IPv6 RA / SLAAC configuration
  sdn: add example for IPv6 in an EVPN zone

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


Summary over all repositories:
  39 files changed, 1490 insertions(+), 18 deletions(-)

-- 
Generated by murpp 0.11.0




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

* [PATCH proxmox-ve-rs v2 01/11] frr: add IPv6 router advertisement support
  2026-04-30 14:29 [PATCH docs/manager/network/proxmox{-ve-rs,-perl-rs} v2 00/11] sdn: evpn: add IPv6 RA / SLAAC support Hannes Laimer
@ 2026-04-30 14:29 ` Hannes Laimer
  2026-04-30 14:29 ` [PATCH proxmox-ve-rs v2 02/11] ve-config: add per-vnet IPv6 RA configuration Hannes Laimer
                   ` (9 subsequent siblings)
  10 siblings, 0 replies; 12+ messages in thread
From: Hannes Laimer @ 2026-04-30 14:29 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>
---
 .../templates/frr.conf.jinja                  |  1 +
 .../templates/nd_interfaces.jinja             | 33 +++++++++
 proxmox-frr/src/ser/mod.rs                    |  6 ++
 proxmox-frr/src/ser/nd.rs                     | 71 +++++++++++++++++++
 proxmox-frr/src/ser/serializer.rs             |  6 +-
 5 files changed, 116 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..79cc16b
--- /dev/null
+++ b/proxmox-frr-templates/templates/nd_interfaces.jinja
@@ -0,0 +1,33 @@
+{% 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.router_lifetime is not none %}
+ ipv6 nd ra-lifetime {{ iface.router_lifetime }}
+{% endif %}
+{% if iface.interval is not none %}
+ ipv6 nd ra-interval {{ iface.interval }}
+{% 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 %}
+{% if prefix.mode.kind == "autoconfig" %}
+ ipv6 nd prefix {{ prefix.cidr }} {{ prefix.mode.valid }} {{ prefix.mode.preferred }}{% if not prefix.on_link %} off-link{% endif %}
+
+{% elif prefix.mode.kind == "no-autoconfig" %}
+ ipv6 nd prefix {{ prefix.cidr }} no-autoconfig{% if not prefix.on_link %} off-link{% endif %}
+
+{% endif %}
+{% endfor %}
+exit
+{% endfor %}
diff --git a/proxmox-frr/src/ser/mod.rs b/proxmox-frr/src/ser/mod.rs
index cf7ae19..70a08b6 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..baa6210
--- /dev/null
+++ b/proxmox-frr/src/ser/nd.rs
@@ -0,0 +1,71 @@
+use std::net::Ipv6Addr;
+
+use proxmox_network_types::ip_address::Ipv6Cidr;
+use serde::{Deserialize, Serialize};
+
+/// Per-prefix advertisement mode.
+///
+/// `Autoconfig` sets the autonomous flag on the prefix and carries the valid and preferred
+/// lifetimes. `NoAutoconfig` announces the prefix with the autonomous flag cleared, so
+/// hosts use it for routing decisions but do not derive addresses from it via SLAAC.
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case", tag = "kind")]
+pub enum NdPrefixMode {
+    Autoconfig {
+        #[serde(deserialize_with = "proxmox_serde::perl::deserialize_u32")]
+        valid: u32,
+        #[serde(deserialize_with = "proxmox_serde::perl::deserialize_u32")]
+        preferred: u32,
+    },
+    NoAutoconfig,
+}
+
+fn default_true() -> bool {
+    true
+}
+
+/// A single prefix advertised in Router Advertisements on an interface.
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+pub struct NdPrefix {
+    pub cidr: Ipv6Cidr,
+    /// 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,
+    pub mode: NdPrefixMode,
+}
+
+/// 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).
+    #[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] 12+ messages in thread

* [PATCH proxmox-ve-rs v2 02/11] ve-config: add per-vnet IPv6 RA configuration
  2026-04-30 14:29 [PATCH docs/manager/network/proxmox{-ve-rs,-perl-rs} v2 00/11] sdn: evpn: add IPv6 RA / SLAAC support Hannes Laimer
  2026-04-30 14:29 ` [PATCH proxmox-ve-rs v2 01/11] frr: add IPv6 router advertisement support Hannes Laimer
@ 2026-04-30 14:29 ` Hannes Laimer
  2026-04-30 14:29 ` [PATCH proxmox-perl-rs v2 03/11] pve-rs: sdn: add IPv6 RA builder binding Hannes Laimer
                   ` (8 subsequent siblings)
  10 siblings, 0 replies; 12+ messages in thread
From: Hannes Laimer @ 2026-04-30 14:29 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>
---
 proxmox-ve-config/src/sdn/config.rs           | 122 +++++++++++++++-
 proxmox-ve-config/src/sdn/mod.rs              |   1 +
 proxmox-ve-config/src/sdn/nd.rs               | 129 +++++++++++++++++
 proxmox-ve-config/tests/nd/main.rs            | 137 ++++++++++++++++++
 .../nd__explicit_lifetimes_are_preserved.snap |   9 ++
 .../nd__mixed_subnets_under_one_vnet.snap     |  14 ++
 .../snapshots/nd__no_autoconfig_prefix.snap   |   9 ++
 .../nd__off_link_emits_off_link_modifier.snap |  10 ++
 ...vel_optional_knobs_are_passed_through.snap |  13 ++
 .../nd__slaac_with_default_lifetimes.snap     |   9 ++
 10 files changed, 446 insertions(+), 7 deletions(-)
 create mode 100644 proxmox-ve-config/src/sdn/nd.rs
 create mode 100644 proxmox-ve-config/tests/nd/main.rs
 create mode 100644 proxmox-ve-config/tests/nd/snapshots/nd__explicit_lifetimes_are_preserved.snap
 create mode 100644 proxmox-ve-config/tests/nd/snapshots/nd__mixed_subnets_under_one_vnet.snap
 create mode 100644 proxmox-ve-config/tests/nd/snapshots/nd__no_autoconfig_prefix.snap
 create mode 100644 proxmox-ve-config/tests/nd/snapshots/nd__off_link_emits_off_link_modifier.snap
 create mode 100644 proxmox-ve-config/tests/nd/snapshots/nd__ra_level_optional_knobs_are_passed_through.snap
 create mode 100644 proxmox-ve-config/tests/nd/snapshots/nd__slaac_with_default_lifetimes.snap

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





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

* [PATCH proxmox-perl-rs v2 03/11] pve-rs: sdn: add IPv6 RA builder binding
  2026-04-30 14:29 [PATCH docs/manager/network/proxmox{-ve-rs,-perl-rs} v2 00/11] sdn: evpn: add IPv6 RA / SLAAC support Hannes Laimer
  2026-04-30 14:29 ` [PATCH proxmox-ve-rs v2 01/11] frr: add IPv6 router advertisement support Hannes Laimer
  2026-04-30 14:29 ` [PATCH proxmox-ve-rs v2 02/11] ve-config: add per-vnet IPv6 RA configuration Hannes Laimer
@ 2026-04-30 14:29 ` Hannes Laimer
  2026-04-30 14:29 ` [PATCH pve-network v2 04/11] sdn: evpn: add IPv6 RA / SLAAC support Hannes Laimer
                   ` (7 subsequent siblings)
  10 siblings, 0 replies; 12+ messages in thread
From: Hannes Laimer @ 2026-04-30 14:29 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 25642cd..f18852d 100644
--- a/pve-rs/Makefile
+++ b/pve-rs/Makefile
@@ -32,6 +32,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 \
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..7338bf9
--- /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::{
+        config::{SubnetConfig, SubnetRunningConfig, VnetConfig, VnetRunningConfig},
+        nd::frr::build_nd_interface,
+        SubnetName, VnetName,
+    };
+
+    /// Build the [`NdInterface`] for one vnet.
+    ///
+    /// Returns `None` when the vnet has no `ipv6-ra` master toggle set, or when there
+    /// are no IPv6 subnets to advertise.
+    ///
+    /// `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 c6361c3..d77099e 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;
 
-- 
2.47.3





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

* [PATCH pve-network v2 04/11] sdn: evpn: add IPv6 RA / SLAAC support
  2026-04-30 14:29 [PATCH docs/manager/network/proxmox{-ve-rs,-perl-rs} v2 00/11] sdn: evpn: add IPv6 RA / SLAAC support Hannes Laimer
                   ` (2 preceding siblings ...)
  2026-04-30 14:29 ` [PATCH proxmox-perl-rs v2 03/11] pve-rs: sdn: add IPv6 RA builder binding Hannes Laimer
@ 2026-04-30 14:29 ` Hannes Laimer
  2026-04-30 14:29 ` [PATCH pve-network v2 05/11] sdn: evpn: derive IP version from CIDR for gateway-less subnets Hannes Laimer
                   ` (6 subsequent siblings)
  10 siblings, 0 replies; 12+ messages in thread
From: Hannes Laimer @ 2026-04-30 14:29 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,
without standing up a DHCPv6 server alongside.

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, the
autonomous flag on non-/64 prefixes, and SNAT on IPv6 subnets.

Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
 src/PVE/Network/SDN/Controllers/EvpnPlugin.pm | 12 +++-
 src/PVE/Network/SDN/SubnetPlugin.pm           | 67 +++++++++++++++++++
 src/PVE/Network/SDN/VnetPlugin.pm             | 67 +++++++++++++++++++
 .../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 ++++++++++++
 7 files changed, 289 insertions(+), 1 deletion(-)
 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/Controllers/EvpnPlugin.pm b/src/PVE/Network/SDN/Controllers/EvpnPlugin.pm
index f50387d..96cd619 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');
@@ -547,6 +548,16 @@ sub generate_zone_frr_config {
 sub generate_vnet_frr_config {
     my ($class, $plugin_config, $controller, $zone, $zoneid, $vnetid, $config) = @_;
 
+    # Read the pending subnets (subnets.cfg) so dry-run sees pending changes. After an
+    # apply this matches the running-config snapshot anyway.
+    my $subnets = PVE::Network::SDN::Vnets::get_subnets($vnetid, 0) // {};
+
+    if ($plugin_config->{'ipv6-ra'}) {
+        my $nd_iface =
+            PVE::RS::SDN::IPv6RA::build_interface($vnetid, $plugin_config, $subnets);
+        $config->{frr}->{nd_interfaces}->{$vnetid} = $nd_iface if $nd_iface;
+    }
+
     my $exitnodes = $zone->{'exitnodes'};
     my $exitnodes_local_routing = $zone->{'exitnodes-local-routing'};
 
@@ -557,7 +568,6 @@ sub generate_vnet_frr_config {
 
     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/SubnetPlugin.pm b/src/PVE/Network/SDN/SubnetPlugin.pm
index e2a0e50..66c184b 100644
--- a/src/PVE/Network/SDN/SubnetPlugin.pm
+++ b/src/PVE/Network/SDN/SubnetPlugin.pm
@@ -177,6 +177,37 @@ 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 (default: enabled). Only valid on /64 subnets.",
+            optional => 1,
+            default => 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,
+            default => 2592000,
+            optional => 1,
+        },
+        'nd-prefix-preferred-lifetime' => {
+            type => 'integer',
+            description => "Preferred lifetime for this prefix in RAs, in seconds.",
+            minimum => 0,
+            default => 604800,
+            optional => 1,
+        },
     };
 }
 
@@ -189,6 +220,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 +241,38 @@ 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.
+    my $has_nd_override =
+        defined($subnet->{'nd-prefix-autonomous'})
+        || defined($subnet->{'nd-prefix-on-link'})
+        || defined($subnet->{'nd-prefix-valid-lifetime'})
+        || defined($subnet->{'nd-prefix-preferred-lifetime'});
+
+    if ($has_nd_override && !$is_ipv6) {
+        raise_param_exc(
+            { 'nd-prefix' => "nd-prefix-* options are only valid on IPv6 subnets" });
+    }
+
+    # SLAAC (autonomous flag) requires the prefix to be /64. Default for autonomous is on,
+    # so the check fires unless the user explicitly opts out on a non-/64 IPv6 subnet.
+    if ($is_ipv6 && $mask != 64) {
+        my $autonomous = $subnet->{'nd-prefix-autonomous'};
+        $autonomous = 1 if !defined($autonomous);
+        if ($autonomous) {
+            raise_param_exc({
+                'nd-prefix-autonomous' =>
+                    "autonomous (SLAAC) flag requires a /64 prefix, set nd-prefix-autonomous=0 to advertise this prefix without SLAAC",
+            });
+        }
+    }
+
+    # SNAT is meaningless on IPv6 subnets.
+    if ($subnet->{snat} && $is_ipv6) {
+        raise_param_exc({ snat => "SNAT is not supported on IPv6 subnets" });
+    }
 
     my $mac = undef;
 
diff --git a/src/PVE/Network/SDN/VnetPlugin.pm b/src/PVE/Network/SDN/VnetPlugin.pm
index e041575..28779c8 100644
--- a/src/PVE/Network/SDN/VnetPlugin.pm
+++ b/src/PVE/Network/SDN/VnetPlugin.pm
@@ -7,6 +7,8 @@ use PVE::Cluster qw(cfs_read_file cfs_write_file cfs_lock_file);
 use PVE::Exception qw(raise raise_param_exc);
 use PVE::JSONSchema qw(get_standard_option);
 
+use PVE::Network::SDN::Zones;
+
 use PVE::SectionConfig;
 use base qw(PVE::SectionConfig);
 
@@ -83,6 +85,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 +146,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 +177,13 @@ sub on_update_hook {
         raise_param_exc({ vlanaware => "vlanaware vnet is not compatible with subnets" })
             if $subnets;
     }
+
+    if ($vnet->{'ipv6-ra'}) {
+        my $zone_cfg = PVE::Network::SDN::Zones::config();
+        my $zone = $zone_cfg->{ids}->{ $vnet->{zone} };
+        raise_param_exc({ 'ipv6-ra' => "IPv6 RA is only supported on EVPN zones" })
+            if !$zone || $zone->{type} ne 'evpn';
+    }
 }
 
 1;
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..b8fafdf
--- /dev/null
+++ b/src/test/zones/evpn/slaac/expected_controller_config
@@ -0,0 +1,53 @@
+frr version 10.4.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 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] 12+ messages in thread

* [PATCH pve-network v2 05/11] sdn: evpn: derive IP version from CIDR for gateway-less subnets
  2026-04-30 14:29 [PATCH docs/manager/network/proxmox{-ve-rs,-perl-rs} v2 00/11] sdn: evpn: add IPv6 RA / SLAAC support Hannes Laimer
                   ` (3 preceding siblings ...)
  2026-04-30 14:29 ` [PATCH pve-network v2 04/11] sdn: evpn: add IPv6 RA / SLAAC support Hannes Laimer
@ 2026-04-30 14:29 ` Hannes Laimer
  2026-04-30 14:29 ` [PATCH pve-network v2 06/11] sdn: evpn: accept untracked IPv6 NA on EVPN vnet bridges Hannes Laimer
                   ` (5 subsequent siblings)
  10 siblings, 0 replies; 12+ messages in thread
From: Hannes Laimer @ 2026-04-30 14:29 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>
---
 src/PVE/Network/SDN/Zones/EvpnPlugin.pm | 8 +++++++-
 1 file changed, 7 insertions(+), 1 deletion(-)

diff --git a/src/PVE/Network/SDN/Zones/EvpnPlugin.pm b/src/PVE/Network/SDN/Zones/EvpnPlugin.pm
index 5df7e5f..a7daced 100644
--- a/src/PVE/Network/SDN/Zones/EvpnPlugin.pm
+++ b/src/PVE/Network/SDN/Zones/EvpnPlugin.pm
@@ -238,6 +238,7 @@ sub generate_sdn_config {
         my $subnet = $subnets->{$subnetid};
         my $cidr = $subnet->{cidr};
         my $mask = $subnet->{mask};
+        my ($subnet_ip) = split(/\//, $cidr);
 
         my $gateway = $subnet->{gateway};
         if ($gateway) {
@@ -247,7 +248,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_ip) {
+            $ipversion = Net::IP::ip_is_ipv6($subnet_ip) ? 6 : 4;
+        }
 
         if ($ipversion == 6) {
             $ipv6 = 1;
-- 
2.47.3





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

* [PATCH pve-network v2 06/11] sdn: evpn: accept untracked IPv6 NA on EVPN vnet bridges
  2026-04-30 14:29 [PATCH docs/manager/network/proxmox{-ve-rs,-perl-rs} v2 00/11] sdn: evpn: add IPv6 RA / SLAAC support Hannes Laimer
                   ` (4 preceding siblings ...)
  2026-04-30 14:29 ` [PATCH pve-network v2 05/11] sdn: evpn: derive IP version from CIDR for gateway-less subnets Hannes Laimer
@ 2026-04-30 14:29 ` Hannes Laimer
  2026-04-30 14:29 ` [PATCH pve-network v2 07/11] api: vnet: include zone-type in vnet list Hannes Laimer
                   ` (4 subsequent siblings)
  10 siblings, 0 replies; 12+ messages in thread
From: Hannes Laimer @ 2026-04-30 14:29 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>
---
 src/PVE/Network/SDN/Zones/EvpnPlugin.pm                     | 6 ++++++
 src/test/zones/evpn/exitnode_snat/expected_sdn_interfaces   | 1 +
 .../zones/evpn/exitnodenullroute/expected_sdn_interfaces    | 1 +
 src/test/zones/evpn/ipv4ipv6/expected_sdn_interfaces        | 1 +
 .../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 +
 8 files changed, 13 insertions(+)

diff --git a/src/PVE/Network/SDN/Zones/EvpnPlugin.pm b/src/PVE/Network/SDN/Zones/EvpnPlugin.pm
index a7daced..b3c6d6a 100644
--- a/src/PVE/Network/SDN/Zones/EvpnPlugin.pm
+++ b/src/PVE/Network/SDN/Zones/EvpnPlugin.pm
@@ -231,6 +231,7 @@ sub generate_sdn_config {
     my $address = {};
     my $ipv4 = undef;
     my $ipv6 = undef;
+    my $has_ipv6_subnet = undef;
     my $enable_forward_v4 = undef;
     my $enable_forward_v6 = undef;
     my $subnets = PVE::Network::SDN::Vnets::get_subnets($vnetid, 1);
@@ -246,6 +247,8 @@ sub generate_sdn_config {
             $address->{$gateway} = 1;
         }
 
+        $has_ipv6_subnet = 1 if $subnet_ip && Net::IP::ip_is_ipv6($subnet_ip);
+
         my $iptables = undef;
         my $checkrouteip = undef;
         my $ipversion = 4;
@@ -296,6 +299,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 $has_ipv6_subnet;
     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_snat/expected_sdn_interfaces b/src/test/zones/evpn/exitnode_snat/expected_sdn_interfaces
index 47df77a..e63c409 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] 12+ messages in thread

* [PATCH pve-network v2 07/11] api: vnet: include zone-type in vnet list
  2026-04-30 14:29 [PATCH docs/manager/network/proxmox{-ve-rs,-perl-rs} v2 00/11] sdn: evpn: add IPv6 RA / SLAAC support Hannes Laimer
                   ` (5 preceding siblings ...)
  2026-04-30 14:29 ` [PATCH pve-network v2 06/11] sdn: evpn: accept untracked IPv6 NA on EVPN vnet bridges Hannes Laimer
@ 2026-04-30 14:29 ` Hannes Laimer
  2026-04-30 14:29 ` [PATCH pve-manager v2 08/11] ui: sdn: disable SNAT for IPv6 subnets Hannes Laimer
                   ` (3 subsequent siblings)
  10 siblings, 0 replies; 12+ messages in thread
From: Hannes Laimer @ 2026-04-30 14:29 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] 12+ messages in thread

* [PATCH pve-manager v2 08/11] ui: sdn: disable SNAT for IPv6 subnets
  2026-04-30 14:29 [PATCH docs/manager/network/proxmox{-ve-rs,-perl-rs} v2 00/11] sdn: evpn: add IPv6 RA / SLAAC support Hannes Laimer
                   ` (6 preceding siblings ...)
  2026-04-30 14:29 ` [PATCH pve-network v2 07/11] api: vnet: include zone-type in vnet list Hannes Laimer
@ 2026-04-30 14:29 ` Hannes Laimer
  2026-04-30 14:29 ` [PATCH pve-manager v2 09/11] ui: sdn: add IPv6 RA / SLAAC support Hannes Laimer
                   ` (2 subsequent siblings)
  10 siblings, 0 replies; 12+ messages in thread
From: Hannes Laimer @ 2026-04-30 14:29 UTC (permalink / raw)
  To: pve-devel

SNAT has no meaning on IPv6 subnets in this configuration. Disable
the field and clear it whenever the subnet field holds an IPv6
prefix.

Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
 www/manager6/sdn/SubnetEdit.js | 24 ++++++++++++++++++++++++
 1 file changed, 24 insertions(+)

diff --git a/www/manager6/sdn/SubnetEdit.js b/www/manager6/sdn/SubnetEdit.js
index a3608428..630b10b8 100644
--- a/www/manager6/sdn/SubnetEdit.js
+++ b/www/manager6/sdn/SubnetEdit.js
@@ -2,6 +2,21 @@ Ext.define('PVE.sdn.SubnetInputPanel', {
     extend: 'Proxmox.panel.InputPanel',
     mixins: ['Proxmox.Mixin.CBind'],
 
+    updateSnatState: function (cidr) {
+        let me = this;
+        let snatField = me.down('[name=snat]');
+        if (!snatField) {
+            return;
+        }
+
+        let addr = cidr ? cidr.split('/')[0] : '';
+        let isV6 = !!addr && Proxmox.Utils.IP6_match.test(addr);
+        snatField.setDisabled(isV6);
+        if (isV6) {
+            snatField.setValue(false);
+        }
+    },
+
     onGetValues: function (values) {
         let me = this;
 
@@ -24,6 +39,14 @@ Ext.define('PVE.sdn.SubnetInputPanel', {
             flex: 1,
             allowBlank: false,
             fieldLabel: gettext('Subnet'),
+            listeners: {
+                change: function (field, value) {
+                    let panel = field.up('inputpanel');
+                    if (panel) {
+                        panel.updateSnatState(value);
+                    }
+                },
+            },
         },
         {
             xtype: 'proxmoxtextfield',
@@ -288,6 +311,7 @@ Ext.define('PVE.sdn.SubnetEdit', {
             me.load({
                 success: function (response, options) {
                     me.setValues(response.result.data);
+                    ipanel.updateSnatState(response.result.data.cidr);
                 },
             });
         }
-- 
2.47.3





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

* [PATCH pve-manager v2 09/11] ui: sdn: add IPv6 RA / SLAAC support
  2026-04-30 14:29 [PATCH docs/manager/network/proxmox{-ve-rs,-perl-rs} v2 00/11] sdn: evpn: add IPv6 RA / SLAAC support Hannes Laimer
                   ` (7 preceding siblings ...)
  2026-04-30 14:29 ` [PATCH pve-manager v2 08/11] ui: sdn: disable SNAT for IPv6 subnets Hannes Laimer
@ 2026-04-30 14:29 ` Hannes Laimer
  2026-04-30 14:29 ` [PATCH pve-docs v2 10/11] sdn: document IPv6 RA / SLAAC configuration Hannes Laimer
  2026-04-30 14:29 ` [PATCH pve-docs v2 11/11] sdn: add example for IPv6 in an EVPN zone Hannes Laimer
  10 siblings, 0 replies; 12+ messages in thread
From: Hannes Laimer @ 2026-04-30 14:29 UTC (permalink / raw)
  To: pve-devel

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

The vnet edit gains an "IPv6 Router Advertisement" tab, only enabled
for EVPN zones. The subnet edit's IPv6 panel is reduced to per-prefix
overrides, only active on IPv6 CIDRs and with the autonomous 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       |  96 +++++++++++++++-
 www/manager6/sdn/SubnetView.js       |   6 +-
 www/manager6/sdn/VnetEdit.js         | 157 ++++++++++++++++++++++++++-
 www/manager6/sdn/VnetView.js         |   6 +-
 5 files changed, 259 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 630b10b8..a462b563 100644
--- a/www/manager6/sdn/SubnetEdit.js
+++ b/www/manager6/sdn/SubnetEdit.js
@@ -45,6 +45,11 @@ Ext.define('PVE.sdn.SubnetInputPanel', {
                     if (panel) {
                         panel.updateSnatState(value);
                     }
+                    let win = field.up('window');
+                    let ipv6 = win?.down('#ipv6Panel');
+                    if (ipv6) {
+                        ipv6.updateForCidr(value);
+                    }
                 },
             },
         },
@@ -82,6 +87,77 @@ 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);
+
+        let autonomous = me.down('[name=nd-prefix-autonomous]');
+        if (autonomous) {
+            autonomous.setDisabled(!isSlash64);
+            if (isV6 && !isSlash64) {
+                autonomous.setValue(false);
+            }
+        }
+    },
+
+    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,
+            allowBlank: true,
+            emptyText: '2592000',
+            cbind: {
+                deleteEmpty: '{!isCreate}',
+            },
+        },
+        {
+            xtype: 'proxmoxintegerfield',
+            name: 'nd-prefix-preferred-lifetime',
+            fieldLabel: gettext('Preferred Lifetime (s)'),
+            minValue: 0,
+            allowBlank: true,
+            emptyText: '604800',
+            cbind: {
+                deleteEmpty: '{!isCreate}',
+            },
+        },
+    ],
+});
+
 Ext.define('PVE.sdn.SubnetDhcpRangePanel', {
     extend: 'Ext.form.FieldContainer',
     mixins: ['Ext.form.field.Field'],
@@ -261,6 +337,7 @@ Ext.define('PVE.sdn.SubnetDhcpRangePanel', {
 Ext.define('PVE.sdn.SubnetEdit', {
     extend: 'Proxmox.window.Edit',
 
+    onlineHelp: 'pvesdn_config_subnet_nd_prefix',
     subject: gettext('Subnet'),
 
     subnet: undefined,
@@ -268,6 +345,7 @@ Ext.define('PVE.sdn.SubnetEdit', {
     width: 350,
 
     base_url: undefined,
+    zoneType: undefined,
 
     bodyPadding: 0,
 
@@ -295,23 +373,37 @@ Ext.define('PVE.sdn.SubnetEdit', {
             name: 'dhcp-range',
         });
 
+        let tabItems = [ipanel, dhcpPanel];
+        let ipv6Panel;
+        if (me.zoneType === 'evpn') {
+            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);
                     ipanel.updateSnatState(response.result.data.cidr);
+                    ipv6Panel?.updateForCidr(response.result.data.cidr);
                 },
             });
         }
diff --git a/www/manager6/sdn/SubnetView.js b/www/manager6/sdn/SubnetView.js
index c61458e0..1eee33d4 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();
@@ -50,6 +52,7 @@ Ext.define(
                     autoShow: true,
                     subnet: rec.data.subnet,
                     base_url: me.base_url,
+                    zoneType: me.zone_type,
                 });
                 win.on('destroy', reload);
             };
@@ -62,6 +65,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 34e382c7..8dc48edc 100644
--- a/www/manager6/sdn/VnetEdit.js
+++ b/www/manager6/sdn/VnetEdit.js
@@ -56,6 +56,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');
                 },
             },
         },
@@ -117,14 +120,148 @@ Ext.define('PVE.sdn.VnetInputPanel', {
     },
 });
 
+Ext.define('PVE.sdn.VnetIPv6RAPanel', {
+    extend: 'Proxmox.panel.InputPanel',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    onGetValues: function (values) {
+        if (values['ipv6-ra-rdnss']) {
+            values['ipv6-ra-rdnss'] = values['ipv6-ra-rdnss']
+                .split(/[\s,]+/)
+                .filter((v) => v.length > 0);
+        }
+        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;
@@ -141,10 +278,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();
@@ -154,6 +305,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] 12+ messages in thread

* [PATCH pve-docs v2 10/11] sdn: document IPv6 RA / SLAAC configuration
  2026-04-30 14:29 [PATCH docs/manager/network/proxmox{-ve-rs,-perl-rs} v2 00/11] sdn: evpn: add IPv6 RA / SLAAC support Hannes Laimer
                   ` (8 preceding siblings ...)
  2026-04-30 14:29 ` [PATCH pve-manager v2 09/11] ui: sdn: add IPv6 RA / SLAAC support Hannes Laimer
@ 2026-04-30 14:29 ` Hannes Laimer
  2026-04-30 14:29 ` [PATCH pve-docs v2 11/11] sdn: add example for IPv6 in an EVPN zone Hannes Laimer
  10 siblings, 0 replies; 12+ messages in thread
From: Hannes Laimer @ 2026-04-30 14:29 UTC (permalink / raw)
  To: pve-devel

Document the per-VNet and per-subnet RA configuration and how they
combine, so the typical SLAAC setup is one toggle on the VNet plus
the IPv6 subnets' default per-prefix settings.

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

diff --git a/pvesdn.adoc b/pvesdn.adoc
index 9501bd4..74ce953 100644
--- a/pvesdn.adoc
+++ b/pvesdn.adoc
@@ -422,6 +422,37 @@ 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.
 
+[[pvesdn_config_vnet_ipv6_ra]]
+IPv6 Router Advertisement (EVPN)
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+For VNets in an EVPN zone, the per-node anycast gateway can emit IPv6 Router
+Advertisements so guests configure addresses via SLAAC. Per-RA settings live on
+the VNet, per-prefix overrides on each subnet (see
+xref:pvesdn_config_subnet_nd_prefix[Per-prefix RA overrides]).
+
+Send Router Advertisements:: Master toggle. When enabled, the VNet bridge emits
+  RAs. Only valid in EVPN zones.
+
+DHCP Managed (M):: Set the managed-address configuration (M) flag in emitted
+  RAs. Hosts should obtain addresses via DHCPv6.
+
+DHCP Other (O):: Set the other-configuration (O) flag in emitted RAs. Hosts
+  should obtain non-address configuration (e.g. DNS) via DHCPv6.
+
+RDNSS:: Comma-separated list of IPv6 addresses advertised as Recursive DNS
+  Servers in RAs.
+
+Router Lifetime:: Default-router lifetime advertised in RAs (seconds). `0` tells
+  hosts not to use this gateway as a default router. Defaults to FRR's built-in
+  value (1800s) if unset.
+
+RA Interval:: Maximum interval between unsolicited RAs (seconds). Defaults to
+  FRR's built-in value (600s) if unset.
+
+Advertised MTU:: Optional MTU to advertise in the RA. Omit to suppress the MTU
+  option entirely.
+
 [[pvesdn_config_subnet]]
 Subnets
 -------
@@ -455,6 +486,31 @@ 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.
 
+[[pvesdn_config_subnet_nd_prefix]]
+Per-prefix RA overrides (EVPN)
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+For IPv6 subnets on a VNet that has
+xref:pvesdn_config_vnet_ipv6_ra[Router Advertisements] enabled, each subnet
+contributes a prefix to the RA. The defaults (advertise the prefix with the
+autonomous and on-link flags set, FRR's default lifetimes) cover the typical
+SLAAC case. The options below let you override per prefix.
+
+SLAAC (A):: Set the autonomous configuration (A) flag for this prefix, enabling
+  SLAAC. Defaults to enabled. Only valid on `/64` prefixes
+  footnote:[RFC-4862 https://datatracker.ietf.org/doc/html/rfc4862].
+  Uncheck it for non-/64 prefixes (e.g. `/96`) so the prefix is announced
+  without autoconfig.
+
+On-link (L):: Set the on-link (L) flag for this prefix. Defaults to on-link.
+  Uncheck for off-link prefixes hosts should reach only via the router.
+
+Valid Lifetime:: Valid lifetime for the prefix advertised in RAs, in seconds.
+  Defaults to 30 days (2592000s).
+
+Preferred Lifetime:: Preferred lifetime for the prefix advertised in RAs, in
+  seconds. Defaults to 7 days (604800s).
+
 
 [[pvesdn_config_controllers]]
 Controllers
@@ -1495,7 +1551,6 @@ can reply back.
 If you have configured an external BGP router, the BGP-EVPN routes (10.0.1.0/24
 and 10.0.2.0/24 in this example), will be announced dynamically.
 
-
 [[pvesdn_notes]]
 Notes
 -----
-- 
2.47.3





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

* [PATCH pve-docs v2 11/11] sdn: add example for IPv6 in an EVPN zone
  2026-04-30 14:29 [PATCH docs/manager/network/proxmox{-ve-rs,-perl-rs} v2 00/11] sdn: evpn: add IPv6 RA / SLAAC support Hannes Laimer
                   ` (9 preceding siblings ...)
  2026-04-30 14:29 ` [PATCH pve-docs v2 10/11] sdn: document IPv6 RA / SLAAC configuration Hannes Laimer
@ 2026-04-30 14:29 ` Hannes Laimer
  10 siblings, 0 replies; 12+ messages in thread
From: Hannes Laimer @ 2026-04-30 14:29 UTC (permalink / raw)
  To: pve-devel

End-to-end walkthrough of an EVPN zone serving IPv6, with a /64 SLAAC
subnet and a /96 announce-only subnet, to show how the per-vnet RA
toggle and per-prefix overrides interact in practice.

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

diff --git a/pvesdn.adoc b/pvesdn.adoc
index 74ce953..2618506 100644
--- a/pvesdn.adoc
+++ b/pvesdn.adoc
@@ -1551,6 +1551,222 @@ can reply back.
 If you have configured an external BGP router, the BGP-EVPN routes (10.0.1.0/24
 and 10.0.2.0/24 in this example), will be announced dynamically.
 
+[[pvesdn_setup_example_evpn_ipv6]]
+EVPN with IPv6 Setup Example
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+The example assumes a cluster with three nodes (node1, node2, node3) with P2P
+connections between `node1<->node2` and `node2<->node3`. Additionally we'll
+also need an `/48` IPv6 prefix routed to the exit node.
+
+We will start by creating an OpenFabric which will be the underlay for our EVPN
+zone. Then we'll set up a VNet with two subnets, one with SLAAC and a `/96` one
+with RA enabled and statically assigned IPs.
+
+.Prefixes
+. to `node2` routed `/48`: `2001:db8:abcd::/48`
+. `/64` subnet with SLAAC: `2001:db8:abcd:1::/64`
+. `/96` subnet without SLAAC: `2001:db8:abcd:2::/96`
+
+.IPv6 forwarding
+This has to be enabled on all nodes.
+----
+sysctl -w net.ipv6.conf.all.forwarding=1
+----
+
+NOTE: The underlay will be IPv4 based, the overlay IPv6.
+
+Setup `OpenFabric`
+^^^^^^^^^^^^^^^^^^
+
+. Create the fabric with `10.0.0.0/24` as its IPv4 prefix and `underlay` as its
+  name, we can leave the defaults for the rest
+. Add the three nodes to the fabric,
+.. `node1` with `10.0.0.1`, select P2P NIC between `node1<->node2`
+.. `node2` with `10.0.0.2`, select both P2P NICs between `node1<->node2` and
+   `node2<->node3`
+.. `node3` with `10.0.0.3`, select P2P NIC between `node2<->node3`
+. Apply the changes
+
+The resulting config in `/etc/pve/sdn/fabrics.cfg` should look something like
+this:
+
+----
+openfabric_fabric: underlay
+	ip_prefix 10.0.0.0/24
+
+openfabric_node: underlay_node1
+	interfaces name=ens19
+	ip 10.0.0.1
+
+openfabric_node: underlay_node2
+	interfaces name=ens20
+	interfaces name=ens19
+	ip 10.0.0.2
+
+openfabric_node: underlay_node3
+	interfaces name=ens19
+	ip 10.0.0.3
+----
+
+Each node should now also be able to reach all of the others, this can quickly
+be checked with a
+
+----
+ping 10.0.0.X
+----
+
+With `ip -4 r` you should also be able to see the respective `onlink` routes.
+
+----
+10.0.0.2 nhid 28 via 10.0.0.2 dev ens19 proto openfabric src 10.0.0.1 metric 20 onlink
+10.0.0.3 nhid 28 via 10.0.0.2 dev ens19 proto openfabric src 10.0.0.1 metric 20 onlink
+----
+
+If this works we can continue.
+
+Create controller
+^^^^^^^^^^^^^^^^^
+
+Create an EVPN controller, keep `65000` as `ASN#` and select the `underlay`
+fabric we have just created. For this we'll name the controller `v6ctl`. Then
+apply the changes.
+
+The relevant config here is `/etc/pve/sdn/controllers.cfg`, and should look
+like this:
+
+----
+evpn: v6ctl
+	asn 65000
+	fabric underlay
+----
+
+Setup the zone
+^^^^^^^^^^^^^^
+
+Create a new EVPN zone, select the `v6ctl` as its controller, set the
+`VRF_VXLAN Tag` to `6` and name it `v6zone`. As exit node select the node that
+the `/48` prefix is routed to. Then apply the changes.
+
+The relevant config here is `/etc/pve/sdn/zones.cfg`, and after applying should
+look something like this:
+
+----
+evpn: v6zone
+	controller v6ctl
+	vrf-vxlan 6
+	exitnodes node2
+	ipam pve
+	mac BC:24:11:F2:72:B8
+----
+
+
+Create the VNets
+^^^^^^^^^^^^^^^^
+
+.The bigger `/64` with SLAAC
+Create a new VNet, name it `net64`, select `v6zone` as zone, set `64` as tag,
+and under "IPv6 Router Advertisement" check `Send Router Advertisements`.
+
+.The smaller `/96` without SLAAC and static IPs
+Create a new VNet, name it `net96`, select `v6zone` as zone, set `96` as tag,
+and under "IPv6 Router Advertisement" check `Send Router Advertisements`.
+
+After applying `/etc/pve/sdn/vnets.cfg` should contain:
+
+----
+vnet: net64
+	zone v6zone
+	tag 64
+	ipv6-ra 1
+
+vnet: net96
+	zone v6zone
+	tag 96
+	ipv6-ra 1
+----
+
+
+Setup the subnets
+^^^^^^^^^^^^^^^^^
+
+First we'll configure the `/64` subnet in `net64`. The defaults already enable
+SLAAC, so no overrides are needed:
+
+. Select the `net64` vnet
+. Create subnet with `2001:db8:abcd:1::/64` and `2001:db8:abcd:1::1/64` as
+  gateway
+
+Then configure the `/96` subnet in `net96`. SLAAC is only valid on `/64`, so
+under "IPv6 Prefix Options" uncheck `SLAAC (A)`:
+
+. Select the `net96` vnet
+. Create subnet with `2001:db8:abcd:2::/96` and `2001:db8:abcd:2::1/96` as
+  gateway
+. Under "IPv6 Prefix Options" uncheck `SLAAC (A)`
+
+NOTE: With RAs enabled a default gateway is advertised so we don't have to
+specify a gateway explicitly for guests with a static IP. The vnet's RDNSS list
+is also advertised, which can be handy because usually we'd need a whole DHCP
+server for that. Whether the advertised DNS is used is somewhat client
+dependent, as sometimes this option is ignored.
+
+The relevant config for these is `/etc/pve/sdn/subnets.cfg`, and after applying
+these changes should contain
+
+----
+subnet: v6zone-2001:db8:abcd:1::-64
+	vnet net64
+	gateway 2001:db8:abcd:1::1
+
+subnet: v6zone-2001:db8:abcd:2::-96
+	vnet net96
+	gateway 2001:db8:abcd:2::1
+	nd-prefix-autonomous 0
+----
+
+Result
+^^^^^^
+
+Now we can create two guests to test this, we'll use Debian CTs here, but any
+guest will do.
+
+. configure first guest
+.. select `net64` as bridge
+.. leave the IPv4 section `static` and the IP field empty
+.. for IPv6 select `SLAAC`
+. configure second guest
+.. select `net96` as bridge
+.. leave the IPv4 section `static` and the IP field empty
+.. for IPv6 select `static` and set `2001:db8:abcd:2::200/96` as `IPv6`
+
+NOTE: We can leave `gateway` empty because we have RAs enabled on `net96`,
+setting it is also not a problem, just not needed.
+
+Both guests should have a default route via the gateway's link-local address,
+`ip -6 r`
+
+----
+default via fe80::be24:11ff:fef2:72b8 dev eth0 proto ra metric 1024 expires 1799sec hoplimit 64 pref medium
+----
+
+This address will match the link-local address on the host's `net64` (or `net96`) interfaces, `ip a show net64`.
+
+----
+7: net64: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1450 qdisc noqueue master vrf_v6zone state UP group default qlen 1000
+    link/ether bc:24:11:f2:72:b8 brd ff:ff:ff:ff:ff:ff
+    inet6 2001:db8:abcd:1::1/64 scope global
+       valid_lft forever preferred_lft forever
+    inet6 fe80::be24:11ff:fef2:72b8/64 scope link proto kernel_ll
+       valid_lft forever preferred_lft forever
+----
+
+Before the guest's first traffic, `ip -6 neigh show vrf vrf_v6zone` will only
+contain an entry for the guest's link-local address. After the first `ping
+2001:db8::1`, also the guest's GUA should have an entry.
+
+
+
 [[pvesdn_notes]]
 Notes
 -----
-- 
2.47.3





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

end of thread, other threads:[~2026-04-30 14:31 UTC | newest]

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

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