public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: Hannes Laimer <h.laimer@proxmox.com>
To: pve-devel@lists.proxmox.com
Subject: [PATCH pve-network v5 7/9] sdn: evpn: add IPv6 RA / SLAAC support
Date: Thu, 29 Oct 2026 16:41:46 +0100	[thread overview]
Message-ID: <20261029154148.2882594-8-h.laimer@proxmox.com> (raw)
In-Reply-To: <20261029154148.2882594-1-h.laimer@proxmox.com>

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





  parent reply	other threads:[~2026-10-29 15:43 UTC|newest]

Thread overview: 12+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
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 ` Hannes Laimer [this message]
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

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20261029154148.2882594-8-h.laimer@proxmox.com \
    --to=h.laimer@proxmox.com \
    --cc=pve-devel@lists.proxmox.com \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal