* [PATCH docs/manager/network/proxmox{-perl-rs,-ve-rs} v5 0/9] add IPv6 RA / SLAAC support to EVPN zones
@ 2026-10-29 15:41 Hannes Laimer
2026-10-29 15:41 ` [PATCH proxmox-perl-rs v5 1/9] pve-rs: sdn: add IPv6 RA builder binding Hannes Laimer
` (9 more replies)
0 siblings, 10 replies; 12+ messages in thread
From: Hannes Laimer @ 2026-10-29 15:41 UTC (permalink / raw)
To: pve-devel
This series adds IPv6 Router Advertisement / SLAAC support to EVPN
zones.
RA settings sit on the vnet (`vnets.cfg`), per-prefix overrides on the
subnet (`subnets.cfg`). Defaults are chosen so that most IPv6 subnets
work without explicit per-prefix configuration.
It also sets `accept_untracked_na=2` on every EVPN vnet bridge that has
an IPv6 subnet, so an exit node can reach a guest it has no neighbor
entry for yet. The commit has the details.
Dependencies:
- proxmox-ve-rs: the new proxmox-frr-templates
- proxmox-perl-rs: proxmox-ve-rs
- pve-network: proxmox-perl-rs
- pve-manager: pve-network
- pve-docs: none
v5:
- respect local overrides for ND, like ISIS, note: ospf/openfabric
should probably as well
- api: return correct data on pending=1
- use only one spec for api fields
- add some tests
v4:
- rebased onto current master
- docs rewritten
- corrected router lifetime default placeholder
- reorder commits
v3, thanks @Lukas S!:
- validation no longer rejects valid IPv6 subnets: non-/64 prefixes and IPv6
subnets using SNAT are accepted and editable again
- improve FRR config generation so a single malformed subnet or a non-member
node can no longer break it, and so configured prefix lifetimes are no
longer silently dropped
- cleaner EVPN gating and better per-prefix defaults, so most IPv6 subnets
work without explicit configuration
- cleanup UI, only persists meaningful settings
- some smaller fixups
proxmox-perl-rs:
Hannes Laimer (1):
pve-rs: sdn: add IPv6 RA builder binding
pve-rs/Makefile | 1 +
pve-rs/src/bindings/sdn/ipv6_ra.rs | 40 ++++++++++++++++++++++++++++++
pve-rs/src/bindings/sdn/mod.rs | 1 +
3 files changed, 42 insertions(+)
create mode 100644 pve-rs/src/bindings/sdn/ipv6_ra.rs
proxmox-ve-rs:
Hannes Laimer (2):
frr: add IPv6 router advertisement support
ve-config: sdn: add IPv6 router advertisement settings
.../templates/frr.conf.jinja | 1 +
.../templates/nd_interfaces.jinja | 28 +++
proxmox-frr/src/ser/mod.rs | 6 +
proxmox-frr/src/ser/nd.rs | 56 ++++++
proxmox-frr/src/ser/serializer.rs | 6 +-
proxmox-ve-config/src/sdn/config.rs | 146 +++++++++++++++-
proxmox-ve-config/src/sdn/mod.rs | 1 +
proxmox-ve-config/src/sdn/nd.rs | 131 ++++++++++++++
proxmox-ve-config/tests/nd/main.rs | 164 ++++++++++++++++++
.../nd__explicit_lifetimes_are_preserved.snap | 9 +
.../nd__mixed_subnets_under_one_vnet.snap | 14 ++
.../snapshots/nd__no_autoconfig_prefix.snap | 9 +
...sh64_prefix_defaults_to_no_autoconfig.snap | 9 +
.../nd__off_link_emits_off_link_modifier.snap | 9 +
...__preferred_lifetime_clamped_to_valid.snap | 9 +
...vel_optional_knobs_are_passed_through.snap | 12 ++
.../nd__slaac_with_default_lifetimes.snap | 9 +
17 files changed, 611 insertions(+), 8 deletions(-)
create mode 100644 proxmox-frr-templates/templates/nd_interfaces.jinja
create mode 100644 proxmox-frr/src/ser/nd.rs
create mode 100644 proxmox-ve-config/src/sdn/nd.rs
create mode 100644 proxmox-ve-config/tests/nd/main.rs
create mode 100644 proxmox-ve-config/tests/nd/snapshots/nd__explicit_lifetimes_are_preserved.snap
create mode 100644 proxmox-ve-config/tests/nd/snapshots/nd__mixed_subnets_under_one_vnet.snap
create mode 100644 proxmox-ve-config/tests/nd/snapshots/nd__no_autoconfig_prefix.snap
create mode 100644 proxmox-ve-config/tests/nd/snapshots/nd__non_slash64_prefix_defaults_to_no_autoconfig.snap
create mode 100644 proxmox-ve-config/tests/nd/snapshots/nd__off_link_emits_off_link_modifier.snap
create mode 100644 proxmox-ve-config/tests/nd/snapshots/nd__preferred_lifetime_clamped_to_valid.snap
create mode 100644 proxmox-ve-config/tests/nd/snapshots/nd__ra_level_optional_knobs_are_passed_through.snap
create mode 100644 proxmox-ve-config/tests/nd/snapshots/nd__slaac_with_default_lifetimes.snap
pve-manager:
Hannes Laimer (1):
ui: sdn: add IPv6 RA / SLAAC support
www/manager6/form/SDNVnetSelector.js | 2 +-
www/manager6/sdn/SubnetEdit.js | 130 ++++++++++++++++-
www/manager6/sdn/SubnetView.js | 7 +-
www/manager6/sdn/VnetEdit.js | 202 ++++++++++++++++++++++++++-
www/manager6/sdn/VnetView.js | 6 +-
5 files changed, 339 insertions(+), 8 deletions(-)
pve-network:
Hannes Laimer (4):
sdn: evpn: derive IP version from CIDR for gateway-less subnets
sdn: evpn: accept untracked IPv6 neighbor advertisements
sdn: evpn: add IPv6 RA / SLAAC support
api: vnets: include the zone type in the vnet list
src/PVE/API2/Network/SDN/Vnets.pm | 19 ++
src/PVE/Network/SDN.pm | 1 +
src/PVE/Network/SDN/Controllers.pm | 21 +-
src/PVE/Network/SDN/Controllers/EvpnPlugin.pm | 22 +-
src/PVE/Network/SDN/Controllers/Plugin.pm | 2 +-
src/PVE/Network/SDN/SubnetPlugin.pm | 64 +++++
src/PVE/Network/SDN/VnetPlugin.pm | 85 +++++++
src/PVE/Network/SDN/Zones/EvpnPlugin.pm | 16 +-
src/PVE/Network/SDN/Zones/Plugin.pm | 6 +
src/test/run_test_vnets_blackbox.pl | 226 ++++++++++++++++++
.../expected_sdn_interfaces | 1 +
.../exitnode_snat/expected_sdn_interfaces | 1 +
.../exitnodenullroute/expected_sdn_interfaces | 1 +
.../evpn/ipv4ipv6/expected_sdn_interfaces | 1 +
.../ipv4ipv6nogateway/expected_sdn_interfaces | 1 +
.../zones/evpn/ipv6/expected_sdn_interfaces | 1 +
.../evpn/ipv6underlay/expected_sdn_interfaces | 1 +
.../evpn/slaac/expected_controller_config | 53 ++++
.../zones/evpn/slaac/expected_sdn_interfaces | 44 ++++
src/test/zones/evpn/slaac/interfaces | 7 +
src/test/zones/evpn/slaac/sdn_config | 41 ++++
21 files changed, 608 insertions(+), 6 deletions(-)
create mode 100644 src/test/zones/evpn/slaac/expected_controller_config
create mode 100644 src/test/zones/evpn/slaac/expected_sdn_interfaces
create mode 100644 src/test/zones/evpn/slaac/interfaces
create mode 100644 src/test/zones/evpn/slaac/sdn_config
pve-docs:
Hannes Laimer (1):
sdn: add IPv6 RA / SLAAC section
pvesdn.adoc | 71 +++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 71 insertions(+)
Summary over all repositories:
47 files changed, 1671 insertions(+), 22 deletions(-)
--
Generated by murpp 0.12.0
^ permalink raw reply [flat|nested] 12+ messages in thread
* [PATCH proxmox-perl-rs v5 1/9] pve-rs: sdn: add IPv6 RA builder binding
2026-10-29 15:41 [PATCH docs/manager/network/proxmox{-perl-rs,-ve-rs} v5 0/9] add IPv6 RA / SLAAC support to EVPN zones Hannes Laimer
@ 2026-10-29 15:41 ` Hannes Laimer
2026-10-29 15:41 ` [PATCH proxmox-ve-rs v5 2/9] frr: add IPv6 router advertisement support Hannes Laimer
` (8 subsequent siblings)
9 siblings, 0 replies; 12+ messages in thread
From: Hannes Laimer @ 2026-10-29 15:41 UTC (permalink / raw)
To: pve-devel
Perl builds the FRR config handed in for rendering, while the
conversion of the IPv6 Router Advertisement settings for it lives in
Rust. The binding takes a vnet and its subnets as the running config
holds them and returns the vnet's entry for the RA part of that FRR
config, with the prefix defaults applied.
Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
pve-rs/Makefile | 1 +
pve-rs/src/bindings/sdn/ipv6_ra.rs | 40 ++++++++++++++++++++++++++++++
pve-rs/src/bindings/sdn/mod.rs | 1 +
3 files changed, 42 insertions(+)
create mode 100644 pve-rs/src/bindings/sdn/ipv6_ra.rs
diff --git a/pve-rs/Makefile b/pve-rs/Makefile
index bb1cd2d5f73a..0dfd672fa496 100644
--- a/pve-rs/Makefile
+++ b/pve-rs/Makefile
@@ -33,6 +33,7 @@ PERLMOD_PACKAGES := \
PVE::RS::ResourceScheduling::Static \
PVE::RS::ResourceScheduling::Dynamic \
PVE::RS::SDN::Fabrics \
+ PVE::RS::SDN::IPv6RA \
PVE::RS::SDN::PrefixLists \
PVE::RS::SDN::RouteMaps \
PVE::RS::SDN::WireGuard::PrivateKeys \
diff --git a/pve-rs/src/bindings/sdn/ipv6_ra.rs b/pve-rs/src/bindings/sdn/ipv6_ra.rs
new file mode 100644
index 000000000000..7e7ed84c788b
--- /dev/null
+++ b/pve-rs/src/bindings/sdn/ipv6_ra.rs
@@ -0,0 +1,40 @@
+#[perlmod::package(name = "PVE::RS::SDN::IPv6RA", lib = "pve_rs")]
+pub mod pve_rs_sdn_ipv6_ra {
+ //! The `PVE::RS::SDN::IPv6RA` package.
+ //!
+ //! This builds the FRR IPv6 Router Advertisement configuration of a vnet from the
+ //! running config of the vnet and its subnets.
+
+ use std::collections::BTreeMap;
+
+ use anyhow::{Context, Error};
+
+ use proxmox_frr::ser::nd::NdInterface;
+ use proxmox_ve_config::sdn::{
+ SubnetName, VnetName,
+ config::{SubnetConfig, SubnetRunningConfig, VnetConfig, VnetRunningConfig},
+ nd::frr::build_nd_interface,
+ };
+
+ /// Method: Build the [`NdInterface`] of a vnet. Returns `None` when the vnet has RAs
+ /// off or no IPv6 subnet.
+ #[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 dcae046a7e46..fb50526785e5 100644
--- a/pve-rs/src/bindings/sdn/mod.rs
+++ b/pve-rs/src/bindings/sdn/mod.rs
@@ -1,4 +1,5 @@
pub(crate) mod fabrics;
+pub(crate) mod ipv6_ra;
pub(crate) mod prefix_lists;
pub(crate) mod route_maps;
pub(crate) mod wireguard;
--
2.47.3
^ permalink raw reply related [flat|nested] 12+ messages in thread
* [PATCH proxmox-ve-rs v5 2/9] frr: add IPv6 router advertisement support
2026-10-29 15:41 [PATCH docs/manager/network/proxmox{-perl-rs,-ve-rs} v5 0/9] add IPv6 RA / SLAAC support to EVPN zones Hannes Laimer
2026-10-29 15:41 ` [PATCH proxmox-perl-rs v5 1/9] pve-rs: sdn: add IPv6 RA builder binding Hannes Laimer
@ 2026-10-29 15:41 ` Hannes Laimer
2026-10-29 15:41 ` [PATCH proxmox-ve-rs v5 3/9] ve-config: sdn: add IPv6 router advertisement settings Hannes Laimer
` (7 subsequent siblings)
9 siblings, 0 replies; 12+ messages in thread
From: Hannes Laimer @ 2026-10-29 15:41 UTC (permalink / raw)
To: pve-devel
The generated FRR config can now carry IPv6 Router Advertisement
settings per interface, along with the prefixes to announce. Each of
these interfaces gets a block of ipv6 nd commands that turns its
advertisements on. Settings left unset are not rendered, so FRR keeps
its defaults for them.
Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
Notes:
v5:
- the ND block is rendered ahead of the node-local config, so local overrides win
v3:
- keep prefix lifetimes on no-autoconfig prefixes (were dropped)
- emit ra-interval before ra-lifetime
- single prefix line instead of duplicated autoconfig branches
.../templates/frr.conf.jinja | 1 +
.../templates/nd_interfaces.jinja | 28 ++++++++++
proxmox-frr/src/ser/mod.rs | 6 ++
proxmox-frr/src/ser/nd.rs | 56 +++++++++++++++++++
proxmox-frr/src/ser/serializer.rs | 6 +-
5 files changed, 96 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 1f98489e09fb..8d96648e5e02 100644
--- a/proxmox-frr-templates/templates/frr.conf.jinja
+++ b/proxmox-frr-templates/templates/frr.conf.jinja
@@ -1,6 +1,7 @@
{% include "bgpd.jinja" %}
{% include "isisd.jinja" %}
{% include "prefix_lists.jinja" %}
+{% include "nd_interfaces.jinja" %}
{% for line in custom_frr_config %}
{{ line }}
{% endfor %}
diff --git a/proxmox-frr-templates/templates/nd_interfaces.jinja b/proxmox-frr-templates/templates/nd_interfaces.jinja
new file mode 100644
index 000000000000..dd047a93a6af
--- /dev/null
+++ b/proxmox-frr-templates/templates/nd_interfaces.jinja
@@ -0,0 +1,28 @@
+{% for name, iface in nd_interfaces|items %}
+!
+interface {{ name }}
+ no ipv6 nd suppress-ra
+{% if iface.managed_config_flag %}
+ ipv6 nd managed-config-flag
+{% endif %}
+{% if iface.other_config_flag %}
+ ipv6 nd other-config-flag
+{% endif %}
+{% if iface.interval is not none %}
+ ipv6 nd ra-interval {{ iface.interval }}
+{% endif %}
+{% if iface.router_lifetime is not none %}
+ ipv6 nd ra-lifetime {{ iface.router_lifetime }}
+{% endif %}
+{% if iface.mtu is not none %}
+ ipv6 nd mtu {{ iface.mtu }}
+{% endif %}
+{% for rdnss in iface.rdnss %}
+ ipv6 nd rdnss {{ rdnss }}
+{% endfor %}
+{% for prefix in iface.prefixes %}
+ ipv6 nd prefix {{ prefix.cidr }} {{ prefix.valid }} {{ prefix.preferred }}{% if not prefix.autonomous %} no-autoconfig{% endif %}{% if not prefix.on_link %} off-link{% endif %}
+
+{% endfor %}
+exit
+{% endfor %}
diff --git a/proxmox-frr/src/ser/mod.rs b/proxmox-frr/src/ser/mod.rs
index b651121b4a82..a65ee46270b5 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 000000000000..4eef037511da
--- /dev/null
+++ b/proxmox-frr/src/ser/nd.rs
@@ -0,0 +1,56 @@
+use std::net::Ipv6Addr;
+
+use proxmox_network_types::ip_address::Ipv6Cidr;
+use serde::{Deserialize, Serialize};
+
+fn default_true() -> bool {
+ true
+}
+
+/// A prefix advertised in Router Advertisements on an interface.
+///
+/// Both lifetimes are emitted in seconds whether or not the prefix is autonomous. The
+/// preferred lifetime must not exceed the valid one, FRR rejects the prefix otherwise.
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+pub struct NdPrefix {
+ pub cidr: Ipv6Cidr,
+ #[serde(
+ default = "default_true",
+ deserialize_with = "proxmox_serde::perl::deserialize_bool"
+ )]
+ pub autonomous: bool,
+ #[serde(
+ default = "default_true",
+ deserialize_with = "proxmox_serde::perl::deserialize_bool"
+ )]
+ pub on_link: bool,
+ #[serde(deserialize_with = "proxmox_serde::perl::deserialize_u32")]
+ pub valid: u32,
+ #[serde(deserialize_with = "proxmox_serde::perl::deserialize_u32")]
+ pub preferred: u32,
+}
+
+/// Router Advertisement configuration of an interface, one `ipv6 nd` command per field.
+///
+/// An entry for an interface turns its RAs on, the generated config emits
+/// `no ipv6 nd suppress-ra` for it.
+#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+pub struct NdInterface {
+ #[serde(default, deserialize_with = "proxmox_serde::perl::deserialize_bool")]
+ pub managed_config_flag: bool,
+ #[serde(default, deserialize_with = "proxmox_serde::perl::deserialize_bool")]
+ pub other_config_flag: bool,
+ #[serde(default)]
+ pub rdnss: Vec<Ipv6Addr>,
+ /// `None` leaves the router lifetime to FRR, three times the RA interval. A value above
+ /// 0 has to be at least the interval, which is why the template renders the interval
+ /// first, FRR checks the lifetime against the interval it has at that point.
+ #[serde(default)]
+ pub router_lifetime: Option<u32>,
+ #[serde(default)]
+ pub interval: Option<u32>,
+ #[serde(default)]
+ pub mtu: Option<u32>,
+ #[serde(default)]
+ pub prefixes: Vec<NdPrefix>,
+}
diff --git a/proxmox-frr/src/ser/serializer.rs b/proxmox-frr/src/ser/serializer.rs
index 2ac85d8cf6c5..5b5d5a5081ab 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 v5 3/9] ve-config: sdn: add IPv6 router advertisement settings
2026-10-29 15:41 [PATCH docs/manager/network/proxmox{-perl-rs,-ve-rs} v5 0/9] add IPv6 RA / SLAAC support to EVPN zones Hannes Laimer
2026-10-29 15:41 ` [PATCH proxmox-perl-rs v5 1/9] pve-rs: sdn: add IPv6 RA builder binding Hannes Laimer
2026-10-29 15:41 ` [PATCH proxmox-ve-rs v5 2/9] frr: add IPv6 router advertisement support Hannes Laimer
@ 2026-10-29 15:41 ` Hannes Laimer
2026-10-29 15:41 ` [PATCH pve-manager v5 4/9] ui: sdn: add IPv6 RA / SLAAC support Hannes Laimer
` (6 subsequent siblings)
9 siblings, 0 replies; 12+ messages in thread
From: Hannes Laimer @ 2026-10-29 15:41 UTC (permalink / raw)
To: pve-devel
Guests can only configure IPv6 addresses with SLAAC when their
gateway sends router advertisements. Parsing the running config now
picks up the RA settings of a vnet and the prefix settings of its
subnets. The FRR ND config of a vnet's interface is built from the
vnet and its subnets, with one prefix per IPv6 subnet. A vnet without
RAs enabled or without an IPv6 subnet gets none.
An RA goes out on the vnet's interface and carries one prefix option
per subnet. So the vnet holds the RA settings and each subnet those
of its prefix. Prefixes are on-link by default. They are autonomous
by default only as a /64, since SLAAC needs one.
Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
Notes:
v3:
- clamp the default preferred lifetime to a shorter valid lifetime
- boolean fields use Option<bool> with deserialize_bool
- autonomous defaults by mask, on for /64
- add a non-/64 default test
proxmox-ve-config/src/sdn/config.rs | 146 +++++++++++++++-
proxmox-ve-config/src/sdn/mod.rs | 1 +
proxmox-ve-config/src/sdn/nd.rs | 131 ++++++++++++++
proxmox-ve-config/tests/nd/main.rs | 164 ++++++++++++++++++
.../nd__explicit_lifetimes_are_preserved.snap | 9 +
.../nd__mixed_subnets_under_one_vnet.snap | 14 ++
.../snapshots/nd__no_autoconfig_prefix.snap | 9 +
...sh64_prefix_defaults_to_no_autoconfig.snap | 9 +
.../nd__off_link_emits_off_link_modifier.snap | 9 +
...__preferred_lifetime_clamped_to_valid.snap | 9 +
...vel_optional_knobs_are_passed_through.snap | 12 ++
.../nd__slaac_with_default_lifetimes.snap | 9 +
12 files changed, 515 insertions(+), 7 deletions(-)
create mode 100644 proxmox-ve-config/src/sdn/nd.rs
create mode 100644 proxmox-ve-config/tests/nd/main.rs
create mode 100644 proxmox-ve-config/tests/nd/snapshots/nd__explicit_lifetimes_are_preserved.snap
create mode 100644 proxmox-ve-config/tests/nd/snapshots/nd__mixed_subnets_under_one_vnet.snap
create mode 100644 proxmox-ve-config/tests/nd/snapshots/nd__no_autoconfig_prefix.snap
create mode 100644 proxmox-ve-config/tests/nd/snapshots/nd__non_slash64_prefix_defaults_to_no_autoconfig.snap
create mode 100644 proxmox-ve-config/tests/nd/snapshots/nd__off_link_emits_off_link_modifier.snap
create mode 100644 proxmox-ve-config/tests/nd/snapshots/nd__preferred_lifetime_clamped_to_valid.snap
create mode 100644 proxmox-ve-config/tests/nd/snapshots/nd__ra_level_optional_knobs_are_passed_through.snap
create mode 100644 proxmox-ve-config/tests/nd/snapshots/nd__slaac_with_default_lifetimes.snap
diff --git a/proxmox-ve-config/src/sdn/config.rs b/proxmox-ve-config/src/sdn/config.rs
index 2f30cf2bd77c..83a5fc42051c 100644
--- a/proxmox-ve-config/src/sdn/config.rs
+++ b/proxmox-ve-config/src/sdn/config.rs
@@ -2,7 +2,7 @@ use std::{
collections::{BTreeMap, HashMap},
error::Error,
fmt::Display,
- net::IpAddr,
+ net::{IpAddr, Ipv6Addr},
str::FromStr,
};
@@ -16,7 +16,10 @@ use crate::{
Ipset,
ipset::{IpsetEntry, IpsetName, IpsetScope},
},
- sdn::{SdnNameError, SubnetName, VnetName, ZoneName},
+ sdn::{
+ SdnNameError, SubnetName, VnetName, ZoneName,
+ nd::{Ipv6RaConfig, NdPrefixConfig},
+ },
};
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
@@ -185,6 +188,33 @@ pub struct SubnetRunningConfig {
snat: Option<u8>,
#[serde(rename = "dhcp-range")]
dhcp_range: Option<Vec<PropertyString<DhcpRange>>>,
+
+ // Per-prefix RA / SLAAC overrides. Only meaningful for IPv6 subnets whose vnet has
+ // RA enabled, silently ignored otherwise.
+ #[serde(
+ default,
+ rename = "nd-prefix-autonomous",
+ deserialize_with = "proxmox_serde::perl::deserialize_bool"
+ )]
+ nd_prefix_autonomous: Option<bool>,
+ #[serde(
+ default,
+ rename = "nd-prefix-on-link",
+ deserialize_with = "proxmox_serde::perl::deserialize_bool"
+ )]
+ nd_prefix_on_link: Option<bool>,
+ #[serde(
+ default,
+ rename = "nd-prefix-valid-lifetime",
+ deserialize_with = "proxmox_serde::perl::deserialize_u32"
+ )]
+ nd_prefix_valid_lifetime: Option<u32>,
+ #[serde(
+ default,
+ rename = "nd-prefix-preferred-lifetime",
+ deserialize_with = "proxmox_serde::perl::deserialize_u32"
+ )]
+ nd_prefix_preferred_lifetime: Option<u32>,
}
/// Struct for deserializing the subnets of the SDN running config
@@ -196,8 +226,67 @@ pub struct SubnetsRunningConfig {
/// Struct for deserializing a vnet entry of the SDN running config
#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct VnetRunningConfig {
+ #[serde(default, deserialize_with = "proxmox_serde::perl::deserialize_u32")]
tag: Option<u32>,
zone: ZoneName,
+
+ // Per-vnet IPv6 RA settings. `ipv6-ra` is the master toggle, the rest are only
+ // meaningful when it is enabled.
+ #[serde(
+ default,
+ rename = "ipv6-ra",
+ deserialize_with = "proxmox_serde::perl::deserialize_bool"
+ )]
+ ipv6_ra: Option<bool>,
+ #[serde(
+ default,
+ rename = "ipv6-ra-managed",
+ deserialize_with = "proxmox_serde::perl::deserialize_bool"
+ )]
+ ipv6_ra_managed: Option<bool>,
+ #[serde(
+ default,
+ rename = "ipv6-ra-other",
+ deserialize_with = "proxmox_serde::perl::deserialize_bool"
+ )]
+ ipv6_ra_other: Option<bool>,
+ #[serde(rename = "ipv6-ra-rdnss")]
+ ipv6_ra_rdnss: Option<Vec<Ipv6Addr>>,
+ #[serde(
+ default,
+ rename = "ipv6-ra-router-lifetime",
+ deserialize_with = "proxmox_serde::perl::deserialize_u32"
+ )]
+ ipv6_ra_router_lifetime: Option<u32>,
+ #[serde(
+ default,
+ rename = "ipv6-ra-interval",
+ deserialize_with = "proxmox_serde::perl::deserialize_u32"
+ )]
+ ipv6_ra_interval: Option<u32>,
+ #[serde(
+ default,
+ rename = "ipv6-ra-mtu",
+ deserialize_with = "proxmox_serde::perl::deserialize_u32"
+ )]
+ ipv6_ra_mtu: Option<u32>,
+}
+
+impl VnetRunningConfig {
+ /// Materialize the IPv6 RA settings if enabled on this vnet, otherwise return `None`.
+ pub fn ipv6_ra_config(&self) -> Option<Ipv6RaConfig> {
+ if !self.ipv6_ra.unwrap_or(false) {
+ return None;
+ }
+ Some(Ipv6RaConfig {
+ managed: self.ipv6_ra_managed.unwrap_or(false),
+ other: self.ipv6_ra_other.unwrap_or(false),
+ rdnss: self.ipv6_ra_rdnss.clone().unwrap_or_default(),
+ router_lifetime: self.ipv6_ra_router_lifetime,
+ interval: self.ipv6_ra_interval,
+ mtu: self.ipv6_ra_mtu,
+ })
+ }
}
/// struct for deserializing the vnets of the SDN running config
@@ -223,6 +312,7 @@ pub struct SubnetConfig {
gateway: Option<IpAddr>,
snat: bool,
dhcp_range: Vec<IpRange>,
+ nd_prefix: NdPrefixConfig,
}
impl SubnetConfig {
@@ -242,11 +332,19 @@ impl SubnetConfig {
}
}
+ // SLAAC requires a /64 prefix, so the autonomous flag only defaults to set there.
+ // The subnet schema rejects explicitly enabling it on other prefix lengths.
+ let nd_prefix = NdPrefixConfig {
+ autonomous: matches!(name.cidr(), Cidr::Ipv6(cidr) if cidr.mask() == 64),
+ ..NdPrefixConfig::default()
+ };
+
Ok(Self {
name,
gateway,
snat,
dhcp_range: dhcp_range.into_iter().collect(),
+ nd_prefix,
})
}
@@ -269,7 +367,19 @@ impl SubnetConfig {
None => Vec::new(),
};
- Self::new(name, running_config.gateway, snat, dhcp_range)
+ let mut config = Self::new(name, running_config.gateway, snat, dhcp_range)?;
+ let nd_default = config.nd_prefix().clone();
+ config.set_nd_prefix(NdPrefixConfig {
+ autonomous: running_config
+ .nd_prefix_autonomous
+ .unwrap_or(nd_default.autonomous),
+ on_link: running_config
+ .nd_prefix_on_link
+ .unwrap_or(nd_default.on_link),
+ valid_lifetime: running_config.nd_prefix_valid_lifetime,
+ preferred_lifetime: running_config.nd_prefix_preferred_lifetime,
+ });
+ Ok(config)
}
pub fn name(&self) -> &SubnetName {
@@ -291,6 +401,17 @@ impl SubnetConfig {
pub fn dhcp_ranges(&self) -> impl Iterator<Item = &IpRange> + '_ {
self.dhcp_range.iter()
}
+
+ /// Per-prefix Router Advertisement overrides. If no explicit overrides were
+ /// configured, this defaults to "include the prefix in the RA with the on-link flag
+ /// set, and the autonomous flag set iff the prefix is a /64" (SLAAC requires /64).
+ pub fn nd_prefix(&self) -> &NdPrefixConfig {
+ &self.nd_prefix
+ }
+
+ pub fn set_nd_prefix(&mut self, nd_prefix: NdPrefixConfig) {
+ self.nd_prefix = nd_prefix;
+ }
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Hash, PartialOrd, Ord)]
@@ -298,6 +419,7 @@ pub struct VnetConfig {
name: VnetName,
tag: Option<u32>,
subnets: BTreeMap<Cidr, SubnetConfig>,
+ ipv6_ra: Option<Ipv6RaConfig>,
}
impl VnetConfig {
@@ -306,6 +428,7 @@ impl VnetConfig {
name,
subnets: BTreeMap::default(),
tag,
+ ipv6_ra: None,
}
}
@@ -360,6 +483,15 @@ impl VnetConfig {
pub fn tag(&self) -> &Option<u32> {
&self.tag
}
+
+ /// Per-vnet IPv6 RA settings. `None` means RAs are not emitted on this vnet's bridge.
+ pub fn ipv6_ra(&self) -> &Option<Ipv6RaConfig> {
+ &self.ipv6_ra
+ }
+
+ pub fn set_ipv6_ra(&mut self, ipv6_ra: Option<Ipv6RaConfig>) {
+ self.ipv6_ra = ipv6_ra;
+ }
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Hash, PartialOrd, Ord)]
@@ -635,10 +767,10 @@ impl TryFrom<RunningConfig> for SdnConfig {
if let Some(running_vnets) = value.vnets.take() {
for (name, running_config) in running_vnets.ids {
- config.add_vnet(
- &running_config.zone,
- VnetConfig::new(name, running_config.tag),
- )?;
+ let ipv6_ra = running_config.ipv6_ra_config();
+ let mut vnet = VnetConfig::new(name, running_config.tag);
+ vnet.set_ipv6_ra(ipv6_ra);
+ config.add_vnet(&running_config.zone, vnet)?;
}
}
diff --git a/proxmox-ve-config/src/sdn/mod.rs b/proxmox-ve-config/src/sdn/mod.rs
index 2133396145bd..457a9ec3af5f 100644
--- a/proxmox-ve-config/src/sdn/mod.rs
+++ b/proxmox-ve-config/src/sdn/mod.rs
@@ -1,6 +1,7 @@
pub mod config;
pub mod fabric;
pub mod ipam;
+pub mod nd;
pub mod prefix_list;
pub mod route_map;
pub mod wireguard;
diff --git a/proxmox-ve-config/src/sdn/nd.rs b/proxmox-ve-config/src/sdn/nd.rs
new file mode 100644
index 000000000000..c1b743428979
--- /dev/null
+++ b/proxmox-ve-config/src/sdn/nd.rs
@@ -0,0 +1,131 @@
+//! IPv6 Router Advertisement / Neighbor Discovery configuration.
+//!
+//! Hosts on an EVPN vnet can autoconfigure addresses via SLAAC when the per-node anycast
+//! gateway emits Router Advertisements. RA configuration is split across two PVE objects
+//! to match the protocol layers:
+//!
+//! * Per-vnet ([`Ipv6RaConfig`]) holds the RA-level settings (M and O flags, RDNSS list,
+//! optional router lifetime, RA interval, advertised MTU). One per vnet.
+//! * Per-subnet ([`NdPrefixConfig`]) holds the per-prefix overrides (autonomous and
+//! on-link flags, valid and preferred lifetimes).
+//!
+//! These are exposed as fields on [`VnetConfig`](crate::sdn::config::VnetConfig) and
+//! [`SubnetConfig`](crate::sdn::config::SubnetConfig). The FRR conversion lives in the
+//! [`frr`] submodule.
+
+use std::net::Ipv6Addr;
+
+use serde::{Deserialize, Serialize};
+
+/// Default valid lifetime (30 days) for advertised prefixes when no override is set.
+pub const DEFAULT_PREFIX_VALID_LIFETIME: u32 = 2_592_000;
+/// Default preferred lifetime (7 days) for advertised prefixes when no override is set.
+pub const DEFAULT_PREFIX_PREFERRED_LIFETIME: u32 = 604_800;
+
+/// Per-vnet IPv6 Router Advertisement configuration.
+///
+/// Presence of this struct on a [`VnetConfig`](crate::sdn::config::VnetConfig) implies the
+/// vnet has RAs enabled. Absence means RAs are suppressed for the vnet.
+#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq, Hash, PartialOrd, Ord)]
+pub struct Ipv6RaConfig {
+ pub managed: bool,
+ pub other: bool,
+ pub rdnss: Vec<Ipv6Addr>,
+ pub router_lifetime: Option<u32>,
+ pub interval: Option<u32>,
+ pub mtu: Option<u32>,
+}
+
+/// Per-subnet (per-prefix) overrides for Router Advertisements.
+///
+/// [`Default`] matches the typical SLAAC use case: autonomous and on-link flags set,
+/// FRR's default lifetimes. Note that
+/// [`SubnetConfig::new`](crate::sdn::config::SubnetConfig::new) only applies the
+/// autonomous default to /64 prefixes, since SLAAC requires a /64.
+#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Hash, PartialOrd, Ord)]
+pub struct NdPrefixConfig {
+ pub autonomous: bool,
+ pub on_link: bool,
+ pub valid_lifetime: Option<u32>,
+ pub preferred_lifetime: Option<u32>,
+}
+
+impl Default for NdPrefixConfig {
+ fn default() -> Self {
+ Self {
+ autonomous: true,
+ on_link: true,
+ valid_lifetime: None,
+ preferred_lifetime: None,
+ }
+ }
+}
+
+#[cfg(feature = "frr")]
+pub mod frr {
+ //! FRR conversion for IPv6 RA / ND configuration.
+ //!
+ //! Folds the per-vnet [`Ipv6RaConfig`] and the per-subnet [`NdPrefixConfig`] of every
+ //! IPv6 subnet under the vnet into a single
+ //! [`NdInterface`](proxmox_frr::ser::nd::NdInterface) keyed by the vnet name in
+ //! [`FrrConfig::nd_interfaces`](proxmox_frr::ser::FrrConfig::nd_interfaces).
+
+ use super::*;
+
+ use proxmox_frr::ser::nd::{NdInterface, NdPrefix};
+ use proxmox_network_types::ip_address::Cidr;
+
+ use crate::sdn::config::{SubnetConfig, VnetConfig};
+
+ /// Build an [`NdInterface`] for the given vnet from its RA settings and per-subnet
+ /// prefix overrides.
+ ///
+ /// Returns `None` when:
+ /// * the vnet has no [`Ipv6RaConfig`] (RAs are disabled), or
+ /// * the vnet has no IPv6 subnet (no prefix to advertise).
+ pub fn build_nd_interface<'a>(
+ vnet: &'a VnetConfig,
+ subnets: impl IntoIterator<Item = &'a SubnetConfig>,
+ ) -> Option<NdInterface> {
+ let ra = vnet.ipv6_ra().as_ref()?;
+
+ let mut prefixes = Vec::new();
+ for subnet in subnets {
+ let &Cidr::Ipv6(cidr) = subnet.cidr() else {
+ continue;
+ };
+ let nd = subnet.nd_prefix();
+
+ let valid = nd.valid_lifetime.unwrap_or(DEFAULT_PREFIX_VALID_LIFETIME);
+ // RFC 4861 requires preferred <= valid (FRR rejects the prefix otherwise), so
+ // clamp the default when only a shorter valid lifetime is configured. Explicit
+ // invalid combinations are already rejected by the subnet schema hook.
+ let preferred = nd
+ .preferred_lifetime
+ .unwrap_or(DEFAULT_PREFIX_PREFERRED_LIFETIME)
+ .min(valid);
+
+ prefixes.push(NdPrefix {
+ cidr,
+ autonomous: nd.autonomous,
+ on_link: nd.on_link,
+ valid,
+ preferred,
+ });
+ }
+
+ if prefixes.is_empty() {
+ return None;
+ }
+
+ Some(NdInterface {
+ managed_config_flag: ra.managed,
+ other_config_flag: ra.other,
+ rdnss: ra.rdnss.clone(),
+ router_lifetime: ra.router_lifetime,
+ interval: ra.interval,
+ mtu: ra.mtu,
+ prefixes,
+ })
+ }
+}
diff --git a/proxmox-ve-config/tests/nd/main.rs b/proxmox-ve-config/tests/nd/main.rs
new file mode 100644
index 000000000000..b875e10a9b3b
--- /dev/null
+++ b/proxmox-ve-config/tests/nd/main.rs
@@ -0,0 +1,164 @@
+#![cfg(feature = "frr")]
+
+use proxmox_frr::ser::{FrrConfig, InterfaceName, serializer::dump};
+use proxmox_network_types::ip_address::Cidr;
+use proxmox_ve_config::sdn::{
+ SubnetName, VnetName, ZoneName,
+ config::{SubnetConfig, VnetConfig},
+ nd::{Ipv6RaConfig, NdPrefixConfig, frr::build_nd_interface},
+};
+
+fn vnet(name: &str, ra: Option<Ipv6RaConfig>) -> VnetConfig {
+ let mut v = VnetConfig::new(VnetName::new(name.to_owned()).unwrap(), Some(100));
+ v.set_ipv6_ra(ra);
+ v
+}
+
+fn subnet(zone: &str, cidr: &str, nd: NdPrefixConfig) -> SubnetConfig {
+ let cidr: Cidr = cidr.parse().unwrap();
+ let zone = ZoneName::new(zone.to_owned()).unwrap();
+ let name = SubnetName::new(zone, cidr);
+ let mut s = SubnetConfig::new(name, None, false, std::iter::empty()).unwrap();
+ s.set_nd_prefix(nd);
+ s
+}
+
+fn render(vname: &str, iface: proxmox_frr::ser::nd::NdInterface) -> String {
+ let mut config = FrrConfig::default();
+ let name: InterfaceName = vname.to_owned().try_into().unwrap();
+ config.nd_interfaces.insert(name, iface);
+ dump(&config).expect("renders")
+}
+
+#[test]
+fn slaac_with_default_lifetimes() {
+ let v = vnet("vrnet100", Some(Ipv6RaConfig::default()));
+ let s = [subnet("zone", "fd00:1::/64", NdPrefixConfig::default())];
+ let iface = build_nd_interface(&v, &s).expect("ra enabled");
+ insta::assert_snapshot!(render("vrnet100", iface));
+}
+
+#[test]
+fn no_autoconfig_prefix() {
+ let v = vnet("vrnet200", Some(Ipv6RaConfig::default()));
+ let s = [subnet(
+ "zone",
+ "fd00:2::/64",
+ NdPrefixConfig {
+ autonomous: false,
+ ..Default::default()
+ },
+ )];
+ let iface = build_nd_interface(&v, &s).expect("ra enabled");
+ insta::assert_snapshot!(render("vrnet200", iface));
+}
+
+#[test]
+fn mixed_subnets_under_one_vnet() {
+ let ra = Ipv6RaConfig {
+ managed: true,
+ other: true,
+ rdnss: vec![
+ "2001:db8::1".parse().unwrap(),
+ "2001:db8::2".parse().unwrap(),
+ ],
+ ..Default::default()
+ };
+ let v = vnet("vrnet300", Some(ra));
+ let s = [
+ // SLAAC-eligible /64
+ subnet("zone", "fd00:1::/64", NdPrefixConfig::default()),
+ // /96 announced but not autoconfig
+ subnet(
+ "zone",
+ "fd00:2::/96",
+ NdPrefixConfig {
+ autonomous: false,
+ ..Default::default()
+ },
+ ),
+ // IPv4 subnet must be skipped silently
+ subnet("zone", "10.0.0.0/24", NdPrefixConfig::default()),
+ ];
+ let iface = build_nd_interface(&v, &s).expect("ra enabled");
+ insta::assert_snapshot!(render("vrnet300", iface));
+}
+
+#[test]
+fn explicit_lifetimes_are_preserved() {
+ let v = vnet("vrnet400", Some(Ipv6RaConfig::default()));
+ let s = [subnet(
+ "zone",
+ "fd00:1::/64",
+ NdPrefixConfig {
+ valid_lifetime: Some(3600),
+ preferred_lifetime: Some(1800),
+ ..Default::default()
+ },
+ )];
+ let iface = build_nd_interface(&v, &s).expect("ra enabled");
+ insta::assert_snapshot!(render("vrnet400", iface));
+}
+
+#[test]
+fn ra_disabled_returns_none() {
+ let v = vnet("vrnet500", None);
+ let s = [subnet("zone", "fd00:1::/64", NdPrefixConfig::default())];
+ assert!(build_nd_interface(&v, &s).is_none());
+}
+
+#[test]
+fn off_link_emits_off_link_modifier() {
+ let v = vnet("vrnet600", Some(Ipv6RaConfig::default()));
+ let s = [subnet(
+ "zone",
+ "fd00:1::/64",
+ NdPrefixConfig {
+ on_link: false,
+ ..Default::default()
+ },
+ )];
+ let iface = build_nd_interface(&v, &s).expect("ra enabled");
+ insta::assert_snapshot!(render("vrnet600", iface));
+}
+
+#[test]
+fn ra_level_optional_knobs_are_passed_through() {
+ let ra = Ipv6RaConfig {
+ router_lifetime: Some(0),
+ interval: Some(60),
+ mtu: Some(1450),
+ ..Default::default()
+ };
+ let v = vnet("vrnet700", Some(ra));
+ let s = [subnet("zone", "fd00:1::/64", NdPrefixConfig::default())];
+ let iface = build_nd_interface(&v, &s).expect("ra enabled");
+ insta::assert_snapshot!(render("vrnet700", iface));
+}
+
+#[test]
+fn preferred_lifetime_clamped_to_valid() {
+ let v = vnet("vrnet800", Some(Ipv6RaConfig::default()));
+ let s = [subnet(
+ "zone",
+ "fd00:1::/64",
+ NdPrefixConfig {
+ valid_lifetime: Some(3600),
+ ..Default::default()
+ },
+ )];
+ let iface = build_nd_interface(&v, &s).expect("ra enabled");
+ insta::assert_snapshot!(render("vrnet800", iface));
+}
+
+#[test]
+fn non_slash64_prefix_defaults_to_no_autoconfig() {
+ let v = vnet("vrnet900", Some(Ipv6RaConfig::default()));
+ let cidr: Cidr = "fd00:1::/96".parse().unwrap();
+ let zone = ZoneName::new("zone".to_owned()).unwrap();
+ let name = SubnetName::new(zone, cidr);
+ // no explicit NdPrefixConfig: the constructor default applies (autonomous iff /64)
+ let s = [SubnetConfig::new(name, None, false, std::iter::empty()).unwrap()];
+ let iface = build_nd_interface(&v, &s).expect("ra enabled");
+ insta::assert_snapshot!(render("vrnet900", iface));
+}
diff --git a/proxmox-ve-config/tests/nd/snapshots/nd__explicit_lifetimes_are_preserved.snap b/proxmox-ve-config/tests/nd/snapshots/nd__explicit_lifetimes_are_preserved.snap
new file mode 100644
index 000000000000..8abcd0760bae
--- /dev/null
+++ b/proxmox-ve-config/tests/nd/snapshots/nd__explicit_lifetimes_are_preserved.snap
@@ -0,0 +1,9 @@
+---
+source: proxmox-ve-config/tests/nd/main.rs
+expression: "render(\"vrnet400\", iface)"
+---
+!
+interface vrnet400
+ no ipv6 nd suppress-ra
+ ipv6 nd prefix fd00:1::/64 3600 1800
+exit
diff --git a/proxmox-ve-config/tests/nd/snapshots/nd__mixed_subnets_under_one_vnet.snap b/proxmox-ve-config/tests/nd/snapshots/nd__mixed_subnets_under_one_vnet.snap
new file mode 100644
index 000000000000..bf50e301f353
--- /dev/null
+++ b/proxmox-ve-config/tests/nd/snapshots/nd__mixed_subnets_under_one_vnet.snap
@@ -0,0 +1,14 @@
+---
+source: proxmox-ve-config/tests/nd/main.rs
+expression: "render(\"vrnet300\", iface)"
+---
+!
+interface vrnet300
+ no ipv6 nd suppress-ra
+ ipv6 nd managed-config-flag
+ ipv6 nd other-config-flag
+ ipv6 nd rdnss 2001:db8::1
+ ipv6 nd rdnss 2001:db8::2
+ ipv6 nd prefix fd00:1::/64 2592000 604800
+ ipv6 nd prefix fd00:2::/96 2592000 604800 no-autoconfig
+exit
diff --git a/proxmox-ve-config/tests/nd/snapshots/nd__no_autoconfig_prefix.snap b/proxmox-ve-config/tests/nd/snapshots/nd__no_autoconfig_prefix.snap
new file mode 100644
index 000000000000..b8767ebfd671
--- /dev/null
+++ b/proxmox-ve-config/tests/nd/snapshots/nd__no_autoconfig_prefix.snap
@@ -0,0 +1,9 @@
+---
+source: proxmox-ve-config/tests/nd/main.rs
+expression: "render(\"vrnet200\", iface)"
+---
+!
+interface vrnet200
+ no ipv6 nd suppress-ra
+ ipv6 nd prefix fd00:2::/64 2592000 604800 no-autoconfig
+exit
diff --git a/proxmox-ve-config/tests/nd/snapshots/nd__non_slash64_prefix_defaults_to_no_autoconfig.snap b/proxmox-ve-config/tests/nd/snapshots/nd__non_slash64_prefix_defaults_to_no_autoconfig.snap
new file mode 100644
index 000000000000..57b12f88311d
--- /dev/null
+++ b/proxmox-ve-config/tests/nd/snapshots/nd__non_slash64_prefix_defaults_to_no_autoconfig.snap
@@ -0,0 +1,9 @@
+---
+source: proxmox-ve-config/tests/nd/main.rs
+expression: "render(\"vrnet900\", iface)"
+---
+!
+interface vrnet900
+ no ipv6 nd suppress-ra
+ ipv6 nd prefix fd00:1::/96 2592000 604800 no-autoconfig
+exit
diff --git a/proxmox-ve-config/tests/nd/snapshots/nd__off_link_emits_off_link_modifier.snap b/proxmox-ve-config/tests/nd/snapshots/nd__off_link_emits_off_link_modifier.snap
new file mode 100644
index 000000000000..a15f2adb1f94
--- /dev/null
+++ b/proxmox-ve-config/tests/nd/snapshots/nd__off_link_emits_off_link_modifier.snap
@@ -0,0 +1,9 @@
+---
+source: proxmox-ve-config/tests/nd/main.rs
+expression: "render(\"vrnet600\", iface)"
+---
+!
+interface vrnet600
+ no ipv6 nd suppress-ra
+ ipv6 nd prefix fd00:1::/64 2592000 604800 off-link
+exit
diff --git a/proxmox-ve-config/tests/nd/snapshots/nd__preferred_lifetime_clamped_to_valid.snap b/proxmox-ve-config/tests/nd/snapshots/nd__preferred_lifetime_clamped_to_valid.snap
new file mode 100644
index 000000000000..f7d34b9457ce
--- /dev/null
+++ b/proxmox-ve-config/tests/nd/snapshots/nd__preferred_lifetime_clamped_to_valid.snap
@@ -0,0 +1,9 @@
+---
+source: proxmox-ve-config/tests/nd/main.rs
+expression: "render(\"vrnet800\", iface)"
+---
+!
+interface vrnet800
+ no ipv6 nd suppress-ra
+ ipv6 nd prefix fd00:1::/64 3600 3600
+exit
diff --git a/proxmox-ve-config/tests/nd/snapshots/nd__ra_level_optional_knobs_are_passed_through.snap b/proxmox-ve-config/tests/nd/snapshots/nd__ra_level_optional_knobs_are_passed_through.snap
new file mode 100644
index 000000000000..44b72b283944
--- /dev/null
+++ b/proxmox-ve-config/tests/nd/snapshots/nd__ra_level_optional_knobs_are_passed_through.snap
@@ -0,0 +1,12 @@
+---
+source: proxmox-ve-config/tests/nd/main.rs
+expression: "render(\"vrnet700\", iface)"
+---
+!
+interface vrnet700
+ no ipv6 nd suppress-ra
+ ipv6 nd ra-interval 60
+ ipv6 nd ra-lifetime 0
+ ipv6 nd mtu 1450
+ ipv6 nd prefix fd00:1::/64 2592000 604800
+exit
diff --git a/proxmox-ve-config/tests/nd/snapshots/nd__slaac_with_default_lifetimes.snap b/proxmox-ve-config/tests/nd/snapshots/nd__slaac_with_default_lifetimes.snap
new file mode 100644
index 000000000000..52b2e5b5fb46
--- /dev/null
+++ b/proxmox-ve-config/tests/nd/snapshots/nd__slaac_with_default_lifetimes.snap
@@ -0,0 +1,9 @@
+---
+source: proxmox-ve-config/tests/nd/main.rs
+expression: "render(\"vrnet100\", iface)"
+---
+!
+interface vrnet100
+ no ipv6 nd suppress-ra
+ ipv6 nd prefix fd00:1::/64 2592000 604800
+exit
--
2.47.3
^ permalink raw reply related [flat|nested] 12+ messages in thread
* [PATCH pve-manager v5 4/9] ui: sdn: add IPv6 RA / SLAAC support
2026-10-29 15:41 [PATCH docs/manager/network/proxmox{-perl-rs,-ve-rs} v5 0/9] add IPv6 RA / SLAAC support to EVPN zones Hannes Laimer
` (2 preceding siblings ...)
2026-10-29 15:41 ` [PATCH proxmox-ve-rs v5 3/9] ve-config: sdn: add IPv6 router advertisement settings Hannes Laimer
@ 2026-10-29 15:41 ` Hannes Laimer
2026-10-29 15:41 ` [PATCH pve-network v5 5/9] sdn: evpn: derive IP version from CIDR for gateway-less subnets Hannes Laimer
` (5 subsequent siblings)
9 siblings, 0 replies; 12+ messages in thread
From: Hannes Laimer @ 2026-10-29 15:41 UTC (permalink / raw)
To: pve-devel
Add an "IPv6 Router Advertisement" tab to the vnet dialog for the
per-vnet RA settings, and an "IPv6 Prefix Options" tab to the subnet
dialog for the per-prefix flags and lifetimes.
The dialogs offer the settings only where the API accepts them. The
vnet tab is enabled only for EVPN zones. The subnet tab appears only
in EVPN zones and is enabled only for an IPv6 prefix. Its SLAAC flag
is disabled for any prefix other than a /64.
Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
Notes:
v4:
- the router lifetime field shows Default instead of 1800, FRR derives it from the interval
www/manager6/form/SDNVnetSelector.js | 2 +-
www/manager6/sdn/SubnetEdit.js | 130 ++++++++++++++++-
www/manager6/sdn/SubnetView.js | 7 +-
www/manager6/sdn/VnetEdit.js | 202 ++++++++++++++++++++++++++-
www/manager6/sdn/VnetView.js | 6 +-
5 files changed, 339 insertions(+), 8 deletions(-)
diff --git a/www/manager6/form/SDNVnetSelector.js b/www/manager6/form/SDNVnetSelector.js
index 9e54159c1405..5ccc3cfb1699 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 a360842891a3..020df23efd11 100644
--- a/www/manager6/sdn/SubnetEdit.js
+++ b/www/manager6/sdn/SubnetEdit.js
@@ -24,6 +24,14 @@ Ext.define('PVE.sdn.SubnetInputPanel', {
flex: 1,
allowBlank: false,
fieldLabel: gettext('Subnet'),
+ listeners: {
+ change: function (field, value) {
+ let ipv6 = field.up('window')?.down('#ipv6Panel');
+ if (ipv6) {
+ ipv6.updateForCidr(value);
+ }
+ },
+ },
},
{
xtype: 'proxmoxtextfield',
@@ -59,6 +67,102 @@ Ext.define('PVE.sdn.SubnetInputPanel', {
],
});
+Ext.define('PVE.sdn.SubnetIPv6Panel', {
+ extend: 'Proxmox.panel.InputPanel',
+ mixins: ['Proxmox.Mixin.CBind'],
+
+ updateForCidr: function (cidr) {
+ let me = this;
+
+ let addr = cidr ? cidr.split('/')[0] : '';
+ let mask = cidr ? parseInt(cidr.split('/')[1], 10) : NaN;
+ let isV6 = !!addr && Proxmox.Utils.IP6_match.test(addr);
+ let isSlash64 = isV6 && mask === 64;
+
+ me.setDisabled(!isV6);
+ if (!isV6) {
+ return;
+ }
+
+ let autonomous = me.down('[name=nd-prefix-autonomous]');
+ if (autonomous) {
+ autonomous.setDisabled(!isSlash64);
+ if (!isSlash64) {
+ if (autonomous.getValue()) {
+ me.autonomousAutoCleared = true;
+ autonomous.setValue(false);
+ }
+ } else if (me.autonomousAutoCleared) {
+ me.autonomousAutoCleared = false;
+ autonomous.setValue(true);
+ }
+ }
+ },
+
+ onGetValues: function (values) {
+ let me = this;
+
+ if (me.isCreate) {
+ for (let name of ['nd-prefix-autonomous', 'nd-prefix-on-link']) {
+ if (String(values[name]) === '1') {
+ delete values[name];
+ }
+ }
+ }
+
+ return values;
+ },
+
+ items: [
+ {
+ xtype: 'proxmoxcheckbox',
+ name: 'nd-prefix-autonomous',
+ uncheckedValue: 0,
+ defaultValue: '1',
+ checked: true,
+ fieldLabel: gettext('SLAAC (A)'),
+ cbind: {
+ deleteDefaultValue: '{!isCreate}',
+ },
+ },
+ {
+ xtype: 'proxmoxcheckbox',
+ name: 'nd-prefix-on-link',
+ uncheckedValue: 0,
+ defaultValue: '1',
+ checked: true,
+ fieldLabel: gettext('On-link (L)'),
+ cbind: {
+ deleteDefaultValue: '{!isCreate}',
+ },
+ },
+ {
+ xtype: 'proxmoxintegerfield',
+ name: 'nd-prefix-valid-lifetime',
+ fieldLabel: gettext('Valid Lifetime (s)'),
+ minValue: 0,
+ maxValue: 4294967295,
+ allowBlank: true,
+ emptyText: '2592000',
+ cbind: {
+ deleteEmpty: '{!isCreate}',
+ },
+ },
+ {
+ xtype: 'proxmoxintegerfield',
+ name: 'nd-prefix-preferred-lifetime',
+ fieldLabel: gettext('Preferred Lifetime (s)'),
+ minValue: 0,
+ maxValue: 4294967295,
+ allowBlank: true,
+ emptyText: '604800',
+ cbind: {
+ deleteEmpty: '{!isCreate}',
+ },
+ },
+ ],
+});
+
Ext.define('PVE.sdn.SubnetDhcpRangePanel', {
extend: 'Ext.form.FieldContainer',
mixins: ['Ext.form.field.Field'],
@@ -238,13 +342,16 @@ Ext.define('PVE.sdn.SubnetDhcpRangePanel', {
Ext.define('PVE.sdn.SubnetEdit', {
extend: 'Proxmox.window.Edit',
+ onlineHelp: 'pvesdn_config_subnet',
subject: gettext('Subnet'),
subnet: undefined,
+ cidr: undefined,
width: 350,
base_url: undefined,
+ zoneType: undefined,
bodyPadding: 0,
@@ -272,22 +379,41 @@ Ext.define('PVE.sdn.SubnetEdit', {
name: 'dhcp-range',
});
+ let tabItems = [ipanel, dhcpPanel];
+ let ipv6Panel;
+ let showIpv6Panel = me.zoneType === 'evpn';
+ if (showIpv6Panel && !me.isCreate && me.cidr) {
+ let addr = me.cidr.split('/')[0];
+ showIpv6Panel = Proxmox.Utils.IP6_match.test(addr);
+ }
+ if (showIpv6Panel) {
+ ipv6Panel = Ext.create('PVE.sdn.SubnetIPv6Panel', {
+ isCreate: me.isCreate,
+ itemId: 'ipv6Panel',
+ title: gettext('IPv6 Prefix Options'),
+ });
+ tabItems.push(ipv6Panel);
+ }
+
Ext.apply(me, {
items: [
{
xtype: 'tabpanel',
bodyPadding: 10,
- items: [ipanel, dhcpPanel],
+ items: tabItems,
},
],
});
me.callParent();
- if (!me.isCreate) {
+ if (me.isCreate) {
+ ipv6Panel?.updateForCidr(undefined);
+ } else {
me.load({
success: function (response, options) {
me.setValues(response.result.data);
+ ipv6Panel?.updateForCidr(response.result.data.cidr);
},
});
}
diff --git a/www/manager6/sdn/SubnetView.js b/www/manager6/sdn/SubnetView.js
index c61458e02457..e9e601bb90bb 100644
--- a/www/manager6/sdn/SubnetView.js
+++ b/www/manager6/sdn/SubnetView.js
@@ -8,13 +8,15 @@ Ext.define(
stateId: 'grid-sdn-subnet',
base_url: undefined,
+ zone_type: undefined,
remove_btn: undefined,
- setBaseUrl: function (url) {
+ setBaseUrl: function (url, zoneType) {
let me = this;
me.base_url = url;
+ me.zone_type = zoneType;
if (url === undefined) {
me.store.removeAll();
@@ -49,7 +51,9 @@ Ext.define(
let win = Ext.create('PVE.sdn.SubnetEdit', {
autoShow: true,
subnet: rec.data.subnet,
+ cidr: rec.data.cidr,
base_url: me.base_url,
+ zoneType: me.zone_type,
});
win.on('destroy', reload);
};
@@ -62,6 +66,7 @@ Ext.define(
autoShow: true,
base_url: me.base_url,
type: 'subnet',
+ zoneType: me.zone_type,
});
win.on('destroy', reload);
},
diff --git a/www/manager6/sdn/VnetEdit.js b/www/manager6/sdn/VnetEdit.js
index 1c2f5293aa65..28fb8a63b738 100644
--- a/www/manager6/sdn/VnetEdit.js
+++ b/www/manager6/sdn/VnetEdit.js
@@ -80,6 +80,9 @@ Ext.define('PVE.sdn.VnetInputPanel', {
let panel = me.up('panel');
panel.setZoneType(zoneType);
+
+ let raPanel = me.up('window').down('#ipv6RaPanel');
+ raPanel?.setDisabled(zoneType !== 'evpn');
},
},
},
@@ -145,14 +148,193 @@ Ext.define('PVE.sdn.VnetInputPanel', {
},
});
+Ext.define('PVE.sdn.VnetIPv6RAPanel', {
+ extend: 'Proxmox.panel.InputPanel',
+ mixins: ['Proxmox.Mixin.CBind'],
+
+ initComponent: function () {
+ let me = this;
+ me.callParent();
+ me.updateGatedState();
+ },
+
+ onGetValues: function (values) {
+ let me = this;
+
+ if (values['ipv6-ra-rdnss']) {
+ values['ipv6-ra-rdnss'] = values['ipv6-ra-rdnss']
+ .split(/[\s,]+/)
+ .filter((v) => v.length > 0);
+ }
+
+ if (me.isCreate) {
+ for (let name of ['ipv6-ra', 'ipv6-ra-managed', 'ipv6-ra-other']) {
+ if (String(values[name]) === '0') {
+ delete values[name];
+ }
+ }
+ return values;
+ }
+
+ let addDelete = (key) => {
+ if (values.delete) {
+ if (Ext.isArray(values.delete)) {
+ values.delete.push(key);
+ } else {
+ values.delete = [values.delete, key];
+ }
+ } else {
+ values.delete = [key];
+ }
+ delete values[key];
+ };
+
+ for (let name of [
+ 'ipv6-ra',
+ 'ipv6-ra-managed',
+ 'ipv6-ra-other',
+ 'ipv6-ra-rdnss',
+ 'ipv6-ra-router-lifetime',
+ 'ipv6-ra-interval',
+ 'ipv6-ra-mtu',
+ ]) {
+ if (me.down(`[name=${name}]`).isDisabled()) {
+ addDelete(name);
+ }
+ }
+
+ return values;
+ },
+
+ setValues: function (values) {
+ let me = this;
+
+ if (Ext.isArray(values['ipv6-ra-rdnss'])) {
+ values = Ext.apply({}, values);
+ values['ipv6-ra-rdnss'] = values['ipv6-ra-rdnss'].join(', ');
+ }
+ me.callParent([values]);
+ },
+
+ updateGatedState: function () {
+ let me = this;
+
+ let raEnabled = !!me.down('[name=ipv6-ra]').getValue();
+ for (let name of [
+ 'ipv6-ra-managed',
+ 'ipv6-ra-other',
+ 'ipv6-ra-rdnss',
+ 'ipv6-ra-router-lifetime',
+ 'ipv6-ra-interval',
+ 'ipv6-ra-mtu',
+ ]) {
+ let field = me.down(`[name=${name}]`);
+ if (field) {
+ field.setDisabled(!raEnabled);
+ }
+ }
+ },
+
+ items: [
+ {
+ xtype: 'proxmoxcheckbox',
+ name: 'ipv6-ra',
+ uncheckedValue: 0,
+ defaultValue: 0,
+ checked: false,
+ fieldLabel: gettext('Send Router Advertisements'),
+ cbind: {
+ deleteDefaultValue: '{!isCreate}',
+ },
+ listeners: {
+ change: function (field) {
+ field.up('inputpanel').updateGatedState();
+ },
+ },
+ },
+ {
+ xtype: 'proxmoxcheckbox',
+ name: 'ipv6-ra-managed',
+ uncheckedValue: 0,
+ defaultValue: 0,
+ checked: false,
+ fieldLabel: gettext('DHCP Managed (M)'),
+ cbind: {
+ deleteDefaultValue: '{!isCreate}',
+ },
+ },
+ {
+ xtype: 'proxmoxcheckbox',
+ name: 'ipv6-ra-other',
+ uncheckedValue: 0,
+ defaultValue: 0,
+ checked: false,
+ fieldLabel: gettext('DHCP Other (O)'),
+ cbind: {
+ deleteDefaultValue: '{!isCreate}',
+ },
+ },
+ {
+ xtype: 'proxmoxtextfield',
+ name: 'ipv6-ra-rdnss',
+ fieldLabel: 'RDNSS',
+ emptyText: gettext('Comma-separated IPv6 addresses'),
+ cbind: {
+ deleteEmpty: '{!isCreate}',
+ },
+ },
+ ],
+ advancedItems: [
+ {
+ xtype: 'proxmoxintegerfield',
+ name: 'ipv6-ra-router-lifetime',
+ fieldLabel: gettext('Router Lifetime (s)'),
+ emptyText: Proxmox.Utils.defaultText,
+ minValue: 0,
+ maxValue: 9000,
+ allowBlank: true,
+ cbind: {
+ deleteEmpty: '{!isCreate}',
+ },
+ },
+ {
+ xtype: 'proxmoxintegerfield',
+ name: 'ipv6-ra-interval',
+ fieldLabel: gettext('RA Interval (s)'),
+ emptyText: '600',
+ minValue: 4,
+ maxValue: 1800,
+ allowBlank: true,
+ cbind: {
+ deleteEmpty: '{!isCreate}',
+ },
+ },
+ {
+ xtype: 'proxmoxintegerfield',
+ name: 'ipv6-ra-mtu',
+ fieldLabel: gettext('Advertised MTU'),
+ minValue: 1280,
+ maxValue: 65535,
+ allowBlank: true,
+ cbind: {
+ deleteEmpty: '{!isCreate}',
+ },
+ },
+ ],
+});
+
Ext.define('PVE.sdn.VnetEdit', {
extend: 'Proxmox.window.Edit',
+ onlineHelp: 'pvesdn_config_vnet',
subject: gettext('VNet'),
vnet: undefined,
+ zoneType: undefined,
+
+ width: 400,
- width: 350,
+ bodyPadding: 0,
initComponent: function () {
var me = this;
@@ -169,10 +351,24 @@ Ext.define('PVE.sdn.VnetEdit', {
let ipanel = Ext.create('PVE.sdn.VnetInputPanel', {
isCreate: me.isCreate,
+ title: gettext('General'),
+ });
+
+ let raPanel = Ext.create('PVE.sdn.VnetIPv6RAPanel', {
+ isCreate: me.isCreate,
+ itemId: 'ipv6RaPanel',
+ title: gettext('IPv6 Router Advertisement'),
+ disabled: me.zoneType !== 'evpn',
});
Ext.apply(me, {
- items: [ipanel],
+ items: [
+ {
+ xtype: 'tabpanel',
+ bodyPadding: 10,
+ items: [ipanel, raPanel],
+ },
+ ],
});
me.callParent();
@@ -182,6 +378,8 @@ Ext.define('PVE.sdn.VnetEdit', {
success: function (response, options) {
let values = response.result.data;
ipanel.setValues(values);
+ raPanel.setValues(values);
+ raPanel.updateGatedState();
},
});
}
diff --git a/www/manager6/sdn/VnetView.js b/www/manager6/sdn/VnetView.js
index 1c576db66158..ce8c314eba6c 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-network v5 5/9] sdn: evpn: derive IP version from CIDR for gateway-less subnets
2026-10-29 15:41 [PATCH docs/manager/network/proxmox{-perl-rs,-ve-rs} v5 0/9] add IPv6 RA / SLAAC support to EVPN zones Hannes Laimer
` (3 preceding siblings ...)
2026-10-29 15:41 ` [PATCH pve-manager v5 4/9] ui: sdn: add IPv6 RA / SLAAC support Hannes Laimer
@ 2026-10-29 15:41 ` Hannes Laimer
2026-10-29 15:41 ` [PATCH pve-network v5 6/9] sdn: evpn: accept untracked IPv6 neighbor advertisements Hannes Laimer
` (4 subsequent siblings)
9 siblings, 0 replies; 12+ messages in thread
From: Hannes Laimer @ 2026-10-29 15:41 UTC (permalink / raw)
To: pve-devel
The IP version was derived from the gateway alone, falling back to
IPv4 when no gateway was set. So on exit nodes an IPv6 subnet with
SNAT but no gateway got its NAT rules for iptables, which rejects the
IPv6 source. Fall back to the subnet CIDR.
Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
Notes:
v3:
- derive the IP version from the CIDR for gateway-less subnets
src/PVE/Network/SDN/Zones/EvpnPlugin.pm | 7 ++++++-
1 file changed, 6 insertions(+), 1 deletion(-)
diff --git a/src/PVE/Network/SDN/Zones/EvpnPlugin.pm b/src/PVE/Network/SDN/Zones/EvpnPlugin.pm
index 0e79707c3746..c1d5536e09c0 100644
--- a/src/PVE/Network/SDN/Zones/EvpnPlugin.pm
+++ b/src/PVE/Network/SDN/Zones/EvpnPlugin.pm
@@ -253,7 +253,12 @@ sub generate_sdn_config {
my $iptables = undef;
my $checkrouteip = undef;
- my $ipversion = Net::IP::ip_is_ipv6($gateway) ? 6 : 4;
+ my $ipversion = 4;
+ if ($gateway) {
+ $ipversion = Net::IP::ip_is_ipv6($gateway) ? 6 : 4;
+ } elsif ($subnet->{network}) {
+ $ipversion = Net::IP::ip_is_ipv6($subnet->{network}) ? 6 : 4;
+ }
if ($ipversion == 6) {
$ipv6 = 1;
--
2.47.3
^ permalink raw reply related [flat|nested] 12+ messages in thread
* [PATCH pve-network v5 6/9] sdn: evpn: accept untracked IPv6 neighbor advertisements
2026-10-29 15:41 [PATCH docs/manager/network/proxmox{-perl-rs,-ve-rs} v5 0/9] add IPv6 RA / SLAAC support to EVPN zones Hannes Laimer
` (4 preceding siblings ...)
2026-10-29 15:41 ` [PATCH pve-network v5 5/9] sdn: evpn: derive IP version from CIDR for gateway-less subnets Hannes Laimer
@ 2026-10-29 15:41 ` Hannes Laimer
2026-10-29 15:41 ` [PATCH pve-network v5 7/9] sdn: evpn: add IPv6 RA / SLAAC support Hannes Laimer
` (3 subsequent siblings)
9 siblings, 0 replies; 12+ messages in thread
From: Hannes Laimer @ 2026-10-29 15:41 UTC (permalink / raw)
To: pve-devel
Every node of an EVPN zone has the same gateway address and MAC on a
vnet bridge. An exit node without a neighbor entry for a guest's IPv6
address floods a solicitation to the other nodes. The guest answers
to the shared gateway, so the node it runs on gets the advertisement.
That node never sent the solicitation, and with no entry for the
address its kernel ignores the advertisement by default. The exit
node is left without an entry and drops traffic from outside the zone
to the guest.
Set accept_untracked_na to 2 on vnet bridges with an IPv6 subnet [1].
The kernel then adds an entry from such an advertisement if its source
is in the subnet of an address on the bridge. From there EVPN carries
the entry to the exit node.
[1] https://docs.kernel.org/networking/ip-sysctl.html#proc-sys-net-ipv6-variables
Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
Notes:
v3:
- add the missing accept_untracked_na line to the exitnode_local_routing_ipv6 fixture
src/PVE/Network/SDN/Zones/EvpnPlugin.pm | 5 +++++
.../evpn/exitnode_local_routing_ipv6/expected_sdn_interfaces | 1 +
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 +
8 files changed, 12 insertions(+)
diff --git a/src/PVE/Network/SDN/Zones/EvpnPlugin.pm b/src/PVE/Network/SDN/Zones/EvpnPlugin.pm
index c1d5536e09c0..96f549577b3b 100644
--- a/src/PVE/Network/SDN/Zones/EvpnPlugin.pm
+++ b/src/PVE/Network/SDN/Zones/EvpnPlugin.pm
@@ -309,6 +309,11 @@ 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;
+
+ if ($ipv6) {
+ push @iface_config,
+ "post-up echo 2 > /proc/sys/net/ipv6/conf/$vnetid/accept_untracked_na || true";
+ }
push @iface_config, "vrf $vrf_iface" if $vrf_iface;
push(@{ $config->{$vnetid} }, @iface_config) if !$config->{$vnetid};
diff --git a/src/test/zones/evpn/exitnode_local_routing_ipv6/expected_sdn_interfaces b/src/test/zones/evpn/exitnode_local_routing_ipv6/expected_sdn_interfaces
index b46d4e7b6ea0..9b268f21857f 100644
--- a/src/test/zones/evpn/exitnode_local_routing_ipv6/expected_sdn_interfaces
+++ b/src/test/zones/evpn/exitnode_local_routing_ipv6/expected_sdn_interfaces
@@ -9,6 +9,7 @@ iface myvnet
mtu 1450
ip6-forward on
arp-accept on
+ post-up echo 2 > /proc/sys/net/ipv6/conf/myvnet/accept_untracked_na || true
vrf vrf_myzone
auto vrf_myzone
diff --git a/src/test/zones/evpn/exitnode_snat/expected_sdn_interfaces b/src/test/zones/evpn/exitnode_snat/expected_sdn_interfaces
index 0d7d17412f0b..3e9b8adca124 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 4bf5ccf96f91..81a3b39ed129 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 7a5d741b067c..7b1727bbf278 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 378fa77fa368..4d904be3fd5b 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 b2bdbfefd810..f77612293fbf 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 3b91f75eb3dd..ab5988fae818 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
--
2.47.3
^ permalink raw reply related [flat|nested] 12+ messages in thread
* [PATCH pve-network v5 7/9] sdn: evpn: add IPv6 RA / SLAAC support
2026-10-29 15:41 [PATCH docs/manager/network/proxmox{-perl-rs,-ve-rs} v5 0/9] add IPv6 RA / SLAAC support to EVPN zones Hannes Laimer
` (5 preceding siblings ...)
2026-10-29 15:41 ` [PATCH pve-network v5 6/9] sdn: evpn: accept untracked IPv6 neighbor advertisements Hannes Laimer
@ 2026-10-29 15:41 ` Hannes Laimer
2026-10-29 15:41 ` [PATCH pve-network v5 8/9] api: vnets: include the zone type in the vnet list Hannes Laimer
` (2 subsequent siblings)
9 siblings, 0 replies; 12+ messages in thread
From: Hannes Laimer @ 2026-10-29 15:41 UTC (permalink / raw)
To: pve-devel
FRR can send IPv6 Router Advertisements (RAs) on the bridge of an
EVPN vnet, so guests configure their addresses through SLAAC. The RAs
can also carry DNS servers and the flags that point guests to DHCPv6.
Everything that applies to the advertisement as a whole is a vnet
option. The prefix flags and lifetimes are subnet options, since an RA
lists each IPv6 subnet of the vnet as a prefix of its own. With RAs
enabled, a /64 subnet is advertised for SLAAC with no subnet option
set. SLAAC needs a /64 prefix, so other prefix lengths are advertised
without the autonomous flag and cannot have it turned on.
The API rejects RA options on vnets outside EVPN zones and prefix
options on IPv4 subnets or outside EVPN zones, since they would have
no effect there. It also rejects a non-zero router lifetime shorter
than the RA interval and a preferred lifetime longer than the valid
one, since FRR refuses to load either.
The prefixes and the exit-node routes take a vnet's subnets from the
config the FRR generation runs on, which is the pending one for the
dry-run diff. The exit-node routes used to read the committed config,
so that diff showed the routes of the committed subnets only.
Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
Notes:
v5:
- the return schema entries come from the plugin schema
- blackbox tests for the vnet and subnet validation
v4:
- drop the SNAT lookup change, master handles a failing lookup since 304989ce75ce
- list and read return schemas carry the ipv6-ra properties
- reject a preferred lifetime above the default valid lifetime as well
- reject the per-prefix options on subnets outside EVPN zones
v3:
- accept valid IPv6 subnets again, non-/64 and SNAT no longer rejected
- reject autonomous only when explicitly set on a non-/64
- validate preferred not above valid, cap both at u32 max
- name the offending field in validation errors
- gate RA via a supports_ipv6_ra predicate, breaks the load cycle
- reject a router lifetime below the RA interval
- read subnets once from the running snapshot, no pending leak
- eval guard RA generation, gate it on zone node membership
- register ipv6-ra-rdnss in encode_value
src/PVE/API2/Network/SDN/Vnets.pm | 5 +
src/PVE/Network/SDN.pm | 1 +
src/PVE/Network/SDN/Controllers.pm | 21 +-
src/PVE/Network/SDN/Controllers/EvpnPlugin.pm | 22 +-
src/PVE/Network/SDN/Controllers/Plugin.pm | 2 +-
src/PVE/Network/SDN/SubnetPlugin.pm | 64 +++++
src/PVE/Network/SDN/VnetPlugin.pm | 85 +++++++
src/PVE/Network/SDN/Zones/EvpnPlugin.pm | 4 +
src/PVE/Network/SDN/Zones/Plugin.pm | 6 +
src/test/run_test_vnets_blackbox.pl | 226 ++++++++++++++++++
.../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 ++++
14 files changed, 576 insertions(+), 5 deletions(-)
create mode 100644 src/test/zones/evpn/slaac/expected_controller_config
create mode 100644 src/test/zones/evpn/slaac/expected_sdn_interfaces
create mode 100644 src/test/zones/evpn/slaac/interfaces
create mode 100644 src/test/zones/evpn/slaac/sdn_config
diff --git a/src/PVE/API2/Network/SDN/Vnets.pm b/src/PVE/API2/Network/SDN/Vnets.pm
index 3f1cb1b4ab98..0bd027ec1c0d 100644
--- a/src/PVE/API2/Network/SDN/Vnets.pm
+++ b/src/PVE/API2/Network/SDN/Vnets.pm
@@ -82,6 +82,11 @@ my $VNET_PROPERTIES = {
maxLength => 256,
optional => 1,
},
+ (
+ map { $_ => PVE::Network::SDN::VnetPlugin->get_property_schema('vnet', $_) }
+ qw(ipv6-ra ipv6-ra-managed ipv6-ra-other ipv6-ra-rdnss ipv6-ra-router-lifetime
+ ipv6-ra-interval ipv6-ra-mtu)
+ ),
'isolate-ports' => {
type => 'boolean',
description =>
diff --git a/src/PVE/Network/SDN.pm b/src/PVE/Network/SDN.pm
index 33a3cf352496..8f30fe76577a 100644
--- a/src/PVE/Network/SDN.pm
+++ b/src/PVE/Network/SDN.pm
@@ -516,6 +516,7 @@ sub encode_value {
|| $key eq 'allowed_ips'
|| $key eq 'secondary-controllers'
|| $key eq 'redistribute'
+ || $key eq 'ipv6-ra-rdnss'
) {
if (ref($value) eq 'HASH') {
return join(',', sort keys(%$value));
diff --git a/src/PVE/Network/SDN/Controllers.pm b/src/PVE/Network/SDN/Controllers.pm
index b2c2f9d91a5c..399f096784c8 100644
--- a/src/PVE/Network/SDN/Controllers.pm
+++ b/src/PVE/Network/SDN/Controllers.pm
@@ -8,6 +8,7 @@ use JSON;
use PVE::Tools qw(extract_param dir_glob_regex run_command);
use PVE::Cluster qw(cfs_read_file cfs_write_file cfs_lock_file);
+use PVE::Network::SDN::Subnets;
use PVE::Network::SDN::Vnets;
use PVE::Network::SDN::Zones;
@@ -153,6 +154,18 @@ sub generate_frr_config {
}
}
+ # Bucket subnets by vnet once, from the same config snapshot the rest of the FRR
+ # config is generated from: the committed running config on apply, the compiled
+ # pending config for the dry-run diff. This keeps frr.conf consistent with the
+ # generated /etc/network/interfaces.d/sdn.
+ my $subnets_by_vnet = {};
+ my $subnet_cfg = $sdn_config->{subnets} // { ids => {} };
+ for my $subnetid (sort keys %{ $subnet_cfg->{ids} }) {
+ my $subnet = PVE::Network::SDN::Subnets::sdn_subnets_config($subnet_cfg, $subnetid);
+ next if !$subnet->{vnet};
+ $subnets_by_vnet->{ $subnet->{vnet} }->{$subnetid} = $subnet;
+ }
+
foreach my $id (sort keys %{ $vnet_cfg->{ids} }) {
my $plugin_config = $vnet_cfg->{ids}->{$id};
my $zoneid = $plugin_config->{zone};
@@ -169,7 +182,13 @@ sub generate_frr_config {
my $controller_plugin =
PVE::Network::SDN::Controllers::Plugin->lookup($controller->{type});
$controller_plugin->generate_vnet_frr_config(
- $plugin_config, $controller, $zone, $zoneid, $id, $frr_config,
+ $plugin_config,
+ $controller,
+ $zone,
+ $zoneid,
+ $id,
+ $frr_config,
+ $subnets_by_vnet->{$id} // {},
);
if (!$zone->{'rt-import'}) {
diff --git a/src/PVE/Network/SDN/Controllers/EvpnPlugin.pm b/src/PVE/Network/SDN/Controllers/EvpnPlugin.pm
index 4220cb6721e5..b9dcb9fa6885 100644
--- a/src/PVE/Network/SDN/Controllers/EvpnPlugin.pm
+++ b/src/PVE/Network/SDN/Controllers/EvpnPlugin.pm
@@ -11,6 +11,7 @@ use PVE::RESTEnvironment qw(log_warn);
use PVE::Network::SDN::Controllers::Plugin;
use PVE::Network::SDN::Zones::Plugin;
use PVE::Network::SDN::Fabrics;
+use PVE::RS::SDN::IPv6RA;
use Net::IP;
use base('PVE::Network::SDN::Controllers::Plugin');
@@ -584,19 +585,34 @@ sub generate_zone_frr_config {
}
sub generate_vnet_frr_config {
- my ($class, $plugin_config, $controller, $zone, $zoneid, $vnetid, $config) = @_;
+ my ($class, $plugin_config, $controller, $zone, $zoneid, $vnetid, $config, $subnets) = @_;
+
+ $subnets //= {};
+
+ my $local_node = PVE::INotify::nodename();
+
+ # Only emit ND/RA config on nodes that are part of the zone, mirroring the node
+ # filtering of the bridge generation in the zones plugin.
+ my $is_zone_member = !defined($zone->{nodes}) || $zone->{nodes}->{$local_node};
+
+ if ($plugin_config->{'ipv6-ra'} && $is_zone_member) {
+ my $nd_iface =
+ eval { PVE::RS::SDN::IPv6RA::build_interface($vnetid, $plugin_config, $subnets); };
+ if (my $err = $@) {
+ log_warn("vnet $vnetid: could not generate IPv6 RA configuration: $err");
+ }
+ $config->{frr}->{nd_interfaces}->{$vnetid} = $nd_iface if $nd_iface;
+ }
my $exitnodes = $zone->{'exitnodes'};
my $exitnodes_local_routing = $zone->{'exitnodes-local-routing'};
return if !$exitnodes_local_routing;
- my $local_node = PVE::INotify::nodename();
my $is_gateway = $exitnodes->{$local_node};
return if !$is_gateway;
- my $subnets = PVE::Network::SDN::Vnets::get_subnets($vnetid, 1);
$config->{frr}->{ip_routes} //= [];
foreach my $subnetid (sort keys %{$subnets}) {
my $subnet = $subnets->{$subnetid};
diff --git a/src/PVE/Network/SDN/Controllers/Plugin.pm b/src/PVE/Network/SDN/Controllers/Plugin.pm
index 6da3eec5eb06..45870eef9809 100644
--- a/src/PVE/Network/SDN/Controllers/Plugin.pm
+++ b/src/PVE/Network/SDN/Controllers/Plugin.pm
@@ -93,7 +93,7 @@ sub generate_zone_frr_config {
}
sub generate_vnet_frr_config {
- my ($class, $plugin_config, $controller, $zoneid, $vnetid, $config) = @_;
+ my ($class, $plugin_config, $controller, $zone, $zoneid, $vnetid, $config, $subnets) = @_;
}
diff --git a/src/PVE/Network/SDN/SubnetPlugin.pm b/src/PVE/Network/SDN/SubnetPlugin.pm
index e2a0e50d0211..232a8ec8ab21 100644
--- a/src/PVE/Network/SDN/SubnetPlugin.pm
+++ b/src/PVE/Network/SDN/SubnetPlugin.pm
@@ -177,6 +177,39 @@ sub properties {
description => 'IP address for the DNS server',
optional => 1,
},
+ # Per-prefix Router Advertisement overrides. Only meaningful for IPv6 subnets of EVPN
+ # zones 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"
+ . " guests on this subnet. SLAAC requires a /64 prefix, so this defaults to"
+ . " enabled on /64 subnets and cannot be enabled on others.",
+ optional => 1,
+ },
+ 'nd-prefix-on-link' => {
+ type => 'boolean',
+ description =>
+ "Set the on-link (L) flag for this prefix in RAs (default: on-link).",
+ optional => 1,
+ default => 1,
+ },
+ 'nd-prefix-valid-lifetime' => {
+ type => 'integer',
+ description => "Valid lifetime for this prefix in RAs, in seconds.",
+ minimum => 0,
+ maximum => 4294967295,
+ default => 2592000,
+ optional => 1,
+ },
+ 'nd-prefix-preferred-lifetime' => {
+ type => 'integer',
+ description => "Preferred lifetime for this prefix in RAs, in seconds.",
+ minimum => 0,
+ maximum => 4294967295,
+ default => 604800,
+ optional => 1,
+ },
};
}
@@ -189,6 +222,10 @@ sub options {
dnszoneprefix => { optional => 1 },
'dhcp-range' => { optional => 1 },
'dhcp-dns-server' => { optional => 1 },
+ 'nd-prefix-autonomous' => { optional => 1 },
+ 'nd-prefix-on-link' => { optional => 1 },
+ 'nd-prefix-valid-lifetime' => { optional => 1 },
+ 'nd-prefix-preferred-lifetime' => { optional => 1 },
};
}
@@ -206,6 +243,33 @@ 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);
+
+ if (my @nd_opts = sort grep { /^nd-prefix/ && defined($subnet->{$_}) } keys %$subnet) {
+ raise_param_exc({ $nd_opts[0] => "only valid on IPv6 subnets" }) if !$is_ipv6;
+ raise_param_exc({ $nd_opts[0] => "only valid on subnets of EVPN zones" })
+ if $zone->{type} ne 'evpn';
+ }
+
+ # SLAAC requires a /64 prefix (RFC 4862), so the autonomous flag defaults to enabled
+ # only on /64 subnets. Explicitly enabling it elsewhere can never work, reject it.
+ if ($is_ipv6 && $mask != 64 && $subnet->{'nd-prefix-autonomous'}) {
+ raise_param_exc(
+ { 'nd-prefix-autonomous' => "autonomous (SLAAC) flag requires a /64 prefix" });
+ }
+
+ # The preferred lifetime must not exceed the valid lifetime (RFC 4861), FRR rejects
+ # such prefix lines at config load. A valid lifetime set below the default preferred
+ # one lowers that default when the FRR config is built.
+ my $nd_valid = $subnet->{'nd-prefix-valid-lifetime'}
+ // $class->properties()->{'nd-prefix-valid-lifetime'}->{default};
+ my $nd_preferred = $subnet->{'nd-prefix-preferred-lifetime'};
+ if (defined($nd_preferred) && $nd_preferred > $nd_valid) {
+ raise_param_exc({
+ 'nd-prefix-preferred-lifetime' =>
+ "preferred lifetime must not be greater than the valid lifetime ($nd_valid)",
+ });
+ }
my $mac = undef;
diff --git a/src/PVE/Network/SDN/VnetPlugin.pm b/src/PVE/Network/SDN/VnetPlugin.pm
index e04157573083..e40c01ead91d 100644
--- a/src/PVE/Network/SDN/VnetPlugin.pm
+++ b/src/PVE/Network/SDN/VnetPlugin.pm
@@ -83,6 +83,57 @@ sub properties {
"If true, sets the isolated property for all interfaces on the bridge of this VNet.",
optional => 1,
},
+ # IPv6 Router Advertisement settings (EVPN zones only). The master toggle gates
+ # the rest. Per-prefix flags live on the subnet (nd-prefix-*).
+ 'ipv6-ra' => {
+ type => 'boolean',
+ description =>
+ "Send IPv6 Router Advertisements on the bridge of this VNet. Only allowed in"
+ . " EVPN zones. Without an IPv6 subnet on the VNet, no RAs are sent.",
+ 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 guests not to use this"
+ . " router as a default gateway.",
+ optional => 1,
+ minimum => 0,
+ maximum => 9000,
+ },
+ 'ipv6-ra-interval' => {
+ type => 'integer',
+ description => "Maximum interval between unsolicited RAs (seconds).",
+ optional => 1,
+ minimum => 4,
+ maximum => 1800,
+ },
+ 'ipv6-ra-mtu' => {
+ type => 'integer',
+ description => "MTU advertised in RAs.",
+ optional => 1,
+ minimum => 1280,
+ maximum => 65535,
+ },
};
}
@@ -93,6 +144,13 @@ sub options {
alias => { optional => 1 },
vlanaware => { optional => 1 },
'isolate-ports' => { optional => 1 },
+ 'ipv6-ra' => { optional => 1 },
+ 'ipv6-ra-managed' => { optional => 1 },
+ 'ipv6-ra-other' => { optional => 1 },
+ 'ipv6-ra-rdnss' => { optional => 1 },
+ 'ipv6-ra-router-lifetime' => { optional => 1 },
+ 'ipv6-ra-interval' => { optional => 1 },
+ 'ipv6-ra-mtu' => { optional => 1 },
};
}
@@ -117,6 +175,33 @@ sub on_update_hook {
raise_param_exc({ vlanaware => "vlanaware vnet is not compatible with subnets" })
if $subnets;
}
+
+ # RA settings are only consumed by zones whose plugin emits RAs (currently EVPN).
+ # Reject them elsewhere so the user notices their config has no effect. Loaded at
+ # runtime to avoid a compile-time module cycle (Zones -> Vnets -> VnetPlugin).
+ if (my @ra_opts = sort grep { /^ipv6-ra/ } keys %$vnet) {
+ require PVE::Network::SDN::Zones;
+ require PVE::Network::SDN::Zones::Plugin;
+
+ my $zone_cfg = PVE::Network::SDN::Zones::config();
+ my $zone = $zone_cfg->{ids}->{ $vnet->{zone} };
+ my $zone_plugin =
+ $zone ? eval { PVE::Network::SDN::Zones::Plugin->lookup($zone->{type}) } : undef;
+
+ raise_param_exc({ $ra_opts[0] => "IPv6 RA options are only supported on EVPN zones" })
+ if !$zone_plugin || !$zone_plugin->supports_ipv6_ra();
+ }
+
+ # RFC 4861: a non-zero router lifetime must be at least the maximum RA interval
+ # (FRR default: 600s), FRR rejects the lifetime at config load otherwise.
+ my $ra_lifetime = $vnet->{'ipv6-ra-router-lifetime'};
+ my $ra_interval = $vnet->{'ipv6-ra-interval'} // 600;
+ if (defined($ra_lifetime) && $ra_lifetime != 0 && $ra_lifetime < $ra_interval) {
+ raise_param_exc({
+ 'ipv6-ra-router-lifetime' =>
+ "router lifetime must be 0 or at least the RA interval ($ra_interval seconds)",
+ });
+ }
}
1;
diff --git a/src/PVE/Network/SDN/Zones/EvpnPlugin.pm b/src/PVE/Network/SDN/Zones/EvpnPlugin.pm
index 96f549577b3b..10eb100e6f98 100644
--- a/src/PVE/Network/SDN/Zones/EvpnPlugin.pm
+++ b/src/PVE/Network/SDN/Zones/EvpnPlugin.pm
@@ -450,5 +450,9 @@ sub vnet_update_hook {
}
}
+sub supports_ipv6_ra {
+ return 1;
+}
+
1;
diff --git a/src/PVE/Network/SDN/Zones/Plugin.pm b/src/PVE/Network/SDN/Zones/Plugin.pm
index 184fca4e1ae7..73c1f9bf8571 100644
--- a/src/PVE/Network/SDN/Zones/Plugin.pm
+++ b/src/PVE/Network/SDN/Zones/Plugin.pm
@@ -163,6 +163,12 @@ sub vnet_update_hook {
# do nothing by default
}
+# Whether vnets in this zone type can emit IPv6 Router Advertisements (ipv6-ra* vnet
+# options). Zone plugins that generate RA configuration override this.
+sub supports_ipv6_ra {
+ return 0;
+}
+
#helpers
sub parse_tag_number_or_range {
my ($str, $max, $tag) = @_;
diff --git a/src/test/run_test_vnets_blackbox.pl b/src/test/run_test_vnets_blackbox.pl
index 9f4c424f3d2f..9a0cc29ed28a 100755
--- a/src/test/run_test_vnets_blackbox.pl
+++ b/src/test/run_test_vnets_blackbox.pl
@@ -963,4 +963,230 @@ run_test(
2,
);
+sub setup_evpn_zone {
+ my ($zoneid) = @_;
+
+ $test_state->{controller_config} = {
+ ids => { evpnctl => { type => "evpn", asn => 65000, peers => "192.168.0.1" } },
+ };
+ return create_zone({
+ type => "evpn",
+ zone => $zoneid,
+ controller => "evpnctl",
+ 'vrf-vxlan' => 1000,
+ ipam => "pve",
+ });
+}
+
+sub update_vnet {
+ my ($params) = @_;
+ PVE::API2::Network::SDN::Vnets->update($params);
+}
+
+sub update_subnet {
+ my ($params) = @_;
+ PVE::API2::Network::SDN::Subnets->update($params);
+}
+
+sub test_ra_options_only_in_evpn_zones {
+ my $test_name = (split(/::/, (caller(0))[3]))[-1];
+
+ create_zone({ type => "simple", zone => "SIMPLE", ipam => "pve" });
+ setup_evpn_zone("EVPN");
+
+ eval { create_vnet({ type => "vnet", zone => "SIMPLE", vnet => "simplev", 'ipv6-ra' => 1 }) };
+ like($@, qr/ipv6-ra/, "$test_name: RA options are rejected on a vnet in a simple zone");
+
+ eval {
+ create_vnet({
+ type => "vnet",
+ zone => "EVPN",
+ vnet => "evpnv",
+ tag => 100,
+ 'ipv6-ra' => 1,
+ });
+ };
+ ok(!$@, "$test_name: RA options are accepted on a vnet in an EVPN zone");
+
+ eval { update_vnet({ vnet => "evpnv", zone => "SIMPLE", delete => "tag" }) };
+ like($@, qr/ipv6-ra/, "$test_name: a vnet with RA options cannot change to a simple zone");
+}
+run_test(\&test_ra_options_only_in_evpn_zones);
+
+sub test_router_lifetime_against_interval {
+ my $test_name = (split(/::/, (caller(0))[3]))[-1];
+
+ setup_evpn_zone("EVPN");
+ create_vnet({ type => "vnet", zone => "EVPN", vnet => "evpnv", tag => 100, 'ipv6-ra' => 1 });
+
+ eval { update_vnet({ vnet => "evpnv", 'ipv6-ra-router-lifetime' => 300 }) };
+ like(
+ $@,
+ qr/ipv6-ra-router-lifetime/,
+ "$test_name: a router lifetime below the default RA interval is rejected",
+ );
+
+ eval { update_vnet({ vnet => "evpnv", 'ipv6-ra-router-lifetime' => 0 }) };
+ ok(!$@, "$test_name: a router lifetime of 0 is accepted");
+
+ eval {
+ update_vnet({
+ vnet => "evpnv",
+ 'ipv6-ra-interval' => 300,
+ 'ipv6-ra-router-lifetime' => 300,
+ });
+ };
+ ok(!$@, "$test_name: a router lifetime equal to the RA interval is accepted");
+}
+run_test(\&test_router_lifetime_against_interval);
+
+sub test_prefix_options_only_on_ipv6_evpn_subnets {
+ my $test_name = (split(/::/, (caller(0))[3]))[-1];
+
+ create_zone({ type => "simple", zone => "SIMPLE", ipam => "pve" });
+ create_vnet({ type => "vnet", zone => "SIMPLE", vnet => "simplev" });
+ setup_evpn_zone("EVPN");
+ create_vnet({ type => "vnet", zone => "EVPN", vnet => "evpnv", tag => 100, 'ipv6-ra' => 1 });
+
+ eval {
+ create_subnet({
+ type => "subnet",
+ vnet => "evpnv",
+ subnet => "10.0.0.0/24",
+ 'nd-prefix-on-link' => 0,
+ });
+ };
+ like($@, qr/nd-prefix-on-link/, "$test_name: prefix options are rejected on an IPv4 subnet");
+
+ eval {
+ create_subnet({
+ type => "subnet",
+ vnet => "simplev",
+ subnet => "fd00:1::/64",
+ 'nd-prefix-on-link' => 0,
+ });
+ };
+ like(
+ $@,
+ qr/nd-prefix-on-link/,
+ "$test_name: prefix options are rejected on a subnet outside EVPN zones",
+ );
+
+ eval {
+ create_subnet({
+ type => "subnet",
+ vnet => "evpnv",
+ subnet => "fd00:1::/64",
+ 'nd-prefix-on-link' => 0,
+ });
+ };
+ ok(!$@, "$test_name: prefix options are accepted on an IPv6 subnet in an EVPN zone");
+}
+run_test(\&test_prefix_options_only_on_ipv6_evpn_subnets);
+
+sub test_slaac_needs_a_64 {
+ my $test_name = (split(/::/, (caller(0))[3]))[-1];
+
+ setup_evpn_zone("EVPN");
+ create_vnet({ type => "vnet", zone => "EVPN", vnet => "evpnv", tag => 100, 'ipv6-ra' => 1 });
+
+ eval { create_subnet({ type => "subnet", vnet => "evpnv", subnet => "fd00:1::/96" }) };
+ ok(!$@, "$test_name: a non-/64 IPv6 subnet is accepted");
+
+ eval {
+ create_subnet({
+ type => "subnet",
+ vnet => "evpnv",
+ subnet => "fd00:2::/96",
+ 'nd-prefix-autonomous' => 1,
+ });
+ };
+ like(
+ $@,
+ qr/nd-prefix-autonomous/,
+ "$test_name: the autonomous flag is rejected on a non-/64 IPv6 subnet",
+ );
+
+ eval {
+ create_subnet({
+ type => "subnet",
+ vnet => "evpnv",
+ subnet => "fd00:3::/64",
+ 'nd-prefix-autonomous' => 1,
+ });
+ };
+ ok(!$@, "$test_name: the autonomous flag is accepted on a /64");
+
+ eval {
+ create_subnet({
+ type => "subnet",
+ vnet => "evpnv",
+ subnet => "fd00:4::/64",
+ snat => 1,
+ });
+ };
+ ok(!$@, "$test_name: an IPv6 subnet with SNAT is accepted");
+}
+run_test(\&test_slaac_needs_a_64);
+
+sub test_preferred_lifetime_not_above_valid {
+ my $test_name = (split(/::/, (caller(0))[3]))[-1];
+
+ setup_evpn_zone("EVPN");
+ create_vnet({ type => "vnet", zone => "EVPN", vnet => "evpnv", tag => 100, 'ipv6-ra' => 1 });
+
+ eval {
+ create_subnet({
+ type => "subnet",
+ vnet => "evpnv",
+ subnet => "fd00:1::/64",
+ 'nd-prefix-valid-lifetime' => 100,
+ 'nd-prefix-preferred-lifetime' => 200,
+ });
+ };
+ like(
+ $@,
+ qr/nd-prefix-preferred-lifetime/,
+ "$test_name: a preferred lifetime above the valid lifetime is rejected",
+ );
+
+ eval {
+ create_subnet({
+ type => "subnet",
+ vnet => "evpnv",
+ subnet => "fd00:2::/64",
+ 'nd-prefix-preferred-lifetime' => 3000000,
+ });
+ };
+ like(
+ $@,
+ qr/nd-prefix-preferred-lifetime/,
+ "$test_name: a preferred lifetime above the default valid lifetime is rejected",
+ );
+
+ eval {
+ create_subnet({
+ type => "subnet",
+ vnet => "evpnv",
+ subnet => "fd00:3::/64",
+ 'nd-prefix-valid-lifetime' => 3600,
+ });
+ };
+ ok(!$@, "$test_name: a valid lifetime below the default preferred lifetime is accepted");
+
+ eval {
+ update_subnet({
+ vnet => "evpnv",
+ subnet => "EVPN-fd00:3::-64",
+ 'nd-prefix-preferred-lifetime' => 7200,
+ });
+ };
+ like(
+ $@,
+ qr/nd-prefix-preferred-lifetime/,
+ "$test_name: an update cannot raise the preferred lifetime above the valid one",
+ );
+}
+run_test(\&test_preferred_lifetime_not_above_valid);
+
done_testing();
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 000000000000..b8f30ff12bff
--- /dev/null
+++ b/src/test/zones/evpn/slaac/expected_controller_config
@@ -0,0 +1,53 @@
+frr version 10.6.1
+frr defaults datacenter
+hostname localhost
+log syslog informational
+service integrated-vtysh-config
+!
+vrf vrf_myzone
+ vni 1000
+exit-vrf
+!
+router bgp 65000
+ bgp router-id 192.168.0.1
+ no bgp hard-administrative-reset
+ no bgp default ipv4-unicast
+ coalesce-time 1000
+ no bgp graceful-restart notification
+ neighbor VTEP peer-group
+ neighbor VTEP remote-as 65000
+ neighbor VTEP bfd
+ neighbor 192.168.0.2 peer-group VTEP
+ neighbor 192.168.0.3 peer-group VTEP
+ !
+ address-family l2vpn evpn
+ neighbor VTEP activate
+ neighbor VTEP route-map MAP_VTEP_IN in
+ neighbor VTEP route-map MAP_VTEP_OUT out
+ advertise-all-vni
+ exit-address-family
+exit
+!
+router bgp 65000 vrf vrf_myzone
+ bgp router-id 192.168.0.1
+ no bgp hard-administrative-reset
+ no bgp graceful-restart notification
+exit
+!
+interface myvnet
+ no ipv6 nd suppress-ra
+ ipv6 nd managed-config-flag
+ ipv6 nd other-config-flag
+ ipv6 nd rdnss 2a08:2142:302:3::53
+ ipv6 nd prefix 2a08:2142:302:3::/64 7200 3600
+ ipv6 nd prefix fd00:1::/64 2592000 604800 no-autoconfig
+exit
+!
+route-map MAP_VTEP_IN permit 1
+exit
+!
+route-map MAP_VTEP_OUT permit 1
+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 000000000000..6853bd4a249f
--- /dev/null
+++ b/src/test/zones/evpn/slaac/expected_sdn_interfaces
@@ -0,0 +1,44 @@
+#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
+ post-up echo 2 > /proc/sys/net/ipv6/conf/myvnet/accept_untracked_na || true
+ 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 000000000000..66bb826a44b3
--- /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 000000000000..e6294e3f40da
--- /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 v5 8/9] api: vnets: include the zone type in the vnet list
2026-10-29 15:41 [PATCH docs/manager/network/proxmox{-perl-rs,-ve-rs} v5 0/9] add IPv6 RA / SLAAC support to EVPN zones Hannes Laimer
` (6 preceding siblings ...)
2026-10-29 15:41 ` [PATCH pve-network v5 7/9] sdn: evpn: add IPv6 RA / SLAAC support Hannes Laimer
@ 2026-10-29 15:41 ` Hannes Laimer
2026-10-29 15:41 ` [PATCH pve-docs v5 9/9] sdn: add IPv6 RA / SLAAC section Hannes Laimer
2026-10-30 15:07 ` [PATCH docs/manager/network/proxmox{-perl-rs,-ve-rs} v5 0/9] add IPv6 RA / SLAAC support to EVPN zones Lukas Sichert
9 siblings, 0 replies; 12+ messages in thread
From: Hannes Laimer @ 2026-10-29 15:41 UTC (permalink / raw)
To: pve-devel
Only vnets in EVPN zones can set the IPv6 Router Advertisement
options, and only their IPv6 subnets can set the prefix flags and
lifetimes. With the zone type on every entry, the list alone shows
which vnets and subnets take these options.
Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
Notes:
v5:
- the zone type follows a pending zone change
src/PVE/API2/Network/SDN/Vnets.pm | 14 ++++++++++++++
1 file changed, 14 insertions(+)
diff --git a/src/PVE/API2/Network/SDN/Vnets.pm b/src/PVE/API2/Network/SDN/Vnets.pm
index 0bd027ec1c0d..756826b54068 100644
--- a/src/PVE/API2/Network/SDN/Vnets.pm
+++ b/src/PVE/API2/Network/SDN/Vnets.pm
@@ -160,6 +160,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 =>
@@ -179,15 +185,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);
@@ -197,6 +208,9 @@ __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);
+ my $type_zoneid = $scfg->{pending}->{zone} // $zoneid;
+ $scfg->{'zone-type'} = $zone_cfg->{ids}->{$type_zoneid}->{type}
+ if $zone_cfg->{ids}->{$type_zoneid};
push @$res, $scfg;
}
--
2.47.3
^ permalink raw reply related [flat|nested] 12+ messages in thread
* [PATCH pve-docs v5 9/9] sdn: add IPv6 RA / SLAAC section
2026-10-29 15:41 [PATCH docs/manager/network/proxmox{-perl-rs,-ve-rs} v5 0/9] add IPv6 RA / SLAAC support to EVPN zones Hannes Laimer
` (7 preceding siblings ...)
2026-10-29 15:41 ` [PATCH pve-network v5 8/9] api: vnets: include the zone type in the vnet list Hannes Laimer
@ 2026-10-29 15:41 ` Hannes Laimer
2026-10-30 15:07 ` [PATCH docs/manager/network/proxmox{-perl-rs,-ve-rs} v5 0/9] add IPv6 RA / SLAAC support to EVPN zones Lukas Sichert
9 siblings, 0 replies; 12+ messages in thread
From: Hannes Laimer @ 2026-10-29 15:41 UTC (permalink / raw)
To: pve-devel
Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
pvesdn.adoc | 71 +++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 71 insertions(+)
diff --git a/pvesdn.adoc b/pvesdn.adoc
index 3fd3533ad07f..dc634fe43074 100644
--- a/pvesdn.adoc
+++ b/pvesdn.adoc
@@ -409,6 +409,10 @@ xref:pvesdn_firewall_integration[VNET Firewall] to further isolate traffic in
the VNET across nodes. For example, DROP by default and only allow traffic from
the IP subnet to the gateway and vice versa.
+A VNet in an EVPN zone can send
+xref:pvesdn_config_ipv6_ra[IPv6 Router Advertisements], so guests configure
+their IPv6 addresses themselves.
+
[[pvesdn_config_subnet]]
Subnets
-------
@@ -442,6 +446,73 @@ 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.
+An IPv6 subnet in an EVPN zone has additional per-prefix options for
+xref:pvesdn_config_ipv6_ra[IPv6 Router Advertisements].
+
+
+[[pvesdn_config_ipv6_ra]]
+IPv6 Router Advertisements (SLAAC)
+----------------------------------
+
+A VNet in an xref:pvesdn_zone_plugin_evpn[EVPN zone] can send IPv6 Router
+Advertisements (RAs) on its bridge, from every node of the zone. Guests on the
+VNet then configure their IPv6 addresses through Stateless Address
+Autoconfiguration (SLAAC) and learn the gateway from the RA. The RA can also
+carry DNS servers, a link MTU, and the flags that point guests to DHCPv6.
+
+RAs are enabled per VNet on the 'IPv6 Router Advertisement' tab of the VNet,
+under `Datacenter -> SDN -> VNets`. The VNet advertises each of its IPv6
+subnets as a prefix, with the settings of that prefix on the 'IPv6 Prefix
+Options' tab of the subnet. Both tabs are only available in EVPN zones.
+
+A VNet without an IPv6 subnet sends no RAs. With the defaults, a /64 subnet
+lets guests autoconfigure without further settings.
+
+Options on the VNet:
+
+Send Router Advertisements:: Enable RAs on this VNet. The other options on the
+ tab only take effect while this is enabled. Optional.
+
+DHCP Managed (M):: Set the managed-address (M) flag, which tells guests to also
+ request an address from a DHCPv6 server. Optional.
+
+DHCP Other (O):: Set the other-configuration (O) flag, which tells guests to
+ get other settings, such as DNS servers, from a DHCPv6 server. Optional.
+
+RDNSS:: Recursive DNS server (RDNSS) addresses to advertise, so guests learn
+ their resolvers from the RA. Optional.
+
+Router Lifetime (s):: Seconds for which guests keep the gateway as their
+ default router, 0 to 9000. With 0, guests do not use the gateway as a default
+ router, for example when the RA only distributes a prefix or DNS servers. A
+ value above 0 must be at least the RA interval. Optional, defaults to three
+ times the RA interval.
+
+RA Interval (s):: Maximum number of seconds between two unsolicited RAs,
+ 4 to 1800. Optional, defaults to 600.
+
+Advertised MTU:: Link MTU to advertise to guests, 1280 to 65535. Optional,
+ without it the RA carries no MTU.
+
+Options on each IPv6 subnet:
+
+SLAAC (A):: Set the autonomous (A) flag, which lets guests derive an address
+ from this prefix. SLAAC needs a /64 prefix, so the flag is enabled by default
+ on /64 subnets and cannot be set on others. Such subnets are still
+ advertised, without the flag.
+
+On-link (L):: Set the on-link (L) flag, which tells guests that addresses in
+ this prefix are reachable directly on the link rather than through the
+ gateway. Optional, enabled by default.
+
+Valid Lifetime (s):: Seconds the prefix, and addresses derived from it, stay
+ valid. Optional, defaults to 2592000 (30 days).
+
+Preferred Lifetime (s):: Seconds an address derived from this prefix stays
+ preferred for new connections. Must not exceed the valid lifetime. If only a
+ shorter valid lifetime is set, the preferred lifetime is lowered to match
+ it. Optional, defaults to 604800 (7 days).
+
[[pvesdn_config_controllers]]
Controllers
--
2.47.3
^ permalink raw reply related [flat|nested] 12+ messages in thread
* Re: [PATCH docs/manager/network/proxmox{-perl-rs,-ve-rs} v5 0/9] add IPv6 RA / SLAAC support to EVPN zones
2026-10-29 15:41 [PATCH docs/manager/network/proxmox{-perl-rs,-ve-rs} v5 0/9] add IPv6 RA / SLAAC support to EVPN zones Hannes Laimer
` (8 preceding siblings ...)
2026-10-29 15:41 ` [PATCH pve-docs v5 9/9] sdn: add IPv6 RA / SLAAC section Hannes Laimer
@ 2026-10-30 15:07 ` Lukas Sichert
2026-10-30 16:30 ` Lukas Sichert
9 siblings, 1 reply; 12+ messages in thread
From: Lukas Sichert @ 2026-10-30 15:07 UTC (permalink / raw)
To: Hannes Laimer, pve-devel
On 2026-10-29 16:41, Hannes Laimer <h.laimer@proxmox.com> wrote:
> This series adds IPv6 Router Advertisement / SLAAC support to EVPN
> zones.
I have only tested this very briefly on a three node cluster with an
existing IPv6 EVPN zone. After enabling 'Send Router Advertisements' on
a vnet called 'evpnet' and applying, then disabling and applying again,
the task exits with a warning. Going by the tasklog it seems to be
trying to remove the interface from frr even though it is still up:
ERROR: Failed to execute no interface evpnet
and
reason: 'Failed to create cfgdata: only inactive interfaces can be deleted'
and further down:
WARN: reloading frr configuration failed
A second Apply resolves this though.
^ permalink raw reply [flat|nested] 12+ messages in thread
* Re: [PATCH docs/manager/network/proxmox{-perl-rs,-ve-rs} v5 0/9] add IPv6 RA / SLAAC support to EVPN zones
2026-10-30 15:07 ` [PATCH docs/manager/network/proxmox{-perl-rs,-ve-rs} v5 0/9] add IPv6 RA / SLAAC support to EVPN zones Lukas Sichert
@ 2026-10-30 16:30 ` Lukas Sichert
0 siblings, 0 replies; 12+ messages in thread
From: Lukas Sichert @ 2026-10-30 16:30 UTC (permalink / raw)
To: Lukas Sichert, Hannes Laimer, pve-devel
On 2026-10-30 16:07, Lukas Sichert <l.sichert@proxmox.com> wrote:
> On 2026-10-29 16:41, Hannes Laimer <h.laimer@proxmox.com> wrote:
>
>> This series adds IPv6 Router Advertisement / SLAAC support to EVPN
>> zones.
>
> I have only tested this very briefly on a three node cluster with an
> existing IPv6 EVPN zone. After enabling 'Send Router Advertisements' on
> a vnet called 'evpnet' and applying, then disabling and applying again,
> the task exits with a warning. Going by the tasklog it seems to be
> trying to remove the interface from frr even though it is still up:
As discussed off list this is not actually harmful, just a bit noisy. FRR
refuses to delete an interface that is still up, so any time a whole
'interface <name>' block disappears from frr.conf, frr-reload.py fails
the same way. Removing an interface from a fabric triggers the same task
warning. The failed reload falls back to restarting frr, which leaves the
configuration in the correct state, and the next Apply is clean. So this
is not something this series needs to fix. Sorry for the noise.
^ permalink raw reply [flat|nested] 12+ messages in thread
end of thread, other threads:[~2026-10-30 16:30 UTC | newest]
Thread overview: 12+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
2026-10-29 15:41 [PATCH docs/manager/network/proxmox{-perl-rs,-ve-rs} v5 0/9] add IPv6 RA / SLAAC support to EVPN zones Hannes Laimer
2026-10-29 15:41 ` [PATCH proxmox-perl-rs v5 1/9] pve-rs: sdn: add IPv6 RA builder binding Hannes Laimer
2026-10-29 15:41 ` [PATCH proxmox-ve-rs v5 2/9] frr: add IPv6 router advertisement support Hannes Laimer
2026-10-29 15:41 ` [PATCH proxmox-ve-rs v5 3/9] ve-config: sdn: add IPv6 router advertisement settings Hannes Laimer
2026-10-29 15:41 ` [PATCH pve-manager v5 4/9] ui: sdn: add IPv6 RA / SLAAC support Hannes Laimer
2026-10-29 15:41 ` [PATCH pve-network v5 5/9] sdn: evpn: derive IP version from CIDR for gateway-less subnets Hannes Laimer
2026-10-29 15:41 ` [PATCH pve-network v5 6/9] sdn: evpn: accept untracked IPv6 neighbor advertisements Hannes Laimer
2026-10-29 15:41 ` [PATCH pve-network v5 7/9] sdn: evpn: add IPv6 RA / SLAAC support Hannes Laimer
2026-10-29 15:41 ` [PATCH pve-network v5 8/9] api: vnets: include the zone type in the vnet list Hannes Laimer
2026-10-29 15:41 ` [PATCH pve-docs v5 9/9] sdn: add IPv6 RA / SLAAC section Hannes Laimer
2026-10-30 15:07 ` [PATCH docs/manager/network/proxmox{-perl-rs,-ve-rs} v5 0/9] add IPv6 RA / SLAAC support to EVPN zones Lukas Sichert
2026-10-30 16:30 ` Lukas Sichert
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox