From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from gate001.proxmox.com (gate001.proxmox.com [45.144.208.40]) by lore.proxmox.com (Postfix) with ESMTPS id 59C531FF0DF for ; Thu, 05 Nov 2026 11:06:31 +0100 (CET) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id DA209216D5; Thu, 05 Nov 2026 11:05:45 +0100 (CET) From: Hannes Laimer To: pve-devel@lists.proxmox.com Subject: [PATCH pve-network v6 10/17] sdn: zones: allow the ebpf DHCP backend on every zone type Date: Thu, 5 Nov 2026 11:03:09 +0100 Message-ID: <20261105100316.449163-11-h.laimer@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20261105100316.449163-1-h.laimer@proxmox.com> References: <20261105100316.449163-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: 1793873078471 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.286 Adjusted score from AWL reputation of From: address DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment (newer systems) RCVD_IN_DNSWL_MED -2.3 Sender listed at https://www.dnswl.org/, medium trust 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: TUDKHVOW52BVHAXSEHOH2ECXY7GZNZOS X-Message-ID-Hash: TUDKHVOW52BVHAXSEHOH2ECXY7GZNZOS 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: The ebpf backend answers each guest on the guest's own tap or veth device, so the zone type makes no difference to it. dnsmasq stays limited to simple zones, since every node runs its own instance on the vnet bridge and only a simple zone's bridge is cut off from the other nodes. Choosing it elsewhere fails on create and on update. Every zone type now reports the MTU its guests should use. The ebpf backend hands it out, and until now only simple zones could answer. A VXLAN or EVPN zone without an MTU setting takes it from the interface that reaches its peers. Signed-off-by: Hannes Laimer --- Notes: v5: * the VXLAN MTU helper moved into the preceding MTU fix src/PVE/API2/Network/SDN/Zones.pm | 8 +- src/PVE/Network/SDN/Zones/EvpnPlugin.pm | 32 +++++++ src/PVE/Network/SDN/Zones/FaucetPlugin.pm | 1 + src/PVE/Network/SDN/Zones/QinQPlugin.pm | 7 ++ src/PVE/Network/SDN/Zones/SimplePlugin.pm | 2 +- src/PVE/Network/SDN/Zones/VlanPlugin.pm | 7 ++ src/PVE/Network/SDN/Zones/VxlanPlugin.pm | 13 +++ src/test/run_test_vnets_blackbox.pl | 109 +++++++++++++++++++++- 8 files changed, 175 insertions(+), 4 deletions(-) diff --git a/src/PVE/API2/Network/SDN/Zones.pm b/src/PVE/API2/Network/SDN/Zones.pm index 4f61e1e858aa..97ed930fad5a 100644 --- a/src/PVE/API2/Network/SDN/Zones.pm +++ b/src/PVE/API2/Network/SDN/Zones.pm @@ -60,7 +60,7 @@ my $ZONE_PROPERTIES = { type => 'string', enum => PVE::Network::SDN::Dhcp->plugin_types(), optional => 1, - description => 'Name of DHCP server backend for this zone.', + description => 'DHCP backend serving the zone. dnsmasq works on simple zones only.', }, 'rt-import' => { type => 'string', @@ -406,6 +406,9 @@ __PACKAGE__->register_method({ raise_param_exc({ dnszone => "missing dns server" }) if $dnszone && !$dnsserver; + raise_param_exc({ dhcp => "the dnsmasq backend only supports simple zones" }) + if ($opts->{dhcp} // '') eq 'dnsmasq' && $plugin->type() ne 'simple'; + $zone_cfg->{ids}->{$id} = $opts; $plugin->on_update_hook($id, $zone_cfg, $controller_cfg); @@ -493,6 +496,9 @@ __PACKAGE__->register_method({ my $dnszone = $opts->{dnszone}; raise_param_exc({ dnszone => "missing dns server" }) if $dnszone && !$dnsserver; + raise_param_exc({ dhcp => "the dnsmasq backend only supports simple zones" }) + if ($scfg->{dhcp} // '') eq 'dnsmasq' && $plugin->type() ne 'simple'; + $plugin->on_update_hook($id, $zone_cfg, $controller_cfg); PVE::Network::SDN::Zones::write_config($zone_cfg); diff --git a/src/PVE/Network/SDN/Zones/EvpnPlugin.pm b/src/PVE/Network/SDN/Zones/EvpnPlugin.pm index 1720e1ff5735..78ab3e8dfe53 100644 --- a/src/PVE/Network/SDN/Zones/EvpnPlugin.pm +++ b/src/PVE/Network/SDN/Zones/EvpnPlugin.pm @@ -129,10 +129,42 @@ sub options { reversedns => { optional => 1 }, dnszone => { optional => 1 }, ipam => { optional => 1 }, + dhcp => { optional => 1 }, }; } # Plugin implementation +# the underlay interface is the one the controller's peers name, found the +# way the bridge generation finds it +sub get_mtu { + my ($class, $plugin_config) = @_; + + return $plugin_config->{mtu} if $plugin_config->{mtu}; + + my $iface; + my $controller_cfg = PVE::Network::SDN::running_config()->{controllers}; + my $controller = $controller_cfg->{ids}->{ $plugin_config->{controller} // '' }; + if ($controller && $controller->{peers}) { + my @peers = PVE::Tools::split_list($controller->{peers}); + my $local_node = PVE::INotify::nodename(); + my $bgprouter = PVE::Network::SDN::Controllers::EvpnPlugin::find_bgp_controller( + $local_node, $controller_cfg, + ); + my $isisrouter = PVE::Network::SDN::Controllers::EvpnPlugin::find_isis_controller( + $local_node, $controller_cfg, + ); + my $loopback = $bgprouter->{loopback} // $isisrouter->{loopback}; + (undef, $iface) = eval { + PVE::Network::SDN::Zones::Plugin::find_local_ip_interface_peers(\@peers, $loopback); + }; + } + return PVE::Network::SDN::Zones::VxlanPlugin::vxlan_mtu( + $plugin_config, + $iface, + PVE::INotify::read_file('interfaces'), + ); +} + sub generate_sdn_config { my ( $class, diff --git a/src/PVE/Network/SDN/Zones/FaucetPlugin.pm b/src/PVE/Network/SDN/Zones/FaucetPlugin.pm index 5f069aea85de..4e3f5eff8a8b 100644 --- a/src/PVE/Network/SDN/Zones/FaucetPlugin.pm +++ b/src/PVE/Network/SDN/Zones/FaucetPlugin.pm @@ -30,6 +30,7 @@ sub options { reversedns => { optional => 1 }, dnszone => { optional => 1 }, ipam => { optional => 1 }, + dhcp => { optional => 1 }, }; } diff --git a/src/PVE/Network/SDN/Zones/QinQPlugin.pm b/src/PVE/Network/SDN/Zones/QinQPlugin.pm index 894bba16a20e..6031e0ad5002 100644 --- a/src/PVE/Network/SDN/Zones/QinQPlugin.pm +++ b/src/PVE/Network/SDN/Zones/QinQPlugin.pm @@ -39,6 +39,12 @@ sub properties { }; } +sub get_mtu { + my ($class, $plugin_config) = @_; + + return $plugin_config->{mtu}; +} + sub options { return { nodes => { optional => 1 }, @@ -51,6 +57,7 @@ sub options { reversedns => { optional => 1 }, dnszone => { optional => 1 }, ipam => { optional => 1 }, + dhcp => { optional => 1 }, }; } diff --git a/src/PVE/Network/SDN/Zones/SimplePlugin.pm b/src/PVE/Network/SDN/Zones/SimplePlugin.pm index 347eee9a46ba..655f7e41cf37 100644 --- a/src/PVE/Network/SDN/Zones/SimplePlugin.pm +++ b/src/PVE/Network/SDN/Zones/SimplePlugin.pm @@ -31,7 +31,7 @@ sub properties { description => "dns domain zone ex: mydomain.com", }, dhcp => { - description => 'Type of the DHCP backend for this zone', + description => 'DHCP backend serving the zone. dnsmasq works on simple zones only.', type => 'string', enum => PVE::Network::SDN::Dhcp->plugin_types(), }, diff --git a/src/PVE/Network/SDN/Zones/VlanPlugin.pm b/src/PVE/Network/SDN/Zones/VlanPlugin.pm index de19755d04f5..46b00d747b80 100644 --- a/src/PVE/Network/SDN/Zones/VlanPlugin.pm +++ b/src/PVE/Network/SDN/Zones/VlanPlugin.pm @@ -38,6 +38,12 @@ sub properties { }; } +sub get_mtu { + my ($class, $plugin_config) = @_; + + return $plugin_config->{mtu}; +} + sub options { return { @@ -49,6 +55,7 @@ sub options { reversedns => { optional => 1 }, dnszone => { optional => 1 }, ipam => { optional => 1 }, + dhcp => { optional => 1 }, }; } diff --git a/src/PVE/Network/SDN/Zones/VxlanPlugin.pm b/src/PVE/Network/SDN/Zones/VxlanPlugin.pm index 4992757c1c5b..16bf0038e7e8 100644 --- a/src/PVE/Network/SDN/Zones/VxlanPlugin.pm +++ b/src/PVE/Network/SDN/Zones/VxlanPlugin.pm @@ -60,6 +60,18 @@ sub vxlan_mtu { return 1450; } +sub get_mtu { + my ($class, $plugin_config) = @_; + + my $iface; + if ($plugin_config->{peers}) { + my @peers = PVE::Tools::split_list($plugin_config->{peers}); + (undef, $iface) = + eval { PVE::Network::SDN::Zones::Plugin::find_local_ip_interface_peers(\@peers) }; + } + return vxlan_mtu($plugin_config, $iface, PVE::INotify::read_file('interfaces')); +} + sub options { return { nodes => { optional => 1 }, @@ -71,6 +83,7 @@ sub options { dnszone => { optional => 1 }, ipam => { optional => 1 }, fabric => { optional => 1 }, + dhcp => { optional => 1 }, }; } diff --git a/src/test/run_test_vnets_blackbox.pl b/src/test/run_test_vnets_blackbox.pl index 665013d4c2be..2aefb73f602e 100755 --- a/src/test/run_test_vnets_blackbox.pl +++ b/src/test/run_test_vnets_blackbox.pl @@ -1610,6 +1610,55 @@ sub test_mapping_edit_target { run_test(\&test_mapping_edit_target); +sub test_zone_dhcp_backends { + my $test_name = (split(/::/, (caller(0))[3]))[-1]; + my $zoneid = "TESTZONE"; + + # dnsmasq answers on the vnet bridge, so it only fits zones confined to + # one node. The ebpf backend answers on the guest's tap and fits any + my $zone = { + type => "vlan", + bridge => "vmbr0", + ipam => "pve", + zone => $zoneid, + }; + eval { PVE::API2::Network::SDN::Zones->create({ %$zone, dhcp => "dnsmasq" }); }; + like( + $@, + qr/dnsmasq backend only supports simple zones/, + "$test_name: dnsmasq is refused on a vlan zone", + ); + + create_zone({ %$zone, dhcp => "ebpf" }); + is(get_zone($zoneid)->{dhcp}, "ebpf", "$test_name: ebpf is accepted on a vlan zone"); + + eval { update_zone($zoneid, { dhcp => "dnsmasq" }); }; + like( + $@, + qr/dnsmasq backend only supports simple zones/, + "$test_name: switching a vlan zone to dnsmasq is refused", + ); + + # every other zone type offers the backend as well + $test_state->{controller_config} = { + ids => { ctrl => { type => 'evpn', asn => 65000, peers => '10.0.0.1' } }, + }; + my $params = { + qinq => { bridge => 'vmbr0', tag => 100 }, + vxlan => { peers => '10.0.0.1' }, + evpn => { controller => 'ctrl', 'vrf-vxlan' => 1000 }, + faucet => { 'dp-id' => 1, controller => 'ctrl' }, + }; + for my $type (sort keys %$params) { + my $id = uc($type) . "Z"; + my $zone = { type => $type, ipam => "pve", zone => $id, %{ $params->{$type} } }; + create_zone({ %$zone, dhcp => "ebpf" }); + is(get_zone($id)->{dhcp}, "ebpf", "$test_name: ebpf is accepted on a $type zone"); + } +} + +run_test(\&test_zone_dhcp_backends); + sub test_ipam_per_vnet { my $test_name = (split(/::/, (caller(0))[3]))[-1]; my $zoneid = "TESTZONE"; @@ -2259,6 +2308,8 @@ sub test_ebpf_backend { ); } +run_test(\&test_ebpf_backend); + sub test_ebpf_backend_edge_cases { my $test_name = (split(/::/, (caller(0))[3]))[-1]; my $zoneid = "TESTZONE"; @@ -2375,7 +2426,7 @@ sub test_ebpf_backend_edge_cases { ); } -run_test(\&test_ebpf_backend); +run_test(\&test_ebpf_backend_edge_cases); sub test_zone_tap_plug { my $test_name = (split(/::/, (caller(0))[3]))[-1]; @@ -2526,7 +2577,6 @@ sub test_zone_tap_plug { } run_test(\&test_zone_tap_plug); -run_test(\&test_ebpf_backend_edge_cases); sub test_ebpf_mac_case { my $test_name = (split(/::/, (caller(0))[3]))[-1]; @@ -2721,6 +2771,61 @@ sub test_ebpf_zone_mtu { run_test(\&test_ebpf_zone_mtu); +sub test_ebpf_vxlan_mtu { + my $test_name = (split(/::/, (caller(0))[3]))[-1]; + my $zoneid = "VXZONE"; + my $vnetid = "vxvnet"; + my $mac = "da:65:8f:18:9b:6f"; + + # a vxlan zone without an MTU of its own answers with the one its bridge + # takes from the underlay, the encapsulation overhead taken off + my $mocked_zone_plugin = Test::MockModule->new('PVE::Network::SDN::Zones::Plugin'); + $mocked_zone_plugin->mock( + find_local_ip_interface_peers => sub { return ('10.10.10.1', 'eno1'); }); + $mocked_pve_inotify->mock( + read_file => sub { return { ifaces => { eno1 => { mtu => 1400 } } }; }); + create_zone({ + type => "vxlan", + peers => "10.10.10.2", + dhcp => "ebpf", + ipam => "pve", + zone => $zoneid, + }); + create_vnet({ + type => "vnet", + zone => $zoneid, + vnet => $vnetid, + tag => 100, + }); + create_subnet({ + type => "subnet", + vnet => $vnetid, + subnet => "10.0.0.0/24", + gateway => "10.0.0.1", + }); + $test_state->{vmlist} = { ids => { 999 => { type => 'qemu', node => 'localnode' } } }; + $test_state->{guest_nets} = { 999 => { net0 => "virtio=$mac,bridge=$vnetid" } }; + $test_state->{macdb} = { vnets => { $vnetid => { $mac => { ip4 => '10.0.0.100' } } } }; + + take_ebpf_calls(); + PVE::Network::SDN::Dhcp::regenerate_config(); + is( + take_ebpf_calls()->[0]->{args}->[0]->[0]->{record}->{mtu}, + 1350, + "$test_name: the underlay's MTU minus the encapsulation is handed out", + ); + update_zone($zoneid, { mtu => 1500 }); + PVE::Network::SDN::Dhcp::regenerate_config(); + is( + take_ebpf_calls()->[0]->{args}->[0]->[0]->{record}->{mtu}, + 1500, + "$test_name: an explicit zone MTU wins", + ); + $mocked_pve_inotify->unmock('read_file'); +} + +run_test(\&test_ebpf_vxlan_mtu); + sub test_ebpf_overlapping_subnets { my $test_name = (split(/::/, (caller(0))[3]))[-1]; my $zoneid = "TESTZONE"; -- 2.47.3