public inbox for pve-devel@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 a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal