From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from gate001.proxmox.com (gate001.proxmox.com [IPv6:2a0f:8001:1:32::40]) by lore.proxmox.com (Postfix) with ESMTPS id A53AD1FF0AA for ; Tue, 20 Oct 2026 15:11:03 +0200 (CEST) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 73E55216AC; Tue, 20 Oct 2026 15:11:00 +0200 (CEST) Message-ID: Date: Tue, 20 Oct 2026 15:10:50 +0200 MIME-Version: 1.0 User-Agent: Mozilla Thunderbird From: Stefan Hanreich Subject: Re: [PATCH pve-network v3 09/17] sdn: dhcp: add an eBPF backend answering on each guest interface To: pve-devel@lists.proxmox.com References: <20261020080005.470757-1-h.laimer@proxmox.com> <20261020080005.470757-10-h.laimer@proxmox.com> Content-Language: en-US In-Reply-To: <20261020080005.470757-10-h.laimer@proxmox.com> Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 7bit X-SPAM-LEVEL: Spam detection results: 0 AWL 0.390 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: IH7AM6LRAJH33JHE6YB2JZYXF6ETYKQJ X-Message-ID-Hash: IH7AM6LRAJH33JHE6YB2JZYXF6ETYKQJ X-MailFrom: s.hanreich@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: On 10/20/26 10:04 AM, Hannes Laimer wrote: > 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 > --- > debian/libpve-network-perl.prerm | 13 + > src/PVE/API2/Network/SDN/Zones.pm | 3 +- > src/PVE/Network/SDN/Dhcp.pm | 79 ++++ > src/PVE/Network/SDN/Dhcp/Ebpf.pm | 308 +++++++++++++ > src/PVE/Network/SDN/Dhcp/Makefile | 2 +- > src/PVE/Network/SDN/Dhcp/Plugin.pm | 19 + > src/PVE/Network/SDN/SubnetPlugin.pm | 4 +- > src/test/run_test_vnets_blackbox.pl | 642 ++++++++++++++++++++++++++++ > 8 files changed, 1066 insertions(+), 4 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 00000000..d28c74fe > --- /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 b897cbdf..0e907260 100644 > --- a/src/PVE/API2/Network/SDN/Zones.pm > +++ b/src/PVE/API2/Network/SDN/Zones.pm > @@ -16,6 +16,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; > @@ -90,7 +91,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 2a023534..cf805a03 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,6 +18,9 @@ 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(); > } > @@ -49,6 +53,81 @@ sub update_mapping { > warn "could not update dhcp mapping for $mac: $@" if $@; > } > > +# 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 > +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], $vmid); > + my $nics = []; > + for my $vmid (sort keys %$nets) { > + my $guest = $vmlist->{ids}->{$vmid} // next; > + 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; > + # a VM names the MAC as the value of its model, a container as hwaddr > + my ($mac) = $net =~ m/(?:^|,)[a-z0-9_-]+=([0-9a-f]{2}(?::[0-9a-f]{2}){5})(?:,|$)/i; Is there a reason for not using the pre-existing parse_net / parse_lxc_network functions in qemu-server / pve-container here? They'd provide a standalone way of parsing the property string only, without re-implementing the logic here. Circular dependencies potentially? > + push @$nics, > + { > + vmid => $vmid, > + node => $guest->{node}, > + iface => "$prefix${vmid}i$index", > + bridge => $bridge, > + mac => $mac, > + }; > + } > + } > + > + return $nics; > +} > + > +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 }; > + 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 00000000..fc4af1e0 > --- /dev/null > +++ b/src/PVE/Network/SDN/Dhcp/Ebpf.pm > @@ -0,0 +1,308 @@ > +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::Network::SDN::Ipams; > +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, $mac) = @_; > + > + 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, > + mac => lc($mac), > + 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}); > + log_warn("subnet $subnetid has an IPv6 DNS server, not handing it out over IPv4") > + if $report > + && defined($subnet->{'dhcp-dns-server'}) > + && !Net::IP::ip_is_ipv4($subnet->{'dhcp-dns-server'}); > + push @$served, $subnet; > + } > + > + return $served; > +} > + > +# entries from before the cache was keyed in lower case carry their writer's > +# spelling > +my sub cached_macs { > + my ($macdb) = @_; > + > + my $macs = {}; > + for my $mac (sort keys $macdb->{macs}->%*) { > + my $entry = $macdb->{macs}->{$mac}; > + $macs->{ lc($mac) }->{$_} = $entry->{$_} for grep { defined($entry->{$_}) } qw(ip4 ip6); > + } > + return $macs; > +} > + > +my $matchers = {}; > + > +# the vnet's subnet holding the cached address decides the answer > +my sub vnet_record { > + my ($subnets, $mtu, $macs, $mac) = @_; > + > + my $ip4 = $macs->{ lc($mac) }->{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, $mac); > + } > + > + return undef; > +} > + > +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 $macs = cached_macs(PVE::Network::SDN::Ipams::read_macdb()); > + 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 = []; > + for my $nic (PVE::Network::SDN::Dhcp::guest_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); > + $record = vnet_record($subnets{$vnetid}, $mtu{$zoneid}, $macs, $nic->{mac}) > + if $nic->{mac}; > + } > + push @$ifaces, > + { > + name => $nic->{iface}, > + vnet => $vnets->{$vnetid} ? $vnetid : undef, > + serve => $serve, > + record => $record, > + }; > + } > + > + 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() }; > + log_warn("could not apply the DHCP responder state: $@") if $@; > +} > + > +my sub mac_ifaces { > + my ($mac, $macs, $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)->@*) { > + next if lc($nic->{mac} // '') ne lc($mac); > + my $vnet = $vnets->{ $nic->{bridge} } // next; > + my $zone = $zones->{ $vnet->{zone} } // next; > + next if ($zone->{dhcp} // '') ne 'ebpf'; > + my $record = vnet_record( > + served_subnets($nic->{bridge}, 0), > + zone_mtu($vnet->{zone}, $zone, 0), > + $macs, > + $mac, > + ); > + push @$ifaces, > + { name => $nic->{iface}, vnet => $nic->{bridge}, 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, $mac, $vmid) = @_; > + > + 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($mac, cached_macs(PVE::Network::SDN::Ipams::read_macdb()), $vmid); > + return 1 if !@$ifaces; > + return $lock->update($ifaces); > + }; > + > + my $done = eval { $update->() }; > + if ($@) { > + log_warn("could not update the DHCP responder records of $mac: $@"); > + return; > + } > + return if $done; > + > + # nothing loaded, the pass takes its own lock once the update's is gone > + full_pass(0); > +} > + > +# 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); > +} > + > +sub tap_unplug { > + my ($class, $iface, $target) = @_; > + > + eval { PVE::RS::SDN::Dhcp::detach($iface, $target) }; > + log_warn("could not detach the DHCP responder from $iface: $@") if $@; > +} > + > +sub tap_plug { > + my ($class, $dhcpid, $vnetid, $iface, $mac) = @_; > + > + my $attach = sub { > + my $lock = PVE::RS::SDN::Dhcp::SharedLock->new(); > + my $record; > + if ($mac) { > + PVE::Cluster::cfs_update(); > + my $zone = PVE::Network::SDN::Zones::get_zone($dhcpid, 1); > + $record = vnet_record( > + served_subnets($vnetid, 0), > + zone_mtu($dhcpid, $zone, 0), > + cached_macs(PVE::Network::SDN::Ipams::read_macdb()), > + $mac, > + ); > + } > + return $lock->attach($iface, $vnetid, $record); > + }; > + > + my $done = eval { $attach->() }; > + if ($@) { > + log_warn("could not attach DHCP responder to $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 { $attach->() }; > + log_warn("could not attach DHCP responder to $iface: " . ($@ || "nothing is loaded")) > + if $@ || !$done; > +} > + > +1; > diff --git a/src/PVE/Network/SDN/Dhcp/Makefile b/src/PVE/Network/SDN/Dhcp/Makefile > index 65465135..ce86aaef 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 2fa8683c..149873e6 100644 > --- a/src/PVE/Network/SDN/Dhcp/Plugin.pm > +++ b/src/PVE/Network/SDN/Dhcp/Plugin.pm > @@ -60,9 +60,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, 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 29edf7b6..38579f21 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/test/run_test_vnets_blackbox.pl b/src/test/run_test_vnets_blackbox.pl > index cda4b7ef..db6fc472 100755 > --- a/src/test/run_test_vnets_blackbox.pl > +++ b/src/test/run_test_vnets_blackbox.pl > @@ -47,6 +47,9 @@ sub clear_test_state { > macdb => {}, > ipamdb => {}, > dnsmasq_calls => [], > + ebpf_calls => [], > + order => [], > + warnings => [], > ipam_config => { > 'ids' => { > 'pve' => { > @@ -54,6 +57,7 @@ sub clear_test_state { > }, > }, > }, > + ebpf_answer => undef, > }; > PVE::Tools::file_set_contents($TMP_ETHERS_FILE, "\n"); > } > @@ -234,6 +238,43 @@ $mocked_sdn_dhcp_dnsmasq->mock( > update_lease => sub { }, > ); > > +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 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 { }, > @@ -258,6 +299,15 @@ my $mocked_pve_cluster_obj = Test::MockModule->new('PVE::Cluster'); > $mocked_pve_cluster_obj->mock( > check_cfs_quorum => sub { return 1; }, > cfs_lock_domain => $mocked_cfs_lock_domain, > + cfs_update => sub { push $test_state->{order}->@*, 'refresh'; }, > + get_vmlist => sub { return $test_state->{vmlist} // { ids => {} }; }, > + 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 -------------- > @@ -365,6 +415,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(); > @@ -1437,4 +1505,578 @@ sub test_dnsmasq_dual_stack_and_sweep { > > run_test(\&test_dnsmasq_dual_stack_and_sweep); > > +# -------------- 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, > + mac => 'da:65:8f:18:9b:6f', > + %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, > + 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, > + 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, serve => 1, record => undef }]], > + }], > + "$test_name: a mapping delete drops the record", > + ); > + > + # a guest running elsewhere has no interface here to write to > + $test_state->{vmlist} = { ids => { 999 => { type => 'qemu', node => 'other' } } }; > + create_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, > + serve => 1, > + record => $ebpf_record->("10.0.0.150"), > + }]], > + }], > + "$test_name: a guest elsewhere is handed over too, the responder skips what is not here", > + ); > + > + # 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" }, > + 103 => { net0 => "virtio,bridge=$vnetid" }, > + }; > + PVE::Network::SDN::Dhcp::regenerate_config(); > + eq_or_diff( > + take_ebpf_calls(), > + [{ > + method => 'apply', > + args => [[ > + { > + name => 'tap100i0', > + vnet => $vnetid, > + serve => 1, > + record => $ebpf_record->("10.0.0.150"), > + }, > + { name => 'tap100i1', vnet => undef, serve => 0, record => undef }, > + { name => 'veth101i2', vnet => $vnetid, serve => 1, record => undef }, > + { name => 'tap103i0', vnet => $vnetid, serve => 1, record => undef }, > + ]], > + }], > + "$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, $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] }], > + "$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, > + 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, > + serve => 1, > + record => $ebpf_record->("10.0.0.100"), > + }]], > + }], > + "$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, $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} = { > + macs => { > + 'da:65:8f:18:9b:6f' => { ip4 => '10.0.0.150' }, > + '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 > + create_zone({ > + type => "simple", > + dhcp => "ebpf", > + ipam => "pve", > + zone => $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} = { macs => { $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} = { macs => { $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();