From: Hannes Laimer <h.laimer@proxmox.com>
To: pve-devel@lists.proxmox.com
Subject: [PATCH pve-network v2 08/16] sdn: dhcp: add ebpf plugin
Date: Wed, 9 Sep 2026 12:41:36 +0200 [thread overview]
Message-ID: <20260909104144.1110031-9-h.laimer@proxmox.com> (raw)
In-Reply-To: <20260909104144.1110031-1-h.laimer@proxmox.com>
A dhcp backend that programs the proxmox-ebpf per-tap DHCP responder
instead of driving a dnsmasq instance, selectable per zone with
dhcp=ebpf. Answers come from the same per-MAC records dnsmasq serves
reservations from, handed in-process to the responder through the pve-rs
bindings. The record names the NIC's MAC and the responder answers no
other, as dnsmasq ignores an unknown MAC.
A record change writes that one MAC's record. The full pass on a
regenerate compiles the whole desired state and lets the responder
converge on it. That state is the records of the ebpf zones and the
guest NICs on their vnets, each interface with the vnet it sits on and
the answer its MAC gets there. It is read from the running config, the
MAC cache and the guest configs. Every change carries a generation drawn
before its input is read. So the responder drops one that arrives after
a newer one of the same record, and nothing has to serialize the
collection. A tap plug draws its generation before it reads the cache.
The number orders that read and says nothing about the guest config,
which a hotplug writes after the plug. The vnet in every record covers
that, the responder leaves a record naming another vnet than the config
alone with its link. A tap plugged onto a bridge the backend does not
serve trades its entry for a marker naming that place instead. So a pass
still reading the old vnet leaves it alone, and one reading the new
place keeps it. An unplug drops the entry, the interface goes away with
it. A responder with nothing loaded, as after boot, answers a single
change with a request for the full pass, which then runs first.
The cache is keyed by the MAC as its writer spelled it and the guest
configs spell it their own way, so the plugin matches them regardless of
case. An ebpf zone confined to other nodes runs no responder here. The
cluster state is refreshed before the cache is read. An IPv4 subnet
whose DNS server or a zone whose MTU the responder cannot hand out is
reported on every apply, guests on it or not. The MTU handed out is the
zone's, as dnsmasq does. The pinned state does not survive a reboot, so
the backends get a boot hook the SDN commit runs before the guests
start. A plug that still finds nothing loaded runs the pass itself, its
retry drawing afresh so its input is read after the pass.
Guests get answers without a DHCP daemon per zone and, once records are
pushed, independent of IPAM reachability. The responder identifies
itself with the gateway address. A subnet without one is served under a
link-local identifier and without a default route, so its guests renew
by broadcast. IPv4 only.
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 | 82 ++++
src/PVE/Network/SDN/Dhcp/Ebpf.pm | 355 +++++++++++++++++
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 | 596 ++++++++++++++++++++++++++++
8 files changed, 1070 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 0000000..2a89236
--- /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, 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 b897cbd..0e90726 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 1f3619d..3b2d798 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();
}
@@ -45,6 +49,84 @@ sub update_mapping {
warn "could not update dhcp mapping for $mac: $@" if $@;
}
+# every guest NIC in the cluster as its config names it, with the interface
+# the guest has for it on its node. The config lines come from pmxcfs in one
+# go, no guest config gets parsed for this. A guest the vmlist does not know
+# is skipped.
+sub guest_nics {
+ # 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 $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;
+ push @$nics,
+ {
+ vmid => $vmid,
+ node => $guest->{node},
+ iface => "$prefix${vmid}i$index",
+ bridge => $bridge,
+ mac => $mac,
+ };
+ }
+ }
+
+ return $nics;
+}
+
+# the interface may come from a vnet of another backend, or of none, so
+# every other backend drops what it holds for it first
+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 $@;
+}
+
+# every backend brings back what a reboot took, run by the SDN commit at boot
+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,
+# every backend gets to drop what it holds for it
+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 0000000..fa20da3
--- /dev/null
+++ b/src/PVE/Network/SDN/Dhcp/Ebpf.pm
@@ -0,0 +1,355 @@
+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;
+
+my $DEFAULT_LEASE_TIME = 600;
+
+# a subnet without a gateway is served under a link-local server identifier,
+# which nothing on the subnet can carry. 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, a zone
+# MTU outside that is reported on an apply and not handed out.
+# TODO: bound the zone mtu in its schema, then this check can go
+my sub zone_mtu {
+ my ($zoneid, $zone, $report) = @_;
+
+ my $mtu = PVE::Network::SDN::Zones::get_mtu($zone);
+ 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, 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,
+ # the responder answers a request carrying this MAC and no other
+ mac => lc($mac),
+ mtu => defined($mtu) ? int($mtu) : undef,
+ };
+}
+
+# The desired state is compiled from the configs without a lock, so two
+# changes can reach the responder in the wrong order. Each carries a
+# generation drawn after its input was written and the responder drops a
+# change older than what it holds. A tap plug draws before it reads the
+# cache. Its number orders that read and says nothing about the guest
+# config, which is not written yet. The vnet in its record covers that.
+
+my sub generation {
+ return PVE::RS::SDN::Dhcp::next_generation();
+}
+
+# the IPv4 subnets of a vnet. What the responder leaves out of an answer
+# is reported only when asked. The full pass on an apply is where an admin
+# sees that once, a guest start must not repeat it
+
+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;
+}
+
+# the cache is keyed by the MAC as its writer spelled it, the guest configs
+# spell it their own way
+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 answer of a MAC on one vnet, none when nothing is to be served. The
+# vnet's subnet holding the cached address decides
+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;
+}
+
+# The complete desired state of this node's responder, built from the
+# running config, the MAC cache and the guest configs. It names every
+# guest NIC in the cluster with the vnet it sits on, whether the responder
+# runs on it and what it answers there. The responder keeps those present
+# on this node. The configs trail the kernel by a moment around a hotplug.
+# An interface they do not name yet, or still put on another vnet than its
+# plug did, is left alone by the responder while its link lives.
+
+my sub full_state {
+ my ($report) = @_;
+
+ # the configs and the cache are read after the cluster state is refreshed,
+ # a copy from before the generation was drawn 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);
+}
+
+# The full pass converges the responder from any starting point. It diffs
+# programs, links and records against its pinned state and leaves alone
+# what changed since it read its input. Once no zone uses the
+# backend anymore the state is torn down instead. A pass overtaken by a
+# newer one has nothing left to do, that one carried everything it would
+# have.
+my sub full_pass {
+ my ($report) = @_;
+
+ my $generation = eval { generation() };
+ if ($@) {
+ log_warn("could not draw a DHCP responder generation: $@");
+ return 0;
+ }
+
+ my ($zones, $ifaces) = eval { full_state($report) };
+ if ($@) {
+ log_warn("could not collect the DHCP responder state: $@");
+ return 0;
+ }
+
+ my $applied = eval {
+ $zones
+ ? PVE::RS::SDN::Dhcp::apply($generation, $ifaces)
+ : PVE::RS::SDN::Dhcp::clear($generation);
+ };
+ if ($@) {
+ log_warn("could not apply the DHCP responder state: $@");
+ return 0;
+ }
+
+ return $applied;
+}
+
+# the interfaces a MAC has on ebpf vnets with the answer the MAC gets
+# there, wherever the configs put them. The responder skips what is not
+# here
+
+my sub mac_ifaces {
+ my ($mac, $macs) = @_;
+
+ 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()->@*) {
+ 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;
+}
+
+# One MAC's records changed, the answer of each of its interfaces here is
+# rewritten or dropped. The generation is drawn first and the cache read
+# after it. The full pass runs only when the responder has nothing loaded to
+# write into.
+sub update_ip_mapping {
+ my ($class, $dhcpid, $mac) = @_;
+
+ my $generation = eval { generation() };
+ if ($@) {
+ log_warn("could not draw a DHCP responder generation: $@");
+ return;
+ }
+ PVE::Cluster::cfs_update();
+ my $ifaces = mac_ifaces($mac, cached_macs(PVE::Network::SDN::Ipams::read_macdb()));
+ return if !@$ifaces;
+
+ my $done = eval { PVE::RS::SDN::Dhcp::update($generation, $ifaces) };
+ if ($@) {
+ log_warn("could not update the DHCP responder records of $mac: $@");
+ return;
+ }
+ full_pass(0) if !$done;
+}
+
+# the dispatcher's per-zone walk is not needed, 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);
+}
+
+# the pinned state is gone after a reboot. The full pass loads the programs
+# before the first guest plugs, so no guest start has to
+sub boot {
+ my ($class) = @_;
+
+ full_pass(1);
+}
+
+# drops the link and the record of an unplugged guest interface, or turns
+# the record into a marker naming where it went. A crash that skipped this
+# leaves them to the next full pass
+
+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 $@;
+}
+
+# attaches the responder program to the plugged guest interface with the
+# answer its MAC gets on the vnet. Best effort, a guest start must not fail
+# on it. Nothing loaded yet means the first plug after boot, the full pass
+# loads and the attach is retried.
+sub tap_plug {
+ my ($class, $dhcpid, $vnetid, $iface, $mac) = @_;
+
+ # the generation is drawn before the cache is read, so the record is at
+ # least as new as every change drawn before it
+ my $attach = sub {
+ my $generation = generation();
+ 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 PVE::RS::SDN::Dhcp::attach($generation, $iface, $vnetid, $record);
+ };
+
+ my $done = eval { $attach->() };
+ if ($@) {
+ log_warn("could not attach DHCP responder to $iface: $@");
+ return;
+ }
+ return if $done;
+
+ # nothing is loaded, a rebuild or a boot the SDN commit did not load on.
+ # The pass loads. The retry draws afresh, its input is read after the pass
+
+ 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 6546513..ce86aae 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 3b95a68..1653c29 100644
--- a/src/PVE/Network/SDN/Dhcp/Plugin.pm
+++ b/src/PVE/Network/SDN/Dhcp/Plugin.pm
@@ -59,9 +59,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,
+# nothing to do for backends serving the bridge rather than the interface
+sub tap_plug {
+ my ($class, $dhcpid, $vnetid, $iface, $mac) = @_;
+}
+
+# a guest interface left its vnet or went away, its zone is not known
+# anymore by then. A target names the bridge it sits on now, the vnet that
+# is if any. None means the interface is gone or goes away
+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 29edf7b..38579f2 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 2429adf..2af4880 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,8 @@ sub clear_test_state {
},
},
},
+ ebpf_answer => undef,
+ generation => 0,
};
PVE::Tools::file_set_contents($TMP_ETHERS_FILE, "\n");
}
@@ -234,6 +239,33 @@ $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(
+ next_generation => sub {
+ push $test_state->{order}->@*, 'draw';
+ return ++$test_state->{generation};
+ },
+ map {
+ my $method = $_;
+ $method => sub {
+ # the generation is a counter, not part of the expected state, it
+ # leads every ordered call
+ my @args = @_;
+ if ($method ne 'detach') {
+ shift @args;
+ push $test_state->{order}->@*, 'write';
+ }
+ push $test_state->{ebpf_calls}->@*, { method => $method, args => [@args] };
+ return $test_state->{ebpf_answer}->{$method} // 1;
+ };
+ } qw(apply attach clear update detach),
+);
+
+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 +290,9 @@ 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 { return $test_state->{guest_nets} // {}; },
);
# ------- TEST FUNCTIONS --------------
@@ -365,6 +400,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 draws, 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();
@@ -1402,4 +1455,547 @@ 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 => 1500,
+ 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, 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();
+ eval { nic_start($vnetid, $mac, "999", "testhostname"); };
+ if ($@) {
+ fail("$test_name: nic_start: $@");
+ return;
+ }
+ 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, a delete leaves the interface without one
+ update_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 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, 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, 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 };
+ 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",
+ );
+
+ # 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, 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 generation is drawn before the state is refreshed and read, so
+ # nothing read is older than the generation it is written under
+ is_deeply(
+ take_order(),
+ ['draw', 'refresh', 'write'],
+ "$test_name: the pass draws, 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 of the old code 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",
+ );
+}
+
+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();
--
2.47.3
next prev parent reply other threads:[~2026-09-09 10:44 UTC|newest]
Thread overview: 17+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-09-09 10:41 [PATCH container/docs/manager/network/proxmox{-ebpf,-perl-rs}/qemu-server v2 00/16] sdn: implement DHCP for all zones using eBPF Hannes Laimer
2026-09-09 10:41 ` [PATCH proxmox-ebpf v2 01/16] dhcp: add per-tap responder BPF program Hannes Laimer
2026-09-09 10:41 ` [PATCH proxmox-ebpf v2 02/16] dhcp: add responder subsystem Hannes Laimer
2026-09-09 10:41 ` [PATCH proxmox-perl-rs v2 03/16] pve-rs: sdn: add dhcp responder bindings Hannes Laimer
2026-09-09 10:41 ` [PATCH pve-network v2 04/16] sdn: push mapping changes from the ipam API to the dhcp backend Hannes Laimer
2026-09-09 10:41 ` [PATCH pve-network v2 05/16] sdn: ipam: do not cache negative per-MAC answers, lock the write Hannes Laimer
2026-09-09 10:41 ` [PATCH pve-network v2 06/16] sdn: subnets: add dhcp-lease-time property Hannes Laimer
2026-09-09 10:41 ` [PATCH pve-network v2 07/16] sdn: dhcp: only assert a backend's availability for zones using it Hannes Laimer
2026-09-09 10:41 ` Hannes Laimer [this message]
2026-09-09 10:41 ` [PATCH pve-network v2 09/16] sdn: zones: attach the dhcp responder on tap plug, detach on unplug Hannes Laimer
2026-09-09 10:41 ` [PATCH pve-network v2 10/16] sdn: dhcp: apply mapping edits on the node serving the guest Hannes Laimer
2026-09-09 10:41 ` [PATCH pve-network v2 11/16] sdn: zones: offer dhcp on all zone types, keep dnsmasq simple-only Hannes Laimer
2026-09-09 10:41 ` [PATCH qemu-server v2 12/16] network: report NIC plug and unplug to SDN with the MAC Hannes Laimer
2026-09-09 10:41 ` [PATCH pve-container v2 13/16] net: report veth plug and unplug to SDN with the hwaddr Hannes Laimer
2026-09-09 10:41 ` [PATCH pve-manager v2 14/16] ui: sdn: dhcp backend selector on all zones, expose dhcp options Hannes Laimer
2026-09-09 10:41 ` [PATCH pve-manager v2 15/16] sdn: bring the dhcp backends up at boot before the guests start Hannes Laimer
2026-09-09 10:41 ` [PATCH pve-docs v2 16/16] sdn: dhcp: document the ebpf backend 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=20260909104144.1110031-9-h.laimer@proxmox.com \
--to=h.laimer@proxmox.com \
--cc=pve-devel@lists.proxmox.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.