all lists on lists.proxmox.com
 help / color / mirror / Atom feed
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();





  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 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.
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal