From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [IPv6:2a01:7e0:0:424::9]) by lore.proxmox.com (Postfix) with ESMTPS id 5D43F1FF13C for ; Thu, 30 Apr 2026 16:31:34 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id E4A09CA01; Thu, 30 Apr 2026 16:30:36 +0200 (CEST) From: Hannes Laimer To: pve-devel@lists.proxmox.com Subject: [PATCH pve-network v2 04/11] sdn: evpn: add IPv6 RA / SLAAC support Date: Thu, 30 Apr 2026 16:29:46 +0200 Message-ID: <20260430142953.315412-5-h.laimer@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260430142953.315412-1-h.laimer@proxmox.com> References: <20260430142953.315412-1-h.laimer@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1777559296829 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.080 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Message-ID-Hash: AOBRUZO3DRARXL74OCTNCN4XBYKQJSYK X-Message-ID-Hash: AOBRUZO3DRARXL74OCTNCN4XBYKQJSYK X-MailFrom: h.laimer@proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox VE development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: Configure IPv6 Router Advertisements on EVPN vnets so guests can autoconfigure addresses via SLAAC and pick up DNS / DHCP hints, without standing up a DHCPv6 server alongside. The configuration is split between the VNet and the subnet, matching the protocol's per-RA / per-prefix layers. Defaults are picked so the typical SLAAC case needs no per-subnet configuration. The API rejects each option where it does not apply: the RA toggle outside EVPN zones, per-prefix overrides on IPv4 subnets, the autonomous flag on non-/64 prefixes, and SNAT on IPv6 subnets. Signed-off-by: Hannes Laimer --- src/PVE/Network/SDN/Controllers/EvpnPlugin.pm | 12 +++- src/PVE/Network/SDN/SubnetPlugin.pm | 67 +++++++++++++++++++ src/PVE/Network/SDN/VnetPlugin.pm | 67 +++++++++++++++++++ .../evpn/slaac/expected_controller_config | 53 +++++++++++++++ .../zones/evpn/slaac/expected_sdn_interfaces | 43 ++++++++++++ src/test/zones/evpn/slaac/interfaces | 7 ++ src/test/zones/evpn/slaac/sdn_config | 41 ++++++++++++ 7 files changed, 289 insertions(+), 1 deletion(-) create mode 100644 src/test/zones/evpn/slaac/expected_controller_config create mode 100644 src/test/zones/evpn/slaac/expected_sdn_interfaces create mode 100644 src/test/zones/evpn/slaac/interfaces create mode 100644 src/test/zones/evpn/slaac/sdn_config diff --git a/src/PVE/Network/SDN/Controllers/EvpnPlugin.pm b/src/PVE/Network/SDN/Controllers/EvpnPlugin.pm index f50387d..96cd619 100644 --- a/src/PVE/Network/SDN/Controllers/EvpnPlugin.pm +++ b/src/PVE/Network/SDN/Controllers/EvpnPlugin.pm @@ -11,6 +11,7 @@ use PVE::RESTEnvironment qw(log_warn); use PVE::Network::SDN::Controllers::Plugin; use PVE::Network::SDN::Zones::Plugin; use PVE::Network::SDN::Fabrics; +use PVE::RS::SDN::IPv6RA; use Net::IP; use base('PVE::Network::SDN::Controllers::Plugin'); @@ -547,6 +548,16 @@ sub generate_zone_frr_config { sub generate_vnet_frr_config { my ($class, $plugin_config, $controller, $zone, $zoneid, $vnetid, $config) = @_; + # Read the pending subnets (subnets.cfg) so dry-run sees pending changes. After an + # apply this matches the running-config snapshot anyway. + my $subnets = PVE::Network::SDN::Vnets::get_subnets($vnetid, 0) // {}; + + if ($plugin_config->{'ipv6-ra'}) { + my $nd_iface = + PVE::RS::SDN::IPv6RA::build_interface($vnetid, $plugin_config, $subnets); + $config->{frr}->{nd_interfaces}->{$vnetid} = $nd_iface if $nd_iface; + } + my $exitnodes = $zone->{'exitnodes'}; my $exitnodes_local_routing = $zone->{'exitnodes-local-routing'}; @@ -557,7 +568,6 @@ sub generate_vnet_frr_config { return if !$is_gateway; - my $subnets = PVE::Network::SDN::Vnets::get_subnets($vnetid, 1); $config->{frr}->{ip_routes} //= []; foreach my $subnetid (sort keys %{$subnets}) { my $subnet = $subnets->{$subnetid}; diff --git a/src/PVE/Network/SDN/SubnetPlugin.pm b/src/PVE/Network/SDN/SubnetPlugin.pm index e2a0e50..66c184b 100644 --- a/src/PVE/Network/SDN/SubnetPlugin.pm +++ b/src/PVE/Network/SDN/SubnetPlugin.pm @@ -177,6 +177,37 @@ sub properties { description => 'IP address for the DNS server', optional => 1, }, + # Per-prefix Router Advertisement overrides. Only meaningful for IPv6 subnets + # whose vnet has `ipv6-ra` enabled, silently ignored otherwise. + 'nd-prefix-autonomous' => { + type => 'boolean', + description => + "Set the autonomous (A) flag for this prefix in RAs, enabling SLAAC for" + . " hosts on this subnet (default: enabled). Only valid on /64 subnets.", + optional => 1, + default => 1, + }, + 'nd-prefix-on-link' => { + type => 'boolean', + description => + "Set the on-link (L) flag for this prefix in RAs (default: on-link).", + optional => 1, + default => 1, + }, + 'nd-prefix-valid-lifetime' => { + type => 'integer', + description => "Valid lifetime for this prefix in RAs, in seconds.", + minimum => 0, + default => 2592000, + optional => 1, + }, + 'nd-prefix-preferred-lifetime' => { + type => 'integer', + description => "Preferred lifetime for this prefix in RAs, in seconds.", + minimum => 0, + default => 604800, + optional => 1, + }, }; } @@ -189,6 +220,10 @@ sub options { dnszoneprefix => { optional => 1 }, 'dhcp-range' => { optional => 1 }, 'dhcp-dns-server' => { optional => 1 }, + 'nd-prefix-autonomous' => { optional => 1 }, + 'nd-prefix-on-link' => { optional => 1 }, + 'nd-prefix-valid-lifetime' => { optional => 1 }, + 'nd-prefix-preferred-lifetime' => { optional => 1 }, }; } @@ -206,6 +241,38 @@ sub on_update_hook { my $dns = $zone->{dns}; my $dnszone = $zone->{dnszone}; my $reversedns = $zone->{reversedns}; + my $is_ipv6 = PVE::JSONSchema::pve_verify_cidrv6($cidr, 1); + + # Per-prefix RA overrides only apply on IPv6 subnets. Reject explicit overrides on + # IPv4 subnets so the user notices their config has no effect. + my $has_nd_override = + defined($subnet->{'nd-prefix-autonomous'}) + || defined($subnet->{'nd-prefix-on-link'}) + || defined($subnet->{'nd-prefix-valid-lifetime'}) + || defined($subnet->{'nd-prefix-preferred-lifetime'}); + + if ($has_nd_override && !$is_ipv6) { + raise_param_exc( + { 'nd-prefix' => "nd-prefix-* options are only valid on IPv6 subnets" }); + } + + # SLAAC (autonomous flag) requires the prefix to be /64. Default for autonomous is on, + # so the check fires unless the user explicitly opts out on a non-/64 IPv6 subnet. + if ($is_ipv6 && $mask != 64) { + my $autonomous = $subnet->{'nd-prefix-autonomous'}; + $autonomous = 1 if !defined($autonomous); + if ($autonomous) { + raise_param_exc({ + 'nd-prefix-autonomous' => + "autonomous (SLAAC) flag requires a /64 prefix, set nd-prefix-autonomous=0 to advertise this prefix without SLAAC", + }); + } + } + + # SNAT is meaningless on IPv6 subnets. + if ($subnet->{snat} && $is_ipv6) { + raise_param_exc({ snat => "SNAT is not supported on IPv6 subnets" }); + } my $mac = undef; diff --git a/src/PVE/Network/SDN/VnetPlugin.pm b/src/PVE/Network/SDN/VnetPlugin.pm index e041575..28779c8 100644 --- a/src/PVE/Network/SDN/VnetPlugin.pm +++ b/src/PVE/Network/SDN/VnetPlugin.pm @@ -7,6 +7,8 @@ use PVE::Cluster qw(cfs_read_file cfs_write_file cfs_lock_file); use PVE::Exception qw(raise raise_param_exc); use PVE::JSONSchema qw(get_standard_option); +use PVE::Network::SDN::Zones; + use PVE::SectionConfig; use base qw(PVE::SectionConfig); @@ -83,6 +85,57 @@ sub properties { "If true, sets the isolated property for all interfaces on the bridge of this VNet.", optional => 1, }, + # IPv6 Router Advertisement settings (EVPN zones only). The master toggle gates + # the rest. Per-prefix flags live on the subnet (nd-prefix-*). + 'ipv6-ra' => { + type => 'boolean', + description => + "Emit IPv6 Router Advertisements on this VNet's bridge. Requires the VNet to belong" + . " to an EVPN zone with at least one IPv6 subnet.", + optional => 1, + }, + 'ipv6-ra-managed' => { + type => 'boolean', + description => "Set the managed-address (M) flag in emitted RAs.", + optional => 1, + }, + 'ipv6-ra-other' => { + type => 'boolean', + description => "Set the other-configuration (O) flag in emitted RAs.", + optional => 1, + }, + 'ipv6-ra-rdnss' => { + type => 'array', + description => "RDNSS (Recursive DNS Server) addresses advertised in RAs.", + optional => 1, + items => { + type => 'string', + format => 'ipv6', + }, + }, + 'ipv6-ra-router-lifetime' => { + type => 'integer', + description => + "Router lifetime advertised in RAs (seconds). 0 tells hosts not to use this" + . " router as a default gateway.", + optional => 1, + minimum => 0, + maximum => 9000, + }, + 'ipv6-ra-interval' => { + type => 'integer', + description => "Maximum interval between unsolicited RAs (seconds).", + optional => 1, + minimum => 4, + maximum => 1800, + }, + 'ipv6-ra-mtu' => { + type => 'integer', + description => "MTU advertised in RAs.", + optional => 1, + minimum => 1280, + maximum => 65535, + }, }; } @@ -93,6 +146,13 @@ sub options { alias => { optional => 1 }, vlanaware => { optional => 1 }, 'isolate-ports' => { optional => 1 }, + 'ipv6-ra' => { optional => 1 }, + 'ipv6-ra-managed' => { optional => 1 }, + 'ipv6-ra-other' => { optional => 1 }, + 'ipv6-ra-rdnss' => { optional => 1 }, + 'ipv6-ra-router-lifetime' => { optional => 1 }, + 'ipv6-ra-interval' => { optional => 1 }, + 'ipv6-ra-mtu' => { optional => 1 }, }; } @@ -117,6 +177,13 @@ sub on_update_hook { raise_param_exc({ vlanaware => "vlanaware vnet is not compatible with subnets" }) if $subnets; } + + if ($vnet->{'ipv6-ra'}) { + my $zone_cfg = PVE::Network::SDN::Zones::config(); + my $zone = $zone_cfg->{ids}->{ $vnet->{zone} }; + raise_param_exc({ 'ipv6-ra' => "IPv6 RA is only supported on EVPN zones" }) + if !$zone || $zone->{type} ne 'evpn'; + } } 1; diff --git a/src/test/zones/evpn/slaac/expected_controller_config b/src/test/zones/evpn/slaac/expected_controller_config new file mode 100644 index 0000000..b8fafdf --- /dev/null +++ b/src/test/zones/evpn/slaac/expected_controller_config @@ -0,0 +1,53 @@ +frr version 10.4.1 +frr defaults datacenter +hostname localhost +log syslog informational +service integrated-vtysh-config +! +vrf vrf_myzone + vni 1000 +exit-vrf +! +router bgp 65000 + bgp router-id 192.168.0.1 + no bgp hard-administrative-reset + no bgp default ipv4-unicast + coalesce-time 1000 + no bgp graceful-restart notification + neighbor VTEP peer-group + neighbor VTEP remote-as 65000 + neighbor VTEP bfd + neighbor 192.168.0.2 peer-group VTEP + neighbor 192.168.0.3 peer-group VTEP + ! + address-family l2vpn evpn + neighbor VTEP activate + neighbor VTEP route-map MAP_VTEP_IN in + neighbor VTEP route-map MAP_VTEP_OUT out + advertise-all-vni + exit-address-family +exit +! +router bgp 65000 vrf vrf_myzone + bgp router-id 192.168.0.1 + no bgp hard-administrative-reset + no bgp graceful-restart notification +exit +! +route-map MAP_VTEP_IN permit 1 +exit +! +route-map MAP_VTEP_OUT permit 1 +exit +! +interface myvnet + no ipv6 nd suppress-ra + ipv6 nd managed-config-flag + ipv6 nd other-config-flag + ipv6 nd rdnss 2a08:2142:302:3::53 + ipv6 nd prefix 2a08:2142:302:3::/64 7200 3600 + ipv6 nd prefix fd00:1::/64 no-autoconfig +exit +! +line vty +! diff --git a/src/test/zones/evpn/slaac/expected_sdn_interfaces b/src/test/zones/evpn/slaac/expected_sdn_interfaces new file mode 100644 index 0000000..2e479a3 --- /dev/null +++ b/src/test/zones/evpn/slaac/expected_sdn_interfaces @@ -0,0 +1,43 @@ +#version:1 + +auto myvnet +iface myvnet + address 2a08:2142:302:3::1/64 + address fd00:1::1/64 + hwaddress A2:1D:CB:1A:C0:8B + bridge_ports vxlan_myvnet + bridge_stp off + bridge_fd 0 + mtu 1450 + ip6-forward on + arp-accept on + vrf vrf_myzone + +auto vrf_myzone +iface vrf_myzone + vrf-table auto + post-up ip route add vrf vrf_myzone unreachable default metric 4278198272 + +auto vrfbr_myzone +iface vrfbr_myzone + bridge-ports vrfvx_myzone + bridge_stp off + bridge_fd 0 + mtu 1450 + vrf vrf_myzone + +auto vrfvx_myzone +iface vrfvx_myzone + vxlan-id 1000 + vxlan-local-tunnelip 192.168.0.1 + bridge-learning off + bridge-arp-nd-suppress on + mtu 1450 + +auto vxlan_myvnet +iface vxlan_myvnet + vxlan-id 100 + vxlan-local-tunnelip 192.168.0.1 + bridge-learning off + bridge-arp-nd-suppress on + mtu 1450 diff --git a/src/test/zones/evpn/slaac/interfaces b/src/test/zones/evpn/slaac/interfaces new file mode 100644 index 0000000..66bb826 --- /dev/null +++ b/src/test/zones/evpn/slaac/interfaces @@ -0,0 +1,7 @@ +auto vmbr0 +iface vmbr0 inet static + address 192.168.0.1/24 + gateway 192.168.0.254 + bridge-ports eth0 + bridge-stp off + bridge-fd 0 diff --git a/src/test/zones/evpn/slaac/sdn_config b/src/test/zones/evpn/slaac/sdn_config new file mode 100644 index 0000000..e6294e3 --- /dev/null +++ b/src/test/zones/evpn/slaac/sdn_config @@ -0,0 +1,41 @@ +{ + version => 1, + vnets => { + ids => { + myvnet => { + tag => "100", + type => "vnet", + zone => "myzone", + 'ipv6-ra' => 1, + 'ipv6-ra-managed' => 1, + 'ipv6-ra-other' => 1, + 'ipv6-ra-rdnss' => ['2a08:2142:302:3::53'], + }, + }, + }, + + zones => { + ids => { myzone => { ipam => "pve", type => "evpn", controller => "evpnctl", 'vrf-vxlan' => 1000, 'mac' => 'A2:1D:CB:1A:C0:8B' } }, + }, + controllers => { + ids => { evpnctl => { type => "evpn", 'peers' => '192.168.0.1,192.168.0.2,192.168.0.3', asn => "65000" } }, + }, + + subnets => { + ids => { + 'myzone-2a08:2142:302:3::-64' => { + 'type' => 'subnet', + 'vnet' => 'myvnet', + 'gateway' => '2a08:2142:302:3::1', + 'nd-prefix-valid-lifetime' => 7200, + 'nd-prefix-preferred-lifetime' => 3600, + }, + 'myzone-fd00:1::-64' => { + 'type' => 'subnet', + 'vnet' => 'myvnet', + 'gateway' => 'fd00:1::1', + 'nd-prefix-autonomous' => 0, + }, + } + } +} -- 2.47.3