From: Stefan Hanreich <s.hanreich@proxmox.com>
To: pve-devel@lists.proxmox.com
Subject: Re: [PATCH pve-network v3 09/17] sdn: dhcp: add an eBPF backend answering on each guest interface
Date: Tue, 20 Oct 2026 15:10:50 +0200 [thread overview]
Message-ID: <edaeb5a6-dca4-4b8f-9034-70cdd13f7ea2@proxmox.com> (raw)
In-Reply-To: <20261020080005.470757-10-h.laimer@proxmox.com>
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 <h.laimer@proxmox.com>
> ---
> 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();
next prev parent reply other threads:[~2026-10-20 13:11 UTC|newest]
Thread overview: 43+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-10-20 7:59 [PATCH container/docs/manager/network/proxmox{-ebpf,-perl-rs}/qemu-server v3 00/17] sdn: implement DHCP for all zones using eBPF Hannes Laimer
2026-10-20 7:59 ` [PATCH proxmox-ebpf v3 01/17] dhcp: add the per-tap DHCPv4 responder program Hannes Laimer
2026-10-20 7:59 ` [PATCH proxmox-ebpf v3 02/17] dhcp: add the subsystem that loads the responder and keeps its records Hannes Laimer
2026-10-20 13:09 ` Stefan Hanreich
2026-10-21 7:09 ` Hannes Laimer
2026-10-20 7:59 ` [PATCH proxmox-perl-rs v3 03/17] pve-rs: sdn: add bindings for the eBPF DHCP responder Hannes Laimer
2026-10-20 13:10 ` Stefan Hanreich
2026-10-21 7:32 ` Hannes Laimer
2026-10-20 7:59 ` [PATCH pve-network v3 04/17] sdn: dhcp: replace a stale dnsmasq reservation instead of dropping it Hannes Laimer
2026-10-20 13:15 ` Stefan Hanreich
2026-10-20 7:59 ` [PATCH pve-network v3 05/17] sdn: dhcp: update reservations when ip mappings change Hannes Laimer
2026-10-20 13:42 ` Stefan Hanreich
2026-10-21 7:21 ` Hannes Laimer
2026-10-20 7:59 ` [PATCH pve-network v3 06/17] sdn: ipam: key the MAC cache by lower-case MAC, stop caching misses Hannes Laimer
2026-10-20 13:10 ` Stefan Hanreich
2026-10-21 6:17 ` Hannes Laimer
2026-10-20 7:59 ` [PATCH pve-network v3 07/17] sdn: dhcp: make the lease time configurable per subnet Hannes Laimer
2026-10-20 13:17 ` Stefan Hanreich
2026-10-20 7:59 ` [PATCH pve-network v3 08/17] sdn: dhcp: require a backend only if a zone on this node uses it Hannes Laimer
2026-10-20 13:17 ` Stefan Hanreich
2026-10-20 7:59 ` [PATCH pve-network v3 09/17] sdn: dhcp: add an eBPF backend answering on each guest interface Hannes Laimer
2026-10-20 13:10 ` Stefan Hanreich [this message]
2026-10-21 6:16 ` Hannes Laimer
2026-10-20 7:59 ` [PATCH pve-network v3 10/17] sdn: zones: report interface plugs and unplugs to the DHCP backends Hannes Laimer
2026-10-20 13:17 ` Stefan Hanreich
2026-10-20 7:59 ` [PATCH pve-network v3 11/17] sdn: dhcp: push IP mapping edits to the guest's node Hannes Laimer
2026-10-20 13:09 ` Stefan Hanreich
2026-10-21 6:44 ` Hannes Laimer
2026-10-20 8:00 ` [PATCH pve-network v3 12/17] sdn: zones: allow the ebpf DHCP backend on every zone type Hannes Laimer
2026-10-20 8:00 ` [PATCH qemu-server v3 13/17] network: sdn: keep the DHCP backends current on guest NICs Hannes Laimer
2026-10-20 13:38 ` Stefan Hanreich
2026-10-20 8:00 ` [PATCH pve-container v3 14/17] net: report NIC plugs and removals to the SDN DHCP backends Hannes Laimer
2026-10-20 13:36 ` Stefan Hanreich
2026-10-21 7:29 ` Hannes Laimer
2026-10-20 8:00 ` [PATCH pve-manager v3 15/17] ui: sdn: add zone DHCP backend selector and subnet DHCP options Hannes Laimer
2026-10-20 13:19 ` Stefan Hanreich
2026-10-20 8:00 ` [PATCH pve-manager v3 16/17] sdn: bring up the DHCP backends at boot before the guests start Hannes Laimer
2026-10-20 13:21 ` Stefan Hanreich
2026-10-21 7:17 ` Hannes Laimer
2026-10-20 8:00 ` [PATCH pve-docs v3 17/17] sdn: dhcp: add eBPF backend section Hannes Laimer
2026-10-20 13:11 ` [PATCH container/docs/manager/network/proxmox{-ebpf,-perl-rs}/qemu-server v3 00/17] sdn: implement DHCP for all zones using eBPF Stefan Hanreich
2026-10-21 7:35 ` Hannes Laimer
2026-10-21 8:46 ` superseded: " Hannes Laimer
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=edaeb5a6-dca4-4b8f-9034-70cdd13f7ea2@proxmox.com \
--to=s.hanreich@proxmox.com \
--cc=pve-devel@lists.proxmox.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox