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 9E1D81FF0DF for ; Thu, 05 Nov 2026 11:07:17 +0100 (CET) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id EE71A216C0; Thu, 05 Nov 2026 11:06:06 +0100 (CET) From: Hannes Laimer To: pve-devel@lists.proxmox.com Subject: [PATCH pve-network v6 08/17] sdn: dhcp: add an eBPF backend answering on each guest interface Date: Thu, 5 Nov 2026 11:03:07 +0100 Message-ID: <20261105100316.449163-9-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: 1793873076237 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.284 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: WCPEDJQHX7CMH5E44K2LJYBXL7IHYHKL X-Message-ID-Hash: WCPEDJQHX7CMH5E44K2LJYBXL7IHYHKL 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 DHCP backend answers DHCPv4 from a program in the kernel on every guest interface of its zones, with the address the SDN MAC cache holds for the interface's MAC. No server runs per zone, and IPv6 subnets are not served. A subnet without a gateway is served under a link-local server identifier no host owns, so its guests renew by broadcast at their rebinding time. An apply runs a full pass on each node, and on a node that no zone with the ebpf backend includes, the pass removes the responder altogether. A record change rewrites the answers for its MAC on the node the change is made on, and the other nodes catch up at their next apply. The responder is pinned in bpffs and does not survive a reboot, so a boot hook runs a full pass. Removing the package deletes those pins, since nothing would detach the responder afterward. Signed-off-by: Hannes Laimer --- Notes: v6: * hand the full pass whether each interface sits on the bridge its config names v5: * read a MAC's address through the vnet lookup of the IPAM and update only the interfaces on the changed vnet * hand the MAC with the interface, not in the record v4: * key the guest NIC list by lower-cased MAC, a NIC line without a MAC is left out debian/libpve-network-perl.prerm | 13 + src/PVE/API2/Network/SDN/Zones.pm | 3 +- src/PVE/Network/SDN/Dhcp.pm | 94 +++- src/PVE/Network/SDN/Dhcp/Ebpf.pm | 345 ++++++++++++++ src/PVE/Network/SDN/Dhcp/Makefile | 2 +- src/PVE/Network/SDN/Dhcp/Plugin.pm | 24 +- src/PVE/Network/SDN/SubnetPlugin.pm | 4 +- src/PVE/Network/SDN/Vnets.pm | 2 +- src/test/run_test_vnets_blackbox.pl | 668 +++++++++++++++++++++++++++- 9 files changed, 1136 insertions(+), 19 deletions(-) create mode 100755 debian/libpve-network-perl.prerm create mode 100644 src/PVE/Network/SDN/Dhcp/Ebpf.pm diff --git a/debian/libpve-network-perl.prerm b/debian/libpve-network-perl.prerm new file mode 100755 index 000000000000..d28c74fe284d --- /dev/null +++ b/debian/libpve-network-perl.prerm @@ -0,0 +1,13 @@ +#!/bin/sh + +set -e + +case "$1" in + remove) + # the eBPF DHCP responder stays attached through its pins in bpffs, and with + # the package gone nothing would ever update or detach it + rm -rf /sys/fs/bpf/proxmox-ebpf/dhcp + ;; +esac + +exit 0 diff --git a/src/PVE/API2/Network/SDN/Zones.pm b/src/PVE/API2/Network/SDN/Zones.pm index 74b10f032bf4..4f61e1e858aa 100644 --- a/src/PVE/API2/Network/SDN/Zones.pm +++ b/src/PVE/API2/Network/SDN/Zones.pm @@ -14,6 +14,7 @@ use PVE::Network::SDN::Dns; use PVE::Network::SDN::Subnets; use PVE::Network::SDN::Vnets; use PVE::Network::SDN; +use PVE::Network::SDN::Dhcp; use PVE::Network::SDN::Zones::EvpnPlugin; use PVE::Network::SDN::Zones::FaucetPlugin; @@ -57,7 +58,7 @@ my $ZONE_PROPERTIES = { }, dhcp => { type => 'string', - enum => ['dnsmasq'], + enum => PVE::Network::SDN::Dhcp->plugin_types(), optional => 1, description => 'Name of DHCP server backend for this zone.', }, diff --git a/src/PVE/Network/SDN/Dhcp.pm b/src/PVE/Network/SDN/Dhcp.pm index d00f0b960de8..47fb706178a9 100644 --- a/src/PVE/Network/SDN/Dhcp.pm +++ b/src/PVE/Network/SDN/Dhcp.pm @@ -9,6 +9,7 @@ use PVE::Network::SDN; use PVE::Network::SDN::Subnets; use PVE::Network::SDN::Dhcp::Plugin; use PVE::Network::SDN::Dhcp::Dnsmasq; +use PVE::Network::SDN::Dhcp::Ebpf; use PVE::INotify; @@ -17,12 +18,15 @@ PVE::Network::SDN::Dhcp::Plugin->init(); PVE::Network::SDN::Dhcp::Dnsmasq->register(); PVE::Network::SDN::Dhcp::Dnsmasq->init(); +PVE::Network::SDN::Dhcp::Ebpf->register(); +PVE::Network::SDN::Dhcp::Ebpf->init(); + sub plugin_types { return PVE::Network::SDN::Dhcp::Plugin->lookup_types(); } sub update_mapping { - my ($vnetid, $mac, $resume) = @_; + my ($vnetid, $mac, $resume, $vmid) = @_; # a record without a MAC has no guest NIC behind it, so no backend serves it return if !$mac; @@ -40,31 +44,99 @@ sub update_mapping { return if defined($zone->{nodes}) && !$zone->{nodes}->{ PVE::INotify::nodename() }; PVE::Network::SDN::Dhcp::Plugin->lookup($zone->{dhcp}) - ->update_ip_mapping($zoneid, $vnetid, $mac, $resume); + ->update_ip_mapping($zoneid, $vnetid, $mac, $resume, $vmid); } -sub guest_node_by_mac { - my ($vnetid, $mac) = @_; - - return if !$mac; +# the NIC lines come from pmxcfs in one go, so no guest config gets parsed for +# this. With a vmid only that guest's lines are fetched. The map is keyed by +# the lower-cased MAC and holds a list per key, since a MAC is unique within a +# guest but not across the cluster +sub guest_nics { + my ($vmid) = @_; + # a hook script starts with the cluster cache empty + PVE::Cluster::cfs_update(); my $vmlist = PVE::Cluster::get_vmlist(); - my $nets = PVE::Cluster::get_guest_config_properties([map { "net$_" } 0 .. 31]); + my $nets = PVE::Cluster::get_guest_config_properties([map { "net$_" } 0 .. 31], $vmid); + my $nics = {}; for my $vmid (sort keys %$nets) { my $guest = $vmlist->{ids}->{$vmid} // next; - for my $net (values %{ $nets->{$vmid} }) { + my $prefix = ($guest->{type} // '') eq 'lxc' ? 'veth' : 'tap'; + for my $key (sort keys %{ $nets->{$vmid} }) { + my ($index) = $key =~ m/^net(\d+)$/ or next; + my $net = $nets->{$vmid}->{$key}; my ($bridge) = $net =~ m/(?:^|,)bridge=([^,]+)/ or next; - next if $bridge ne $vnetid; # a VM names the MAC as the value of its model, a container as hwaddr - my ($nic_mac) = $net =~ m/(?:^|,)[a-z0-9_-]+=([0-9a-f]{2}(?::[0-9a-f]{2}){5})(?:,|$)/i + my ($mac) = $net =~ m/(?:^|,)[a-z0-9_-]+=([0-9a-f]{2}(?::[0-9a-f]{2}){5})(?:,|$)/i or next; - return ($guest->{node}, $vmid) if lc($nic_mac) eq lc($mac); + push $nics->{ lc($mac) }->@*, + { + vmid => $vmid, + node => $guest->{node}, + iface => "$prefix${vmid}i$index", + bridge => $bridge, + mac => lc($mac), + }; } } + return $nics; +} + +sub guest_node_by_mac { + my ($vnetid, $mac) = @_; + + return if !$mac; + + for my $nic ((guest_nics()->{ lc($mac) } // [])->@*) { + return ($nic->{node}, $nic->{vmid}) if $nic->{bridge} eq $vnetid; + } + return; } +sub tap_plug { + my ($bridge, $iface, $mac) = @_; + + my $vnet = PVE::Network::SDN::Vnets::get_vnet($bridge, 1); + my $zone = $vnet ? PVE::Network::SDN::Zones::get_zone($vnet->{zone}, 1) : undef; + my $backend = $zone ? $zone->{dhcp} : undef; + + # every other backend hears where the interface sits now. A plain bridge + # is no vnet + my $target = { vnet => $vnet ? $bridge : undef, mac => $mac }; + for my $type (PVE::Network::SDN::Dhcp::Plugin->lookup_types()->@*) { + next if defined($backend) && $type eq $backend; + eval { PVE::Network::SDN::Dhcp::Plugin->lookup($type)->tap_unplug($iface, $target) }; + warn "could not unplug $iface from the $type dhcp backend: $@" if $@; + } + return if !$backend; + + eval { + PVE::Network::SDN::Dhcp::Plugin->lookup($backend) + ->tap_plug($vnet->{zone}, $bridge, $iface, $mac); + }; + warn "could not plug $iface into the dhcp backend of zone $vnet->{zone}: $@" if $@; +} + +sub boot { + for my $type (PVE::Network::SDN::Dhcp::Plugin->lookup_types()->@*) { + eval { PVE::Network::SDN::Dhcp::Plugin->lookup($type)->boot() }; + warn "could not bring up the $type dhcp backend: $@" if $@; + } +} + +# the interface is usually gone already and with it any way to its zone, so +# every backend is told +sub tap_unplug { + my ($iface) = @_; + + for my $type (PVE::Network::SDN::Dhcp::Plugin->lookup_types()->@*) { + eval { PVE::Network::SDN::Dhcp::Plugin->lookup($type)->tap_unplug($iface) }; + warn "could not unplug $iface from the $type dhcp backend: $@" if $@; + } +} + sub regenerate_config { my ($reload) = @_; diff --git a/src/PVE/Network/SDN/Dhcp/Ebpf.pm b/src/PVE/Network/SDN/Dhcp/Ebpf.pm new file mode 100644 index 000000000000..91fabee3e27f --- /dev/null +++ b/src/PVE/Network/SDN/Dhcp/Ebpf.pm @@ -0,0 +1,345 @@ +package PVE::Network::SDN::Dhcp::Ebpf; + +use strict; +use warnings; + +use base qw(PVE::Network::SDN::Dhcp::Plugin); + +use Net::IP; +use Net::Subnet qw(subnet_matcher); + +use PVE::Cluster; +use PVE::INotify; +use PVE::RESTEnvironment qw(log_warn); + +use PVE::RS::SDN::Dhcp; +use PVE::RS::SDN::Dhcp::ExclusiveLock; +use PVE::RS::SDN::Dhcp::SharedLock; + +my $DEFAULT_LEASE_TIME = 600; + +# a subnet without a gateway is served under a link-local server identifier, +# which no host on the subnet owns. A guest cannot renew by unicast then and +# broadcasts at its rebinding time instead +my $SERVER_ID_NO_GATEWAY = '169.254.0.1'; + +sub type { + return 'ebpf'; +} + +# the MTU option is 16 bits wide with 68 the smallest MTU IPv4 allows, so a +# zone MTU outside that is reported on an apply and not handed out. A zone +# without one hands out none and the guest keeps its own setting +my sub zone_mtu { + my ($zoneid, $zone, $report) = @_; + + my $mtu = PVE::Network::SDN::Zones::Plugin->lookup($zone->{type})->get_mtu($zone); + return undef if !defined($mtu); + return $mtu if $mtu >= 68 && $mtu <= 65535; + log_warn("zone $zoneid has MTU $mtu, not handing it out over DHCP") if $report; + return undef; +} + +my sub dhcp_record { + my ($ip4, $subnet, $mtu) = @_; + + my $gateway = $subnet->{gateway}; + + # a DNS server has to be reachable over the address family served + my $dns = $subnet->{'dhcp-dns-server'}; + $dns = undef if defined($dns) && !Net::IP::ip_is_ipv4($dns); + + # the config hands its numbers over as strings, while the bindings take integers only + return { + ip => $ip4, + prefixlen => int($subnet->{mask}), + server_id => $gateway // $SERVER_ID_NO_GATEWAY, + lease => int($subnet->{'dhcp-lease-time'} // $DEFAULT_LEASE_TIME), + router => $gateway, + dns => $dns, + mtu => defined($mtu) ? int($mtu) : undef, + }; +} + +# the IPv4 subnets of a vnet, warning about a DNS server the reply cannot +# carry when asked to. Only the apply asks, so a guest start does not repeat +# the warning +my sub served_subnets { + my ($vnetid, $report) = @_; + + my $subnets = PVE::Network::SDN::Vnets::get_subnets($vnetid, 1) // {}; + my $served = []; + for my $subnetid (sort keys %$subnets) { + my $subnet = $subnets->{$subnetid}; + next if !Net::IP::ip_is_ipv4($subnet->{network}); + if ( + $report + && defined($subnet->{'dhcp-dns-server'}) + && !Net::IP::ip_is_ipv4($subnet->{'dhcp-dns-server'}) + ) { + log_warn("subnet $subnetid has an IPv6 DNS server, not handing it out over IPv4"); + } + push @$served, $subnet; + } + + return $served; +} + +my $matchers = {}; + +# the vnet's subnet holding the address decides the answer +my sub vnet_record { + my ($subnets, $mtu, $ip4) = @_; + + return undef if !$ip4; + # the most specific of overlapping subnets is the one to answer from + for my $subnet (sort { $b->{mask} <=> $a->{mask} } @$subnets) { + my $matcher = $matchers->{ $subnet->{cidr} } //= subnet_matcher($subnet->{cidr}); + next if !$matcher->($ip4); + # the vnet's own gateway address is cached too and never a lease + return undef if defined($subnet->{gateway}) && $ip4 eq $subnet->{gateway}; + return dhcp_record($ip4, $subnet, $mtu); + } + + return undef; +} + +# the bridge an interface is attached to. With the firewall on, the interface +# sits on a bridge of its own, and a veth peer of that bridge sits on the vnet +my sub attached_bridge { + my ($iface) = @_; + + my $master = readlink("/sys/class/net/$iface/master") // return undef; + if ($master =~ m!/fwbr(\d+)i(\d+)$!) { + $master = readlink("/sys/class/net/fwpr$1p$2/master") // return undef; + } + return $master =~ s!^.*/!!r; +} + +my sub full_state { + my ($report) = @_; + + # the configs and the cache are read after the cluster state is refreshed, + # since a copy from before the lock was taken would hand the pass older input + PVE::Cluster::cfs_update(); + my $cfg = PVE::Network::SDN::running_config(); + my $zones = $cfg->{zones}->{ids} // {}; + my $vnets = $cfg->{vnets}->{ids} // {}; + # an ebpf zone confined to other nodes runs no responder here + my $nodename = PVE::INotify::nodename(); + my %ebpf = map { $_ => 1 } grep { + ($zones->{$_}->{dhcp} // '') eq 'ebpf' + && (!defined($zones->{$_}->{nodes}) || $zones->{$_}->{nodes}->{$nodename}) + } keys %$zones; + return (0, []) if !%ebpf; + + my (%mtu, %subnets); + # a subnet or a zone MTU the responder cannot serve is reported per apply, + # guests or not + if ($report) { + for my $vnetid (sort keys %$vnets) { + my $zoneid = $vnets->{$vnetid}->{zone}; + next if !$zoneid || !$ebpf{$zoneid}; + $subnets{$vnetid} //= served_subnets($vnetid, 1); + $mtu{$zoneid} = zone_mtu($zoneid, $zones->{$zoneid}, 1) if !exists $mtu{$zoneid}; + } + } + my $ifaces = []; + # every plug waits for the pass, so it reads the cache alone and only once + my $cache = PVE::Network::SDN::Ipams::read_cache(); + my %cache_subnets; + my @nics = map { $_->@* } values PVE::Network::SDN::Dhcp::guest_nics()->%*; + for my $nic (sort { $a->{vmid} cmp $b->{vmid} || $a->{iface} cmp $b->{iface} } @nics) { + my $vnetid = $nic->{bridge}; + my $zoneid = $vnets->{$vnetid} ? $vnets->{$vnetid}->{zone} : undef; + my $serve = $zoneid && $ebpf{$zoneid} ? 1 : 0; + my $record; + if ($serve) { + $mtu{$zoneid} = zone_mtu($zoneid, $zones->{$zoneid}, $report) if !exists $mtu{$zoneid}; + $subnets{$vnetid} //= served_subnets($vnetid, $report); + if ($nic->{mac} && $zones->{$zoneid}->{ipam}) { + $cache_subnets{$vnetid} //= PVE::Network::SDN::Vnets::get_subnets($vnetid, 1) // {}; + my ($ip4) = PVE::Network::SDN::Ipams::cached_ips_from_mac( + $cache, $vnetid, $nic->{mac}, $cache_subnets{$vnetid}, + ); + $record = vnet_record($subnets{$vnetid}, $mtu{$zoneid}, $ip4); + } + } + push @$ifaces, + { + name => $nic->{iface}, + vnet => $vnets->{$vnetid} ? $vnetid : undef, + mac => $nic->{mac}, + serve => $serve, + record => $record, + on_config_bridge => (attached_bridge($nic->{iface}) // '') eq $vnetid ? 1 : 0, + }; + } + + return (scalar(keys %ebpf), $ifaces); +} + +my sub full_pass { + my ($report) = @_; + + my $lock = eval { PVE::RS::SDN::Dhcp::ExclusiveLock->new() }; + if (!$lock) { + log_warn("could not lock the DHCP responder: $@"); + return; + } + + my ($zones, $ifaces) = eval { full_state($report) }; + if ($@) { + log_warn("could not collect the DHCP responder state: $@"); + return; + } + + eval { $zones ? $lock->apply($ifaces) : $lock->clear() }; + if ($@) { + log_warn("could not apply the DHCP responder state: $@"); + return; + } + return 1; +} + +my sub mac_ifaces { + my ($vnetid, $mac, $vmid) = @_; + + my $cfg = PVE::Network::SDN::running_config(); + my $zones = $cfg->{zones}->{ids} // {}; + my $vnets = $cfg->{vnets}->{ids} // {}; + my $ifaces = []; + for my $nic ((PVE::Network::SDN::Dhcp::guest_nics($vmid)->{ lc($mac) } // [])->@*) { + next if $nic->{bridge} ne $vnetid; + my $vnet = $vnets->{ $nic->{bridge} } // next; + my $zone = $zones->{ $vnet->{zone} } // next; + next if ($zone->{dhcp} // '') ne 'ebpf'; + my ($ip4) = PVE::Network::SDN::Vnets::get_ips_from_mac($vnetid, $mac, 1); + my $record = + vnet_record(served_subnets($nic->{bridge}, 0), zone_mtu($vnet->{zone}, $zone, 0), $ip4); + push @$ifaces, + { + name => $nic->{iface}, + vnet => $nic->{bridge}, + mac => $nic->{mac}, + serve => 1, + record => $record, + }; + } + + return $ifaces; +} + +# under the guest lock nothing else writes the guest's entries, so a caller +# that names the guest takes the shared lock. An API edit names no guest and +# takes the exclusive one +sub update_ip_mapping { + my ($class, $dhcpid, $vnetid, $mac, $resume, $vmid) = @_; + + # a resume finds the entries current. The plug of an incoming migration + # wrote them, and an edit waits for the migration to end + return if $resume; + + my $update = sub { + my $lock = + defined($vmid) + ? PVE::RS::SDN::Dhcp::SharedLock->new() + : PVE::RS::SDN::Dhcp::ExclusiveLock->new(); + PVE::Cluster::cfs_update(); + my $ifaces = mac_ifaces($vnetid, $mac, $vmid); + return 1 if !@$ifaces; + return $lock->update($ifaces); + }; + + my $done = eval { $update->() }; + die "could not update the DHCP responder records of $mac: $@" if $@; + return if $done; + + # nothing loaded, the pass takes its own lock once the update's is gone + full_pass(0) or die "could not load the DHCP responder\n"; +} + +# the dispatcher's per-zone walk is not needed, since the full pass collects +# the state itself +sub before_regenerate { } +sub before_configure { } +sub configure_subnet { } +sub configure_range { } +sub configure_vnet { } +sub after_configure { } + +sub after_regenerate { + my ($class) = @_; + + full_pass(1); +} + +sub boot { + my ($class) = @_; + + full_pass(1); +} + +# a plug onto a vnet or a plain bridge. The caller picked the backend from a +# config read before the lock, so the zone is read again under it. An apply +# that switched the backend meanwhile is then either seen here or runs after +my sub plug { + my ($iface, $vnetid, $mac) = @_; + + my $lock = PVE::RS::SDN::Dhcp::SharedLock->new(); + if (!defined($vnetid)) { + $lock->detach($iface, undef); + return 1; + } + PVE::Cluster::cfs_update(); + my $vnet = PVE::Network::SDN::Vnets::get_vnet($vnetid, 1); + my $zoneid = $vnet ? $vnet->{zone} : undef; + my $zone = $zoneid ? PVE::Network::SDN::Zones::get_zone($zoneid, 1) : undef; + if (!$zone || ($zone->{dhcp} // '') ne 'ebpf') { + $lock->detach($iface, $vnet ? $vnetid : undef); + return 1; + } + my $record; + if ($mac) { + my ($ip4) = PVE::Network::SDN::Vnets::get_ips_from_mac($vnetid, $mac, 1); + $record = vnet_record(served_subnets($vnetid, 0), zone_mtu($zoneid, $zone, 0), $ip4); + } + return $lock->attach($iface, $vnetid, $mac || undef, $record); +} + +my sub plug_or_warn { + my ($iface, $vnetid, $mac) = @_; + + my $done = eval { plug($iface, $vnetid, $mac) }; + if ($@) { + log_warn("could not update the DHCP responder on $iface: $@"); + return; + } + return if $done; + + # nothing is loaded, after a rebuild or a boot where the SDN commit loaded + # nothing. The plug's lock is gone by here, so the pass can take its own + full_pass(0); + $done = eval { plug($iface, $vnetid, $mac) }; + log_warn("could not update the DHCP responder on $iface: " . ($@ || "nothing is loaded")) + if $@ || !$done; +} + +sub tap_unplug { + my ($class, $iface, $target) = @_; + + if ($target) { + plug_or_warn($iface, $target->{vnet}, $target->{mac}); + return; + } + eval { PVE::RS::SDN::Dhcp::detach($iface) }; + log_warn("could not detach the DHCP responder from $iface: $@") if $@; +} + +sub tap_plug { + my ($class, $dhcpid, $vnetid, $iface, $mac) = @_; + + plug_or_warn($iface, $vnetid, $mac); +} + +1; diff --git a/src/PVE/Network/SDN/Dhcp/Makefile b/src/PVE/Network/SDN/Dhcp/Makefile index 65465135a51c..ce86aaef97a2 100644 --- a/src/PVE/Network/SDN/Dhcp/Makefile +++ b/src/PVE/Network/SDN/Dhcp/Makefile @@ -1,4 +1,4 @@ -SOURCES=Plugin.pm Dnsmasq.pm +SOURCES=Plugin.pm Dnsmasq.pm Ebpf.pm PERL5DIR=${DESTDIR}/usr/share/perl5 diff --git a/src/PVE/Network/SDN/Dhcp/Plugin.pm b/src/PVE/Network/SDN/Dhcp/Plugin.pm index a3e5102b601a..40511af6c875 100644 --- a/src/PVE/Network/SDN/Dhcp/Plugin.pm +++ b/src/PVE/Network/SDN/Dhcp/Plugin.pm @@ -25,9 +25,10 @@ sub private { # the MAC's records on the vnet changed, so make the backend serve the current # ones. With no record left its reservation on the vnet goes. Dies if the # backend could not be updated. For a resume the push does not wait long for -# a busy backend +# a busy backend. The vmid names the guest when the caller knows it, which an +# API edit does not sub update_ip_mapping { - my ($class, $dhcpid, $vnetid, $mac, $resume) = @_; + my ($class, $dhcpid, $vnetid, $mac, $resume, $vmid) = @_; die 'implement in sub class'; } @@ -61,9 +62,28 @@ sub before_regenerate { die 'implement in sub class'; } +# the node booted. A backend whose state does not survive a reboot brings it +# back here, before the guests start +sub boot { + my ($class) = @_; +} + sub after_regenerate { my ($class) = @_; die 'implement in sub class'; } +# a guest interface was plugged into a vnet of a zone using this backend. +# Backends serving the bridge rather than the interface have nothing to do +sub tap_plug { + my ($class, $dhcpid, $vnetid, $iface, $mac) = @_; +} + +# a guest interface left its vnet or went away, and by then nothing says which +# zone it was on. The target is the vnet it sits on now, none for a plain +# bridge, with the NIC's MAC, and no target means it is gone +sub tap_unplug { + my ($class, $iface, $target) = @_; +} + 1; diff --git a/src/PVE/Network/SDN/SubnetPlugin.pm b/src/PVE/Network/SDN/SubnetPlugin.pm index 29edf7b65330..38579f213206 100644 --- a/src/PVE/Network/SDN/SubnetPlugin.pm +++ b/src/PVE/Network/SDN/SubnetPlugin.pm @@ -183,8 +183,8 @@ sub properties { maximum => 4294967295, description => 'Lease time in seconds for DHCP answers. Without it dnsmasq hands out' - . ' infinite leases, and it raises anything below two minutes to two' - . ' minutes.', + . ' infinite leases and the ebpf responder ten minutes. dnsmasq raises' + . ' anything below two minutes to two minutes.', optional => 1, }, }; diff --git a/src/PVE/Network/SDN/Vnets.pm b/src/PVE/Network/SDN/Vnets.pm index 58db0f803f8b..3543345a6f01 100644 --- a/src/PVE/Network/SDN/Vnets.pm +++ b/src/PVE/Network/SDN/Vnets.pm @@ -242,7 +242,7 @@ sub add_dhcp_mapping { ($ip4, $ip6) = PVE::Network::SDN::Vnets::get_ips_from_mac($vnetid, $mac); return if !$ip4 && !$ip6; # a guest start does not fail on its DHCP backend - eval { PVE::Network::SDN::Dhcp::update_mapping($vnetid, $mac) }; + eval { PVE::Network::SDN::Dhcp::update_mapping($vnetid, $mac, undef, $vmid) }; warn $@ if $@; } diff --git a/src/test/run_test_vnets_blackbox.pl b/src/test/run_test_vnets_blackbox.pl index 0d8e760561d2..641d5900b135 100755 --- a/src/test/run_test_vnets_blackbox.pl +++ b/src/test/run_test_vnets_blackbox.pl @@ -51,6 +51,9 @@ sub clear_test_state { macdb => {}, ipamdb => {}, dnsmasq_calls => [], + ebpf_calls => [], + order => [], + warnings => [], ipam_config => { 'ids' => { 'pve' => { @@ -59,6 +62,7 @@ sub clear_test_state { }, }, fabrics => {}, + ebpf_answer => undef, }; PVE::Tools::file_set_contents($TMP_ETHERS_FILE, "\n"); } @@ -252,6 +256,43 @@ $mocked_sdn_fabrics->mock( cfs_lock_file => $mocked_cfs_lock_file, ); +my $mocked_pve_rs_dhcp = Test::MockModule->new('PVE::RS::SDN::Dhcp'); +$mocked_pve_rs_dhcp->mock( + detach => sub { + push $test_state->{ebpf_calls}->@*, { method => 'detach', args => [@_] }; + return $test_state->{ebpf_answer}->{detach} // 1; + }, +); + +# taking a lock is the ordered step, and the responder calls are its methods +my %mocked_pve_rs_dhcp_locks; +for my $class (qw(SharedLock ExclusiveLock)) { + my $package = "PVE::RS::SDN::Dhcp::$class"; + my $mock = Test::MockModule->new($package, no_auto => 1); + $mock->mock( + new => sub { + push $test_state->{order}->@*, 'lock'; + push $test_state->{lock_modes}->@*, $class eq 'SharedLock' ? 'shared' : 'exclusive'; + return bless {}, $package; + }, + map { + my $method = $_; + $method => sub { + my ($self, @args) = @_; + push $test_state->{order}->@*, 'write'; + push $test_state->{ebpf_calls}->@*, { method => $method, args => [@args] }; + return $test_state->{ebpf_answer}->{$method} // 1; + }; + } ($class eq 'SharedLock' ? qw(attach detach update) : qw(apply update clear)), + ); + $mocked_pve_rs_dhcp_locks{$class} = $mock; +} + +my $mocked_sdn_dhcp_ebpf = Test::MockModule->new('PVE::Network::SDN::Dhcp::Ebpf'); +$mocked_sdn_dhcp_ebpf->mock( + log_warn => sub { push $test_state->{warnings}->@*, $_[0]; }, +); + my $mocked_api_zones = Test::MockModule->new('PVE::API2::Network::SDN::Zones'); $mocked_api_zones->mock( create_etc_interfaces_sdn_dir => sub { }, @@ -287,11 +328,18 @@ $mocked_pve_cluster_obj->mock( # a vmlist change a test parks here lands with the next refresh, as a # change made meanwhile on the cluster does cfs_update => sub { + push $test_state->{order}->@*, 'refresh'; $test_state->{vmlist} = delete $test_state->{vmlist_pending} if $test_state->{vmlist_pending}; }, get_vmlist => sub { return $test_state->{vmlist} // { ids => {} }; }, - get_guest_config_properties => sub { return $test_state->{guest_nets} // {}; }, + get_guest_config_properties => sub { + my ($properties, $vmid) = @_; + my $nets = $test_state->{guest_nets} // {}; + push $test_state->{nic_listings}->@*, $vmid; + return $nets if !defined($vmid); + return exists $nets->{$vmid} ? { $vmid => $nets->{$vmid} } : {}; + }, ); # ------- TEST FUNCTIONS -------------- @@ -427,6 +475,24 @@ sub delete_ip { return PVE::API2::Network::SDN::Ips->ipdelete($param); } +sub take_ebpf_calls { + my $calls = $test_state->{ebpf_calls}; + $test_state->{ebpf_calls} = []; + return $calls; +} + +# the lock takes, cluster refreshes and responder writes in the order they +# ran, a repeated step collapsed into one +sub take_order { + my $order = $test_state->{order}; + $test_state->{order} = []; + my @shape; + for my $step (@$order) { + push @shape, $step if !@shape || $shape[-1] ne $step; + } + return \@shape; +} + sub run_test { my $test = shift; clear_test_state(); @@ -1951,4 +2017,604 @@ sub test_dnsmasq_reservation_per_vnet { run_test(\&test_dnsmasq_reservation_per_vnet); +# -------------- ebpf dhcp backend +my $ebpf_record = sub { + my ($ip, %over) = @_; + return { + ip => $ip, + prefixlen => 24, + server_id => '10.0.0.1', + lease => 600, + router => '10.0.0.1', + dns => undef, + mtu => undef, + %over, + }; +}; + +sub test_ebpf_backend { + my $test_name = (split(/::/, (caller(0))[3]))[-1]; + my $zoneid = "TESTZONE"; + my $vnetid = "testvnet"; + my $mac = "da:65:8f:18:9b:6f"; + + create_zone({ + type => "simple", + dhcp => "ebpf", + ipam => "pve", + zone => $zoneid, + }); + create_vnet({ + type => "vnet", + zone => $zoneid, + vnet => $vnetid, + }); + create_subnet({ + type => "subnet", + vnet => $vnetid, + subnet => "10.0.0.0/24", + gateway => "10.0.0.1", + 'dhcp-range' => ["start-address=10.0.0.100,end-address=10.0.0.200"], + }); + + # the guest behind the MAC runs here, so its interface gets the record + $test_state->{vmlist} = { ids => { 999 => { type => 'qemu', node => 'localnode' } } }; + $test_state->{guest_nets} = { 999 => { net0 => "virtio=$mac,bridge=$vnetid" } }; + take_ebpf_calls(); + $test_state->{nic_listings} = []; + $test_state->{lock_modes} = []; + eval { nic_start($vnetid, $mac, "999", "testhostname"); }; + if ($@) { + fail("$test_name: nic_start: $@"); + return; + } + is( + $test_state->{nic_listings}->[0], + 999, + "$test_name: the start reads its own guest's config only", + ); + eq_or_diff( + $test_state->{lock_modes}, + ['shared'], + "$test_name: and writes its record under the shared lock", + ); + eq_or_diff( + take_ebpf_calls(), + [{ + method => 'update', + args => [[{ + name => 'tap999i0', + vnet => $vnetid, + mac => $mac, + serve => 1, + record => $ebpf_record->("10.0.0.100"), + }]], + }], + "$test_name: a guest start writes the record of its interface", + ); + + # a mapping edit rewrites it, and a delete leaves the interface without one + $test_state->{lock_modes} = []; + update_ip({ + zone => $zoneid, + vnet => $vnetid, + mac => $mac, + ip => "10.0.0.150", + }); + eq_or_diff( + $test_state->{lock_modes}, + ['exclusive'], + "$test_name: an edit names no guest and writes under the exclusive lock", + ); + eq_or_diff( + take_ebpf_calls(), + [{ + method => 'update', + args => [[{ + name => 'tap999i0', + vnet => $vnetid, + mac => $mac, + serve => 1, + record => $ebpf_record->("10.0.0.150"), + }]], + }], + "$test_name: a mapping edit rewrites the record", + ); + delete_ip({ + zone => $zoneid, + vnet => $vnetid, + mac => $mac, + ip => "10.0.0.150", + }); + eq_or_diff( + take_ebpf_calls(), + [{ + method => 'update', + args => [[ + { + name => 'tap999i0', + vnet => $vnetid, + mac => $mac, + serve => 1, + record => undef, + }, + ]], + }], + "$test_name: a mapping delete drops the record", + ); + + # the pass and the plug below answer with this mapping + create_ip({ + zone => $zoneid, + vnet => $vnetid, + mac => $mac, + ip => "10.0.0.150", + }); + take_ebpf_calls(); + + # a resume finds the entries current and takes no lock the guest would + # wait on + $test_state->{order} = []; + PVE::Network::SDN::Dhcp::update_mapping($vnetid, $mac, 1); + eq_or_diff(take_ebpf_calls(), [], "$test_name: a resume leaves the responder alone"); + eq_or_diff($test_state->{order}, [], "$test_name: without taking its lock"); + + # the full pass names every guest NIC in the cluster, whether the + # responder runs on it and what it answers. The responder keeps those + # present here. A guest the vmlist does not know is skipped, and a NIC + # without a MAC is served nothing + $test_state->{vmlist} = { + ids => { + 100 => { type => 'qemu', node => 'other' }, + 101 => { type => 'lxc', node => 'other' }, + 103 => { type => 'qemu', node => 'other' }, + }, + }; + $test_state->{guest_nets} = { + 100 => { + net0 => "virtio=$mac,bridge=$vnetid,firewall=1", + net1 => "virtio=00:11:22:33:44:55,bridge=vmbr0", + }, + 101 => { net2 => "name=eth0,bridge=$vnetid,hwaddr=00:11:22:33:44:66" }, + 102 => { net0 => "virtio=00:11:22:33:44:77,bridge=$vnetid" }, + # a line without a MAC has nothing to answer and is left out + 103 => { net0 => "virtio,bridge=$vnetid" }, + }; + PVE::Network::SDN::Dhcp::regenerate_config(); + eq_or_diff( + take_ebpf_calls(), + [{ + method => 'apply', + args => [[ + { + name => 'tap100i0', + vnet => $vnetid, + mac => $mac, + serve => 1, + record => $ebpf_record->("10.0.0.150"), + on_config_bridge => 0, + }, + { + name => 'tap100i1', + vnet => undef, + mac => '00:11:22:33:44:55', + serve => 0, + record => undef, + on_config_bridge => 0, + }, + { + name => 'veth101i2', + vnet => $vnetid, + mac => '00:11:22:33:44:66', + serve => 1, + record => undef, + on_config_bridge => 0, + }, + ]], + }], + "$test_name: the full pass names every guest NIC with its answer", + ); + + # a responder with nothing loaded cannot take a single record, so the + # full pass loads it and fills everything + $test_state->{vmlist} = { ids => { 999 => { type => 'qemu', node => 'localnode' } } }; + $test_state->{guest_nets} = { 999 => { net0 => "virtio=$mac,bridge=$vnetid" } }; + $test_state->{ebpf_answer} = { update => 0 }; + $test_state->{lock_modes} = []; + nic_start($vnetid, $mac, "999", "testhostname"); + $test_state->{ebpf_answer} = undef; + eq_or_diff( + [map { $_->{method} } take_ebpf_calls()->@*], + ['update', 'apply'], + "$test_name: an unloaded responder gets the full pass", + ); + eq_or_diff( + $test_state->{lock_modes}, + ['shared', 'exclusive'], + "$test_name: which takes its own lock after the record change let go", + ); + + # a plug attaches the interface with the answer its MAC gets on the + # vnet. With nothing loaded the full pass runs first and the attach is + # retried + PVE::Network::SDN::Dhcp::Ebpf->tap_plug($zoneid, $vnetid, 'tap999i0', $mac); + eq_or_diff( + take_ebpf_calls(), + [{ method => 'attach', args => ['tap999i0', $vnetid, $mac, $ebpf_record->("10.0.0.150")] }], + "$test_name: a plug attaches its interface with the record", + ); + $test_state->{ebpf_answer} = { attach => 0 }; + PVE::Network::SDN::Dhcp::Ebpf->tap_plug($zoneid, $vnetid, 'tap999i0', $mac); + $test_state->{ebpf_answer} = undef; + eq_or_diff( + [map { $_->{method} } take_ebpf_calls()->@*], + ['attach', 'apply', 'attach'], + "$test_name: a plug with nothing loaded runs the full pass first", + ); + PVE::Network::SDN::Dhcp::Ebpf->tap_plug($zoneid, $vnetid, 'tap999i0'); + eq_or_diff( + take_ebpf_calls(), + [{ method => 'attach', args => ['tap999i0', $vnetid, undef, undef] }], + "$test_name: a plug without a MAC attaches with nothing to answer", + ); +} + +sub test_ebpf_backend_edge_cases { + my $test_name = (split(/::/, (caller(0))[3]))[-1]; + my $zoneid = "TESTZONE"; + my $vnetid = "testvnet"; + my $mac = "da:65:8f:18:9b:6f"; + + # a regenerate without any ebpf zone tears the responder state down + create_zone({ + type => "simple", + ipam => "pve", + zone => $zoneid, + }); + take_ebpf_calls(); + PVE::Network::SDN::Dhcp::regenerate_config(); + eq_or_diff( + take_ebpf_calls(), + [{ method => 'clear', args => [] }], + "$test_name: regenerate without an ebpf zone clears the responder", + ); + + # an ebpf zone confined to other nodes runs no responder here either + update_zone($zoneid, { dhcp => "ebpf", nodes => 'other' }); + PVE::Network::SDN::Dhcp::regenerate_config(); + eq_or_diff( + take_ebpf_calls(), + [{ method => 'clear', args => [] }], + "$test_name: an ebpf zone confined to other nodes clears the responder here", + ); + update_zone($zoneid, { delete => 'nodes' }); + + # the SDN commit brings the responder back at boot, one full pass + PVE::Network::SDN::Dhcp::boot(); + eq_or_diff( + [map { $_->{method} } take_ebpf_calls()->@*], + ['apply'], + "$test_name: the boot runs the full pass", + ); + + # the zone switches to ebpf, but its subnets lack what a record needs + update_zone($zoneid, { dhcp => "ebpf" }); + create_vnet({ + type => "vnet", + zone => $zoneid, + vnet => $vnetid, + }); + create_subnet({ + type => "subnet", + vnet => $vnetid, + subnet => "10.0.0.0/24", + 'dhcp-range' => ["start-address=10.0.0.100,end-address=10.0.0.200"], + }); + create_subnet({ + type => "subnet", + vnet => $vnetid, + subnet => "fd00::/64", + gateway => "fd00::1", + 'dhcp-range' => ["start-address=fd00::100,end-address=fd00::200"], + }); + $test_state->{vmlist} = { ids => { 999 => { type => 'qemu', node => 'localnode' } } }; + $test_state->{guest_nets} = { 999 => { net0 => "virtio=$mac,bridge=$vnetid" } }; + + take_ebpf_calls(); + eval { nic_start($vnetid, $mac, "999", "testhostname"); }; + if ($@) { + fail("$test_name: nic_start: $@"); + return; + } + + # a gateway-less v4 subnet is served under a link-local identifier and + # without a router. The v6 subnet yields nothing + eq_or_diff( + take_ebpf_calls(), + [{ + method => 'update', + args => [[{ + name => 'tap999i0', + vnet => $vnetid, + mac => $mac, + serve => 1, + record => $ebpf_record->( + "10.0.0.100", + server_id => '169.254.0.1', + router => undef, + ), + }]], + }], + "$test_name: a subnet without a gateway is served without a router", + ); + $test_state->{warnings} = []; + PVE::Network::SDN::Dhcp::regenerate_config(); + take_ebpf_calls(); + is(scalar($test_state->{warnings}->@*), 0, "$test_name: and the full pass has nothing to say"); + + # a v6 resolver on a v4 subnet is dropped from the record instead of + # failing the whole set + update_subnet({ + vnet => $vnetid, + subnet => "$zoneid-10.0.0.0-24", + gateway => "10.0.0.1", + 'dhcp-dns-server' => "fd00::53", + }); + $test_state->{warnings} = []; + PVE::Network::SDN::Dhcp::regenerate_config(); + my $calls = take_ebpf_calls(); + is(scalar(@$calls), 1, "$test_name: regenerate applies once"); + my $iface = $calls->[0]->{args}->[0]->[0]; + is($iface->{name}, 'tap999i0', "$test_name: the guest interface is listed"); + is($iface->{record}->{ip}, '10.0.0.100', "$test_name: the v4 subnet now yields the record"); + is($iface->{record}->{dns}, undef, "$test_name: the IPv6 resolver is not handed out"); + is( + scalar(grep { m/IPv6 DNS server/ } $test_state->{warnings}->@*), + 1, + "$test_name: and the full pass says so", + ); +} + +run_test(\&test_ebpf_backend); +run_test(\&test_ebpf_backend_edge_cases); + +sub test_ebpf_mac_case { + my $test_name = (split(/::/, (caller(0))[3]))[-1]; + my $zoneid = "TESTZONE"; + my $vnetid = "testvnet"; + my $mac = "da:65:8f:18:9b:6f"; + + # a mapping typed in lower case for a NIC whose config spells the MAC in + # upper case is the same mapping + create_zone({ + type => "simple", + dhcp => "ebpf", + ipam => "pve", + zone => $zoneid, + }); + create_vnet({ + type => "vnet", + zone => $zoneid, + vnet => $vnetid, + }); + create_subnet({ + type => "subnet", + vnet => $vnetid, + subnet => "10.0.0.0/24", + gateway => "10.0.0.1", + 'dhcp-range' => ["start-address=10.0.0.100,end-address=10.0.0.200"], + }); + $test_state->{vmlist} = { ids => { 999 => { type => 'qemu', node => 'localnode' } } }; + $test_state->{guest_nets} = { 999 => { net0 => "virtio=" . uc($mac) . ",bridge=$vnetid" } }; + create_ip({ + zone => $zoneid, + vnet => $vnetid, + mac => $mac, + ip => "10.0.0.100", + }); + take_ebpf_calls(); + take_order(); + PVE::Network::SDN::Dhcp::regenerate_config(); + eq_or_diff( + take_ebpf_calls(), + [{ + method => 'apply', + args => [[{ + name => 'tap999i0', + vnet => $vnetid, + mac => $mac, + serve => 1, + record => $ebpf_record->("10.0.0.100"), + on_config_bridge => 0, + }]], + }], + "$test_name: the full pass finds the mapping however the config spells the MAC", + ); + # the lock is taken before the refresh and the reads + is_deeply( + take_order(), + ['lock', 'refresh', 'write'], + "$test_name: the pass locks, refreshes, then writes", + ); + PVE::Network::SDN::Dhcp::Ebpf->tap_plug($zoneid, $vnetid, 'tap999i0', uc($mac)); + eq_or_diff( + take_ebpf_calls(), + [{ + method => 'attach', + args => ['tap999i0', $vnetid, uc($mac), $ebpf_record->("10.0.0.100")], + }], + "$test_name: and so does a plug", + ); + + # an entry split across spellings is read as one + $test_state->{macdb} = { + macs => { uc($mac) => { ip4 => '10.0.0.100' }, $mac => { ip6 => '8888::100' } }, + }; + PVE::Network::SDN::Dhcp::regenerate_config(); + is( + take_ebpf_calls()->[0]->{args}->[0]->[0]->{record}->{ip}, + '10.0.0.100', + "$test_name: a cache entry split across spellings is read as one", + ); +} + +run_test(\&test_ebpf_mac_case); + +sub test_ebpf_equal_subnets { + my $test_name = (split(/::/, (caller(0))[3]))[-1]; + + # two ebpf zones carry the same subnet. Each interface is answered from + # the subnet of the vnet it sits on, whatever address the cache holds + # for its MAC + for my $net (['ZONEA', 'vneta', '10.0.0.1'], ['ZONEB', 'vnetb', '10.0.0.2']) { + my ($zoneid, $vnetid, $gateway) = @$net; + create_zone({ + type => "simple", + dhcp => "ebpf", + ipam => "pve", + zone => $zoneid, + }); + create_vnet({ + type => "vnet", + zone => $zoneid, + vnet => $vnetid, + }); + create_subnet({ + type => "subnet", + vnet => $vnetid, + subnet => "10.0.0.0/24", + gateway => $gateway, + }); + } + $test_state->{vmlist} = { + ids => { + 100 => { type => 'qemu', node => 'localnode' }, + 101 => { type => 'qemu', node => 'localnode' }, + }, + }; + $test_state->{guest_nets} = { + 100 => { net0 => "virtio=da:65:8f:18:9b:6f,bridge=vneta" }, + 101 => { net0 => "virtio=da:65:8f:18:9b:70,bridge=vnetb" }, + }; + $test_state->{macdb} = { + vnets => { + vneta => { 'da:65:8f:18:9b:6f' => { ip4 => '10.0.0.150' } }, + vnetb => { 'da:65:8f:18:9b:70' => { ip4 => '10.0.0.150' } }, + }, + }; + + take_ebpf_calls(); + PVE::Network::SDN::Dhcp::regenerate_config(); + my $ifaces = take_ebpf_calls()->[0]->{args}->[0]; + eq_or_diff( + [map { [$_->{name}, $_->{record}->{router}] } @$ifaces], + [['tap100i0', '10.0.0.1'], ['tap101i0', '10.0.0.2']], + "$test_name: each interface is answered from its own vnet", + ); +} + +run_test(\&test_ebpf_equal_subnets); + +sub test_ebpf_zone_mtu { + my $test_name = (split(/::/, (caller(0))[3]))[-1]; + my $zoneid = "TESTZONE"; + my $vnetid = "testvnet"; + my $mac = "da:65:8f:18:9b:6f"; + + # a zone MTU the DHCP option cannot carry is reported on the apply and + # left out of the answer. The API refuses such an MTU, a config written + # before it did may still hold one + create_zone({ + type => "simple", + dhcp => "ebpf", + ipam => "pve", + zone => $zoneid, + }); + $test_state->{zones_config}->{ids}->{$zoneid}->{mtu} = 10; + create_vnet({ + type => "vnet", + zone => $zoneid, + vnet => $vnetid, + }); + 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(); + $test_state->{warnings} = []; + PVE::Network::SDN::Dhcp::regenerate_config(); + is( + scalar(grep { m/MTU 10/ } $test_state->{warnings}->@*), + 1, + "$test_name: the zone MTU is reported once", + ); + is( + take_ebpf_calls()->[0]->{args}->[0]->[0]->{record}->{mtu}, + undef, + "$test_name: and not handed out", + ); + + update_zone($zoneid, { mtu => 9000 }); + PVE::Network::SDN::Dhcp::regenerate_config(); + is( + take_ebpf_calls()->[0]->{args}->[0]->[0]->{record}->{mtu}, + 9000, + "$test_name: a zone MTU the option carries is handed out", + ); +} + +run_test(\&test_ebpf_zone_mtu); + +sub test_ebpf_overlapping_subnets { + my $test_name = (split(/::/, (caller(0))[3]))[-1]; + my $zoneid = "TESTZONE"; + my $vnetid = "testvnet"; + my $mac = "da:65:8f:18:9b:6f"; + + # a vnet with overlapping subnets answers from the most specific one + create_zone({ + type => "simple", + dhcp => "ebpf", + ipam => "pve", + zone => $zoneid, + }); + create_vnet({ + type => "vnet", + zone => $zoneid, + vnet => $vnetid, + }); + create_subnet({ + type => "subnet", + vnet => $vnetid, + subnet => "10.0.0.0/16", + gateway => "10.0.255.1", + }); + 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(); + my $record = take_ebpf_calls()->[0]->{args}->[0]->[0]->{record}; + is_deeply( + [$record->@{qw(prefixlen router)}], + [24, '10.0.0.1'], + "$test_name: the most specific subnet answers", + ); +} + +run_test(\&test_ebpf_overlapping_subnets); + done_testing(); -- 2.47.3