public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
* [PATCH pve-network 07/12] sdn: dhcp: add ebpf plugin
  2026-09-02 12:47 [RFC manager/network/proxmox{-ebpf,-perl-rs} 00/12] sdn: implement DHCP for all zones using eBPF Hannes Laimer
@ 2026-09-02 12:47 ` Hannes Laimer
  0 siblings, 0 replies; 14+ messages in thread
From: Hannes Laimer @ 2026-09-02 12:47 UTC (permalink / raw)
  To: pve-devel

A dhcp backend that programs the proxmox-ebpf per-tap DHCP responder
instead of driving a dnsmasq instance, selectable per zone with
dhcp=ebpf. Answers come from the same per-MAC records dnsmasq serves
reservations from, handed in-process to the responder through the
pve-rs bindings as complete records, so each mapping push and the
full regenerate sync are self-contained.

Guests get answers without a DHCP daemon per zone and, once records
are pushed, independent of IPAM reachability. Subnets without a
gateway are skipped, the responder identifies itself with the
gateway address. IPv4 only.

Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
 src/PVE/API2/Network/SDN/Zones.pm |   2 +-
 src/PVE/Network/SDN/Dhcp.pm       |   4 +
 src/PVE/Network/SDN/Dhcp/Ebpf.pm  | 173 ++++++++++++++++++++++++++++++
 src/PVE/Network/SDN/Dhcp/Makefile |   2 +-
 4 files changed, 179 insertions(+), 2 deletions(-)
 create mode 100644 src/PVE/Network/SDN/Dhcp/Ebpf.pm

diff --git a/src/PVE/API2/Network/SDN/Zones.pm b/src/PVE/API2/Network/SDN/Zones.pm
index b897cbd..ad16bef 100644
--- a/src/PVE/API2/Network/SDN/Zones.pm
+++ b/src/PVE/API2/Network/SDN/Zones.pm
@@ -90,7 +90,7 @@ my $ZONE_PROPERTIES = {
     },
     dhcp => {
         type => 'string',
-        enum => ['dnsmasq'],
+        enum => ['dnsmasq', 'ebpf'],
         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 4d937dc..f046dfd 100644
--- a/src/PVE/Network/SDN/Dhcp.pm
+++ b/src/PVE/Network/SDN/Dhcp.pm
@@ -10,6 +10,7 @@ use PVE::Network::SDN::Ipams;
 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;
 
@@ -18,6 +19,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();
 }
diff --git a/src/PVE/Network/SDN/Dhcp/Ebpf.pm b/src/PVE/Network/SDN/Dhcp/Ebpf.pm
new file mode 100644
index 0000000..d6a85a5
--- /dev/null
+++ b/src/PVE/Network/SDN/Dhcp/Ebpf.pm
@@ -0,0 +1,173 @@
+package PVE::Network::SDN::Dhcp::Ebpf;
+
+use strict;
+use warnings;
+
+use base qw(PVE::Network::SDN::Dhcp::Plugin);
+
+use Net::IP qw(:PROC);
+use Net::Subnet qw(subnet_matcher);
+
+use PVE::RESTEnvironment qw(log_warn);
+
+use PVE::RS::SDN::Dhcp;
+
+my $DEFAULT_LEASE_TIME = 600;
+
+sub type {
+    return 'ebpf';
+}
+
+# The responder identifies itself with the subnet gateway, a subnet
+# without one cannot be served.
+my sub dhcp_record {
+    my ($mac, $ip4, $subnet, $mtu) = @_;
+
+    my $gateway = $subnet->{gateway};
+    return undef if !$gateway;
+
+    # the config hands its numbers over as strings, the bindings take integers only
+    return {
+        mac => $mac,
+        ip => $ip4,
+        prefixlen => int($subnet->{mask}),
+        server_id => $gateway,
+        lease => int($subnet->{'dhcp-lease-time'} // $DEFAULT_LEASE_TIME),
+        router => $gateway,
+        dns => $subnet->{'dhcp-dns-server'},
+        mtu => defined($mtu) ? int($mtu) : undef,
+    };
+}
+
+my sub zone_subnets {
+    my ($zoneid) = @_;
+
+    my $cfg = PVE::Network::SDN::Subnets::config();
+
+    my $subnets = {};
+    for my $id (keys %{ $cfg->{ids} }) {
+        my $subnet = PVE::Network::SDN::Subnets::sdn_subnets_config($cfg, $id);
+        next if $subnet->{zone} ne $zoneid;
+        $subnets->{$id} = $subnet;
+    }
+
+    return $subnets;
+}
+
+my sub zone_mtu {
+    my ($zoneid) = @_;
+
+    my $zone = PVE::Network::SDN::Zones::get_zone($zoneid, 1);
+    return if !$zone;
+
+    return PVE::Network::SDN::Zones::get_mtu($zone);
+}
+
+sub add_ip_mapping {
+    my ($class, $dhcpid, $macdb, $mac, $ip4, $ip6) = @_;
+
+    return if !$ip4; # v4 answers only
+
+    my $subnets = zone_subnets($dhcpid);
+    my ($subnetid, $subnet) = eval { PVE::Network::SDN::Subnets::find_ip_subnet($ip4, $subnets) };
+    if (!$subnet) {
+        warn "could not find subnet for $ip4 in zone $dhcpid: $@";
+        return;
+    }
+
+    my $record = dhcp_record($mac, $ip4, $subnet, zone_mtu($dhcpid));
+    if (!defined($record)) {
+        warn "subnet $subnetid has no gateway, cannot serve DHCP for $mac\n";
+        return;
+    }
+
+    eval { PVE::RS::SDN::Dhcp::update([$record]) };
+    warn "could not update DHCP record for $mac: $@" if $@;
+}
+
+sub del_ip_mapping {
+    my ($class, $dhcpid, $mac) = @_;
+
+    eval { PVE::RS::SDN::Dhcp::remove($mac) };
+    warn "could not remove DHCP record for $mac: $@" if $@;
+}
+
+# regenerate translates the full record set into one responder sync, the
+# dispatcher collects per-vnet records through the configure hooks
+my $sync_records = undef;
+my $current_mtu = undef;
+
+sub before_regenerate {
+    my ($class, $noerr) = @_;
+
+    $sync_records = [];
+}
+
+sub before_configure {
+    my ($class, $dhcpid, $zone_cfg) = @_;
+
+    $current_mtu = PVE::Network::SDN::Zones::get_mtu($zone_cfg);
+}
+
+sub configure_subnet {
+    my ($class, $config, $dhcpid, $vnetid, $subnet_config) = @_;
+
+    return if !Net::IP::ip_is_ipv4($subnet_config->{network});
+
+    if (!$subnet_config->{gateway}) {
+        warn "subnet $subnet_config->{id} has no gateway, not serving DHCP for it\n";
+        return;
+    }
+
+    my $macdb = PVE::Network::SDN::Ipams::read_macdb();
+    my $matcher = subnet_matcher($subnet_config->{cidr});
+
+    for my $mac (sort keys %{ $macdb->{macs} }) {
+        my $ip4 = $macdb->{macs}->{$mac}->{ip4};
+        next if !$ip4 || !$matcher->($ip4);
+        # the vnet's own gateway address is cached too and never a lease
+        next if $ip4 eq $subnet_config->{gateway};
+        push @$config, dhcp_record($mac, $ip4, $subnet_config, $current_mtu);
+    }
+}
+
+sub configure_range {
+    # noop, static answers only
+}
+
+sub configure_vnet {
+    my ($class, $config, $dhcpid, $vnetid, $vnet_config) = @_;
+
+    push @$sync_records, @$config;
+}
+
+sub after_configure {
+    my ($class, $dhcpid, $noerr) = @_;
+
+    $current_mtu = undef;
+}
+
+sub after_regenerate {
+    my ($class) = @_;
+
+    my $records = $sync_records // [];
+    $sync_records = undef;
+
+    # the full pass also drops the link pins of departed guests
+    eval { PVE::RS::SDN::Dhcp::apply() };
+    warn "could not refresh the DHCP responder: $@" if $@;
+
+    eval { PVE::RS::SDN::Dhcp::sync($records) };
+    warn "could not sync DHCP records: $@" if $@;
+}
+
+# tap plug hook, attaches the responder program to a guest interface.
+# Best effort, a guest start must not fail on it.
+sub attach_iface {
+    my ($iface) = @_;
+
+    eval { PVE::RS::SDN::Dhcp::attach($iface) };
+    log_warn("could not attach DHCP responder to $iface: $@") if $@;
+}
+
+1;
diff --git a/src/PVE/Network/SDN/Dhcp/Makefile b/src/PVE/Network/SDN/Dhcp/Makefile
index 6546513..ce86aae 100644
--- a/src/PVE/Network/SDN/Dhcp/Makefile
+++ b/src/PVE/Network/SDN/Dhcp/Makefile
@@ -1,4 +1,4 @@
-SOURCES=Plugin.pm Dnsmasq.pm
+SOURCES=Plugin.pm Dnsmasq.pm Ebpf.pm
 
 
 PERL5DIR=${DESTDIR}/usr/share/perl5
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 14+ messages in thread

* [PATCH manager/network/proxmox{-ebpf,-perl-rs} 00/12] sdn: implement DHCP for all zones using eBPF
@ 2026-09-04  9:38 Hannes Laimer
  2026-09-04  9:38 ` [PATCH proxmox-ebpf 01/12] dhcp: add per-tap responder BPF program Hannes Laimer
                   ` (11 more replies)
  0 siblings, 12 replies; 14+ messages in thread
From: Hannes Laimer @ 2026-09-04  9:38 UTC (permalink / raw)
  To: pve-devel

Adds a second DHCP backend, `ebpf`, next to dnsmasq, selectable per
zone. It aims to replace dnsmasq eventually, for now it is a second
implementation, which keeps a migration simple. Every zone type can
enable DHCP through a dropdown selector, `dnsmasq` stays limited to
simple zones.

The responder is a subsystem of `proxmox-ebpf` [1], Perl reaches it
through new pve-rs bindings (PVE::RS::SDN::Dhcp), so the pve-network
patches need the pve-rs of this series.

Currently only supports DHCPv4, but adding v6 is very possible once
we're happy with the overall design.

# How
An eBPF program on the ingress of every guest tap parses DHCP requests,
looks the client MAC up in a mac -> ip+options map and rewrites the
request into the reply in place, redirected back out of the tap. The
exchange never reaches the bridge. Everything else, including MACs
without a map entry, passes untouched, so attaching is a no-op for
unmanaged MACs.

IPAM is the source of the assignments, the map is a per-node copy of
the records. Every trigger below runs the same full pass, the plugin
collects all records of the ebpf zones and the guest interfaces on
their vnets, the responder diffs both against the kernel state, so
programs, links and records converge from any starting point:
 - guest start / NIC hotplug / migration: add_dhcp_mapping already
   fires here, before the interface is plugged, a new tap_plug hook of
   the dhcp plugins then attaches the program.
 - mapping create/update/delete through the API: the editing node runs
   it and pokes the node running the guest to do the same through a
   new node endpoint (POST /nodes/{node}/sdn/dhcp-mapping), detached
   from the request. Best effort, an unreachable node catches up on its
   next apply or the guest's next start.
 - SDN apply: also refreshes the programs, a rebuild on a schema change
   is refilled in the same pass, and a zone switching its backend takes
   effect for running guests too.
 - boot: nothing is pinned, the first pass after boot loads the
   programs and fills the map.

Subnets get a `dhcp-lease-time` property, used by both backends,
dnsmasq keeps handing out infinite leases without it and the responder
defaults to ten minutes. The responder identifies itself with the
subnet gateway, so a subnet without one is not served, and it hands out
IPv4 resolvers only, a v6 one configured on a v4 subnet is left out of
the answers.

Changes made directly on an external IPAM service are not detectable
and the per-MAC answers are cached, so they are not picked up on apply
either, exactly like with dnsmasq today.

The pve-network patches apply on top of the separately posted patch
pushing ipam API mapping changes to the dhcp backend [2].

pre-build packages are on sani(`packages/ebpf-dhcp-v1`)

since the RFC:
 - every trigger runs the same full pass instead of per-trigger map
   updates, the responder diffs programs, links and records against
   the kernel state, so a schema rebuild is refilled by the pass that
   caused it and a zone switching to ebpf serves its running guests
 - the guest node is poked through a node endpoint, not all nodes
 - the tap plug goes through a hook of the dhcp plugin base
 - the bridge-change paths of guests push their record changes too
 - the records are collected under the macdb lock
 - a v6 resolver on a v4 subnet is left out instead of failing the
   pass, a subnet without a gateway is skipped with a warning
 - dnsmasq honours dhcp-lease-time as well
 - the mapping push endpoint checks the vnet belongs to the zone


[1] https://lore.proxmox.com/pve-devel/20260904090458.990888-1-h.laimer@proxmox.com/T/#t 
[2] https://lore.proxmox.com/pve-devel/20260902125357.757029-1-h.laimer@proxmox.com/T/#u


proxmox-ebpf:

Hannes Laimer (2):
  dhcp: add per-tap responder BPF program
  dhcp: add responder subsystem

 Cargo.toml              |   5 +
 debian/control          |   6 +-
 src/dhcp/bpf/dhcp.bpf.c | 324 +++++++++++++++++++
 src/dhcp/bpf/types.h    |  25 ++
 src/dhcp/mod.rs         | 247 +++++++++++++++
 src/dhcp/types.rs       |  53 ++++
 src/lib.rs              |   3 +
 tests/dhcp.rs           | 668 ++++++++++++++++++++++++++++++++++++++++
 8 files changed, 1330 insertions(+), 1 deletion(-)
 create mode 100644 src/dhcp/bpf/dhcp.bpf.c
 create mode 100644 src/dhcp/bpf/types.h
 create mode 100644 src/dhcp/mod.rs
 create mode 100644 src/dhcp/types.rs
 create mode 100644 tests/dhcp.rs


proxmox-perl-rs:

Hannes Laimer (1):
  pve-rs: sdn: add dhcp responder bindings

 pve-rs/Cargo.toml               |  2 +
 pve-rs/Makefile                 |  1 +
 pve-rs/debian/control           |  2 +
 pve-rs/src/bindings/sdn/dhcp.rs | 81 +++++++++++++++++++++++++++++++++
 pve-rs/src/bindings/sdn/mod.rs  |  1 +
 5 files changed, 87 insertions(+)
 create mode 100644 pve-rs/src/bindings/sdn/dhcp.rs


pve-network:

Hannes Laimer (8):
  sdn: ipam: do not cache negative per-MAC answers, lock the write
  sdn: subnets: add dhcp-lease-time property
  sdn: dhcp: only assert a backend's availability for zones using it
  sdn: dhcp: add ebpf plugin
  sdn: zones: attach the dhcp responder on tap plug
  sdn: dhcp: apply mapping edits on the node serving the guest
  sdn: zones: offer dhcp on all zone types, keep dnsmasq simple-only
  tests: cover the ebpf dhcp backend and ipam API mapping pushes

 src/PVE/API2/Network/SDN/Ips.pm           |   3 +
 src/PVE/API2/Network/SDN/Nodes/Status.pm  |  42 +++-
 src/PVE/API2/Network/SDN/Zones.pm         |   8 +-
 src/PVE/Network/SDN/Dhcp.pm               |  87 +++++++-
 src/PVE/Network/SDN/Dhcp/Dnsmasq.pm       |   3 +-
 src/PVE/Network/SDN/Dhcp/Ebpf.pm          | 187 ++++++++++++++++++
 src/PVE/Network/SDN/Dhcp/Makefile         |   2 +-
 src/PVE/Network/SDN/Dhcp/Plugin.pm        |   6 +
 src/PVE/Network/SDN/Ipams.pm              |  24 ++-
 src/PVE/Network/SDN/SubnetPlugin.pm       |   9 +
 src/PVE/Network/SDN/Zones.pm              |   3 +
 src/PVE/Network/SDN/Zones/EvpnPlugin.pm   |   1 +
 src/PVE/Network/SDN/Zones/FaucetPlugin.pm |   1 +
 src/PVE/Network/SDN/Zones/QinQPlugin.pm   |   7 +
 src/PVE/Network/SDN/Zones/VlanPlugin.pm   |   7 +
 src/PVE/Network/SDN/Zones/VxlanPlugin.pm  |   9 +
 src/test/run_test_vnets_blackbox.pl       | 231 ++++++++++++++++++++++
 17 files changed, 619 insertions(+), 11 deletions(-)
 create mode 100644 src/PVE/Network/SDN/Dhcp/Ebpf.pm


pve-manager:

Hannes Laimer (1):
  ui: sdn: dhcp backend selector on all zones, expose dhcp options

 www/manager6/sdn/SubnetEdit.js       | 24 ++++++++++++++++++++++++
 www/manager6/sdn/zones/Base.js       | 17 +++++++++++++++++
 www/manager6/sdn/zones/SimpleEdit.js | 11 -----------
 3 files changed, 41 insertions(+), 11 deletions(-)


Summary over all repositories:
  33 files changed, 2077 insertions(+), 23 deletions(-)

-- 
Generated by murpp 0.12.0




^ permalink raw reply	[flat|nested] 14+ messages in thread

* [PATCH proxmox-ebpf 01/12] dhcp: add per-tap responder BPF program
  2026-09-04  9:38 [PATCH manager/network/proxmox{-ebpf,-perl-rs} 00/12] sdn: implement DHCP for all zones using eBPF Hannes Laimer
@ 2026-09-04  9:38 ` Hannes Laimer
  2026-09-04  9:38 ` [PATCH proxmox-ebpf 02/12] dhcp: add responder subsystem Hannes Laimer
                   ` (10 subsequent siblings)
  11 siblings, 0 replies; 14+ messages in thread
From: Hannes Laimer @ 2026-09-04  9:38 UTC (permalink / raw)
  To: pve-devel

Attached on tap ingress, the responder rewrites a request from a MAC
with a record into the reply in place and redirects it back out the
tap, so the exchange never reaches the bridge. Everything else passes
untouched, so attaching is harmless on interfaces that end up
unmanaged, and a REQUEST for a stale address is NAKed so edited
records converge within one lease.

Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
 Cargo.toml              |   5 +
 debian/control          |   6 +-
 src/dhcp/bpf/dhcp.bpf.c | 324 +++++++++++++++++++
 src/dhcp/bpf/types.h    |  25 ++
 tests/dhcp.rs           | 668 ++++++++++++++++++++++++++++++++++++++++
 5 files changed, 1027 insertions(+), 1 deletion(-)
 create mode 100644 src/dhcp/bpf/dhcp.bpf.c
 create mode 100644 src/dhcp/bpf/types.h
 create mode 100644 tests/dhcp.rs

diff --git a/Cargo.toml b/Cargo.toml
index 1e8703d..172042e 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -11,6 +11,7 @@ homepage = "https://proxmox.com"
 exclude = [".cargo", ".gitignore", "Makefile", "build", "debian"]
 
 [features]
+dhcp = []
 # When enabled, BPF programs compile with -DBPF_DEBUG so their DBG() macros
 # expand to bpf_printk. Off by default
 bpf-debug = []
@@ -20,3 +21,7 @@ aya = "0.13"
 anyhow = "1"
 log = "0.4"
 nix = { version = "0.29", features = ["fs", "net"] }
+
+[[test]]
+name = "dhcp"
+required-features = ["dhcp"]
diff --git a/debian/control b/debian/control
index c415c8d..545d30e 100644
--- a/debian/control
+++ b/debian/control
@@ -37,14 +37,18 @@ Depends:
 Provides:
  librust-proxmox-ebpf+bpf-debug-dev (= ${binary:Version}),
  librust-proxmox-ebpf+default-dev (= ${binary:Version}),
+ librust-proxmox-ebpf+dhcp-dev (= ${binary:Version}),
  librust-proxmox-ebpf-0-dev (= ${binary:Version}),
  librust-proxmox-ebpf-0+bpf-debug-dev (= ${binary:Version}),
  librust-proxmox-ebpf-0+default-dev (= ${binary:Version}),
+ librust-proxmox-ebpf-0+dhcp-dev (= ${binary:Version}),
  librust-proxmox-ebpf-0.1-dev (= ${binary:Version}),
  librust-proxmox-ebpf-0.1+bpf-debug-dev (= ${binary:Version}),
  librust-proxmox-ebpf-0.1+default-dev (= ${binary:Version}),
+ librust-proxmox-ebpf-0.1+dhcp-dev (= ${binary:Version}),
  librust-proxmox-ebpf-0.1.0-dev (= ${binary:Version}),
  librust-proxmox-ebpf-0.1.0+bpf-debug-dev (= ${binary:Version}),
- librust-proxmox-ebpf-0.1.0+default-dev (= ${binary:Version})
+ librust-proxmox-ebpf-0.1.0+default-dev (= ${binary:Version}),
+ librust-proxmox-ebpf-0.1.0+dhcp-dev (= ${binary:Version})
 Description: Proxmox VE eBPF - Rust source code
  Source code for Debianized Rust crate "proxmox-ebpf"
diff --git a/src/dhcp/bpf/dhcp.bpf.c b/src/dhcp/bpf/dhcp.bpf.c
new file mode 100644
index 0000000..8f9034e
--- /dev/null
+++ b/src/dhcp/bpf/dhcp.bpf.c
@@ -0,0 +1,324 @@
+// Per-tap DHCPv4 responder. Attached on tap ingress, it answers DISCOVER and
+// REQUEST for MACs with a record by rewriting the request into the reply in
+// place and redirecting it back out the same interface. The redirect consumes
+// the request, so nothing upstream on the bridge sees it. Everything else,
+// including requests from MACs without a record, passes untouched.
+
+#include "vmlinux.h"
+#include <bpf/bpf_helpers.h>
+#include <bpf/bpf_endian.h>
+#include "types.h"
+#include "bpf_debug.h"
+
+char LICENSE[] SEC("license") = "GPL";
+
+#define TC_ACT_OK 0
+
+#define ETH_HLEN 14
+#define ETH_P_IP 0x0800
+#define IP_PROTO_UDP 17
+
+#define BOOTP_LEN 236
+// replies are padded up to the minimal BOOTP message size (RFC 1542), some old
+// client stacks drop anything shorter
+#define BOOTP_MIN_LEN 300
+#define DHCP_MAGIC 0x63825363
+#define BOOTP_FLAG_BCAST 0x8000
+
+#define BOOTREQUEST 1
+#define BOOTREPLY 2
+
+#define DHCP_DISCOVER 1
+#define DHCP_OFFER 2
+#define DHCP_REQUEST 3
+#define DHCP_ACK 5
+#define DHCP_NAK 6
+
+#define OPT_PAD 0
+#define OPT_NETMASK 1
+#define OPT_ROUTER 3
+#define OPT_DNS 6
+#define OPT_MTU 26
+#define OPT_REQUESTED_IP 50
+#define OPT_LEASE 51
+#define OPT_MSGTYPE 53
+#define OPT_SERVER_ID 54
+#define OPT_WPAD 252
+#define OPT_END 255
+
+struct {
+    __uint(type, BPF_MAP_TYPE_HASH);
+    __type(key, struct dhcp_mac_key);
+    __type(value, struct dhcp_record);
+    __uint(max_entries, 65536);
+    __uint(map_flags, BPF_F_NO_PREALLOC);
+    __uint(pinning, LIBBPF_PIN_BY_NAME);
+} dhcp_records SEC(".maps");
+
+struct bootp {
+    __u8 op;
+    __u8 htype;
+    __u8 hlen;
+    __u8 hops;
+    __u32 xid;
+    __u16 secs;
+    __u16 flags;
+    __u32 ciaddr;
+    __u32 yiaddr;
+    __u32 siaddr;
+    __u32 giaddr;
+    __u8 chaddr[16];
+    __u8 sname[64];
+    __u8 file[128];
+};
+
+struct reply_pkt {
+    struct iphdr ip;
+    struct udphdr udp;
+    struct bootp bp;
+    __u8 cookie[4];
+    __u8 opts[BOOTP_MIN_LEN - BOOTP_LEN - 4];
+} __attribute__((packed));
+
+// the responder has no address on the tap, replies carry a fixed locally
+// administered source MAC that nothing ever learns
+static const __u8 SRC_MAC[6] = { 0x02, 0x00, 0x00, 0x00, 0x00, 0x01 };
+static const __u8 BCAST_MAC[6] = { 0xff, 0xff, 0xff, 0xff, 0xff, 0xff };
+
+static __always_inline __u16 ip_csum(struct iphdr *ip)
+{
+    __u32 sum = 0;
+    __u16 *p = (__u16 *)ip;
+#pragma unroll
+    for (int i = 0; i < 10; i++)
+        sum += p[i];
+    sum = (sum & 0xffff) + (sum >> 16);
+    sum = (sum & 0xffff) + (sum >> 16);
+    return ~sum;
+}
+
+SEC("classifier")
+int tc_dhcp_ingress(struct __sk_buff *skb)
+{
+    __u32 min_len = ETH_HLEN + sizeof(struct iphdr) + sizeof(struct udphdr) + BOOTP_LEN + 4;
+
+    void *data = (void *)(long)skb->data;
+    void *data_end = (void *)(long)skb->data_end;
+
+    if (data + min_len > data_end) {
+        // request may sit in a non-linear skb, everything shorter is
+        // not a DHCP request anyway
+        if (skb->len < min_len || bpf_skb_pull_data(skb, min_len))
+            return TC_ACT_OK;
+        data = (void *)(long)skb->data;
+        data_end = (void *)(long)skb->data_end;
+        if (data + min_len > data_end)
+            return TC_ACT_OK;
+    }
+
+    struct ethhdr *eth = data;
+    if (eth->h_proto != bpf_htons(ETH_P_IP))
+        return TC_ACT_OK;
+
+    struct iphdr *ip = data + ETH_HLEN;
+    if (ip->version != 4 || ip->ihl != 5 || ip->protocol != IP_PROTO_UDP)
+        return TC_ACT_OK;
+
+    // a fragment is never a whole request, and past the first one the bytes at
+    // the UDP header offset are arbitrary payload
+    if (ip->frag_off & bpf_htons(0x3fff))
+        return TC_ACT_OK;
+
+    struct udphdr *udp = data + ETH_HLEN + sizeof(struct iphdr);
+    if (udp->dest != bpf_htons(67))
+        return TC_ACT_OK;
+
+    struct bootp *req = data + ETH_HLEN + sizeof(struct iphdr) + sizeof(struct udphdr);
+    if (req->op != BOOTREQUEST || req->htype != 1 || req->hlen != 6)
+        return TC_ACT_OK;
+
+    // a reply to a relayed request must go unicast to the relay instead of the
+    // client, leave those to a server that speaks relay
+    if (req->giaddr) {
+        DBG("dhcp: request relayed via %pI4 passed", &req->giaddr);
+        return TC_ACT_OK;
+    }
+
+    __u32 *magic = (void *)req + BOOTP_LEN;
+    if (*magic != bpf_htonl(DHCP_MAGIC))
+        return TC_ACT_OK;
+
+    // clients are identified by chaddr alone, a client identifier option is
+    // deliberately not honored since the records are keyed by MAC (manual
+    // allocation in RFC 2131 terms)
+    struct dhcp_mac_key key = {};
+    __builtin_memcpy(key.addr, req->chaddr, 6);
+    struct dhcp_record *rec = bpf_map_lookup_elem(&dhcp_records, &key);
+    if (!rec) {
+        DBG("dhcp: no record for %02x:%02x:%02x:%02x:%02x:%02x", key.addr[0], key.addr[1],
+            key.addr[2], key.addr[3], key.addr[4], key.addr[5]);
+        return TC_ACT_OK;
+    }
+
+    __u8 msgtype = 0;
+    __u32 requested_ip = 0;
+    __u32 server_id = 0;
+    __u32 off = min_len;
+
+    for (int i = 0; i < 128; i++) {
+        __u8 code, len;
+        if (bpf_skb_load_bytes(skb, off, &code, 1))
+            break;
+        if (code == OPT_END)
+            break;
+        if (code == OPT_PAD) {
+            off += 1;
+            continue;
+        }
+        if (bpf_skb_load_bytes(skb, off + 1, &len, 1))
+            break;
+        if (code == OPT_MSGTYPE && len == 1)
+            bpf_skb_load_bytes(skb, off + 2, &msgtype, 1);
+        else if (code == OPT_REQUESTED_IP && len == 4)
+            bpf_skb_load_bytes(skb, off + 2, &requested_ip, 4);
+        else if (code == OPT_SERVER_ID && len == 4)
+            bpf_skb_load_bytes(skb, off + 2, &server_id, 4);
+        off += 2 + len;
+    }
+
+    __u8 reply;
+    if (msgtype == DHCP_DISCOVER) {
+        reply = DHCP_OFFER;
+    } else if (msgtype == DHCP_REQUEST) {
+        // a REQUEST naming another server declines our offer and is
+        // answered, if at all, by that server
+        if (server_id && server_id != rec->server_id) {
+            DBG("dhcp: request selected server %pI4, passed", &server_id);
+            return TC_ACT_OK;
+        }
+        // a NAK on a stale address makes the client re-DISCOVER, which
+        // is how an edited record converges within one lease
+        __u32 want = requested_ip ? requested_ip : req->ciaddr;
+        reply = want == rec->ip ? DHCP_ACK : DHCP_NAK;
+        if (reply == DHCP_NAK)
+            DBG("dhcp: request for %pI4 against record %pI4, nak", &want, &rec->ip);
+    } else {
+        DBG("dhcp: message type %d passed", msgtype);
+        return TC_ACT_OK;
+    }
+
+    __u32 xid = req->xid;
+    __u16 flags = req->flags;
+    __u32 ciaddr = req->ciaddr;
+
+    struct reply_pkt buf = {};
+
+    buf.bp.op = BOOTREPLY;
+    buf.bp.htype = 1;
+    buf.bp.hlen = 6;
+    buf.bp.xid = xid;
+    buf.bp.flags = flags;
+    __builtin_memcpy(buf.bp.chaddr, key.addr, 6);
+
+    buf.cookie[0] = 0x63;
+    buf.cookie[1] = 0x82;
+    buf.cookie[2] = 0x53;
+    buf.cookie[3] = 0x63;
+
+    __u32 o = 0;
+    buf.opts[o++] = OPT_MSGTYPE;
+    buf.opts[o++] = 1;
+    buf.opts[o++] = reply;
+    buf.opts[o++] = OPT_SERVER_ID;
+    buf.opts[o++] = 4;
+    __builtin_memcpy(&buf.opts[o], &rec->server_id, 4);
+    o += 4;
+
+    if (reply != DHCP_NAK) {
+        buf.bp.yiaddr = rec->ip;
+
+        buf.opts[o++] = OPT_LEASE;
+        buf.opts[o++] = 4;
+        __builtin_memcpy(&buf.opts[o], &rec->lease, 4);
+        o += 4;
+        buf.opts[o++] = OPT_NETMASK;
+        buf.opts[o++] = 4;
+        __builtin_memcpy(&buf.opts[o], &rec->netmask, 4);
+        o += 4;
+        if (rec->router) {
+            buf.opts[o++] = OPT_ROUTER;
+            buf.opts[o++] = 4;
+            __builtin_memcpy(&buf.opts[o], &rec->router, 4);
+            o += 4;
+        }
+        if (rec->dns) {
+            buf.opts[o++] = OPT_DNS;
+            buf.opts[o++] = 4;
+            __builtin_memcpy(&buf.opts[o], &rec->dns, 4);
+            o += 4;
+        }
+        if (rec->mtu) {
+            buf.opts[o++] = OPT_MTU;
+            buf.opts[o++] = 2;
+            __builtin_memcpy(&buf.opts[o], &rec->mtu, 2);
+            o += 2;
+        }
+        // an empty WPAD answer, without it some windows versions
+        // probe for a proxy config indefinitely
+        buf.opts[o++] = OPT_WPAD;
+        buf.opts[o++] = 1;
+        buf.opts[o++] = '\n';
+    }
+    buf.opts[o++] = OPT_END;
+
+    // the zeroed tail of opts pads short replies out to the minimum, on the
+    // wire those zeros read as pad options after the end option
+    __u32 bootp_out = BOOTP_LEN + 4 + o;
+    if (bootp_out < BOOTP_MIN_LEN)
+        bootp_out = BOOTP_MIN_LEN;
+
+    __u32 payload = sizeof(struct udphdr) + bootp_out;
+
+    buf.ip.version = 4;
+    buf.ip.ihl = 5;
+    buf.ip.ttl = 64;
+    buf.ip.protocol = IP_PROTO_UDP;
+    buf.ip.tot_len = bpf_htons(sizeof(struct iphdr) + payload);
+    buf.ip.saddr = rec->server_id;
+    // a renewing client has an address and gets a unicast answer,
+    // everything else cannot be addressed yet
+    buf.ip.daddr = (reply != DHCP_NAK && ciaddr) ? ciaddr : 0xffffffff;
+    buf.ip.check = ip_csum(&buf.ip);
+
+    buf.udp.source = bpf_htons(67);
+    buf.udp.dest = bpf_htons(68);
+    buf.udp.len = bpf_htons(payload);
+    buf.udp.check = 0; // optional for IPv4
+
+    __u32 wlen = sizeof(struct iphdr) + payload;
+    if (wlen > sizeof(buf))
+        return TC_ACT_OK;
+
+    if (bpf_skb_change_tail(skb, ETH_HLEN + wlen, 0))
+        return TC_ACT_OK;
+
+    // NAKs are always broadcast, and a client without an address that set the
+    // BROADCAST flag asked for a broadcast reply
+    __u8 eth_hdr[ETH_HLEN];
+    if (reply == DHCP_NAK || (!ciaddr && (flags & bpf_htons(BOOTP_FLAG_BCAST)))) {
+        __builtin_memcpy(eth_hdr, BCAST_MAC, 6);
+    } else {
+        __builtin_memcpy(eth_hdr, key.addr, 6);
+    }
+    __builtin_memcpy(eth_hdr + 6, SRC_MAC, 6);
+    eth_hdr[12] = 0x08;
+    eth_hdr[13] = 0x00;
+
+    if (bpf_skb_store_bytes(skb, 0, eth_hdr, ETH_HLEN, 0))
+        return TC_ACT_OK;
+    if (bpf_skb_store_bytes(skb, ETH_HLEN, &buf, wlen, 0))
+        return TC_ACT_OK;
+
+    DBG("dhcp: reply type %d with %pI4 to ifindex %d", reply, &buf.bp.yiaddr, skb->ifindex);
+    return bpf_redirect(skb->ifindex, 0);
+}
diff --git a/src/dhcp/bpf/types.h b/src/dhcp/bpf/types.h
new file mode 100644
index 0000000..84f1564
--- /dev/null
+++ b/src/dhcp/bpf/types.h
@@ -0,0 +1,25 @@
+#ifndef PROXMOX_EBPF_DHCP_TYPES_H
+#define PROXMOX_EBPF_DHCP_TYPES_H
+
+// Keep in sync with ../types.rs
+// __u8/__u16/__u32 come from vmlinux.h
+
+struct dhcp_mac_key {
+    __u8 addr[6];
+    __u8 _pad[2];
+};
+
+// addresses, lease and mtu are stored in network byte order, so a
+// reply copies them into the packet as-is
+struct dhcp_record {
+    __u32 ip;
+    __u32 netmask;
+    __u32 router; // 0 = not served
+    __u32 dns; // 0 = not served
+    __u32 server_id;
+    __u32 lease;
+    __u16 mtu; // 0 = not served
+    __u8 _pad[2];
+};
+
+#endif
diff --git a/tests/dhcp.rs b/tests/dhcp.rs
new file mode 100644
index 0000000..13a9ecd
--- /dev/null
+++ b/tests/dhcp.rs
@@ -0,0 +1,668 @@
+//! The dhcp responder program run natively: crafted requests in, the rewritten reply out.
+
+mod common;
+
+use std::ffi::{c_int, c_void};
+
+use common::*;
+
+unsafe extern "C" {
+    fn tc_dhcp_ingress(skb: *mut SkBuff) -> c_int;
+    static dhcp_records: u8;
+}
+
+const MAC: [u8; 6] = [0xda, 0x65, 0x8f, 0x18, 0x9b, 0x6f];
+const SRC_MAC: [u8; 6] = [0x02, 0x00, 0x00, 0x00, 0x00, 0x01];
+const BCAST: [u8; 6] = [0xff; 6];
+const IFINDEX: u32 = 42;
+
+const IP: [u8; 4] = [10, 0, 0, 100];
+const MASK: [u8; 4] = [255, 255, 255, 0];
+const GW: [u8; 4] = [10, 0, 0, 1];
+const DNS: [u8; 4] = [1, 1, 1, 1];
+const LEASE: u32 = 300;
+const MTU: u16 = 1500;
+
+const OPTS_OFF: usize = 14 + 20 + 8 + 236 + 4;
+
+const DISCOVER: &[u8] = &[53, 1, 1];
+const REQUEST: &[u8] = &[53, 1, 3];
+
+fn records_map() -> *const c_void {
+    (unsafe { &dhcp_records } as *const u8).cast()
+}
+
+fn key(mac: [u8; 6]) -> [u8; 8] {
+    let mut k = [0u8; 8];
+    k[..6].copy_from_slice(&mac);
+    k
+}
+
+fn record(
+    ip: [u8; 4],
+    mask: [u8; 4],
+    router: [u8; 4],
+    dns: [u8; 4],
+    server: [u8; 4],
+    lease: u32,
+    mtu: u16,
+) -> [u8; 28] {
+    let mut r = [0u8; 28];
+    r[0..4].copy_from_slice(&ip);
+    r[4..8].copy_from_slice(&mask);
+    r[8..12].copy_from_slice(&router);
+    r[12..16].copy_from_slice(&dns);
+    r[16..20].copy_from_slice(&server);
+    r[20..24].copy_from_slice(&lease.to_be_bytes());
+    r[24..26].copy_from_slice(&mtu.to_be_bytes());
+    r
+}
+
+fn setup_full_record() {
+    register_map(records_map(), 8);
+    map_insert(
+        records_map(),
+        &key(MAC),
+        &record(IP, MASK, GW, DNS, GW, LEASE, MTU),
+    );
+}
+
+struct Request {
+    mac: [u8; 6],
+    xid: [u8; 4],
+    flags: [u8; 2],
+    ciaddr: [u8; 4],
+    giaddr: [u8; 4],
+    options: Vec<u8>,
+    dport: u16,
+    ethertype: [u8; 2],
+    ipproto: u8,
+    frag_off: [u8; 2],
+    ip_options: Vec<u8>,
+    op: u8,
+    htype: u8,
+    hlen: u8,
+}
+
+impl Default for Request {
+    fn default() -> Self {
+        Request {
+            mac: MAC,
+            xid: [0xde, 0xad, 0xbe, 0xef],
+            flags: [0, 0],
+            ciaddr: [0; 4],
+            giaddr: [0; 4],
+            options: DISCOVER.to_vec(),
+            dport: 67,
+            ethertype: [0x08, 0x00],
+            ipproto: 17,
+            frag_off: [0, 0],
+            ip_options: Vec::new(),
+            op: 1,
+            htype: 1,
+            hlen: 6,
+        }
+    }
+}
+
+impl Request {
+    fn build(&self) -> Vec<u8> {
+        assert_eq!(self.ip_options.len() % 4, 0);
+        let ihl = 5 + self.ip_options.len() / 4;
+        let payload = 8 + 236 + 4 + self.options.len() + 1;
+        let mut p = Vec::new();
+        // ethernet
+        p.extend_from_slice(&BCAST);
+        p.extend_from_slice(&self.mac);
+        p.extend_from_slice(&self.ethertype);
+        // ipv4
+        p.extend_from_slice(&[0x40 | ihl as u8, 0]);
+        p.extend_from_slice(&((ihl * 4 + payload) as u16).to_be_bytes());
+        p.extend_from_slice(&[0, 0]); // id
+        p.extend_from_slice(&self.frag_off);
+        p.extend_from_slice(&[64, self.ipproto, 0, 0]);
+        p.extend_from_slice(&[0, 0, 0, 0]); // saddr
+        p.extend_from_slice(&[255, 255, 255, 255]); // daddr
+        p.extend_from_slice(&self.ip_options);
+        // udp
+        p.extend_from_slice(&68u16.to_be_bytes());
+        p.extend_from_slice(&self.dport.to_be_bytes());
+        p.extend_from_slice(&(payload as u16).to_be_bytes());
+        p.extend_from_slice(&[0, 0]);
+        // bootp
+        p.extend_from_slice(&[self.op, self.htype, self.hlen, 0]);
+        p.extend_from_slice(&self.xid);
+        p.extend_from_slice(&[0, 0]); // secs
+        p.extend_from_slice(&self.flags);
+        p.extend_from_slice(&self.ciaddr);
+        p.extend_from_slice(&[0; 8]); // yiaddr, siaddr
+        p.extend_from_slice(&self.giaddr);
+        p.extend_from_slice(&self.mac);
+        p.extend_from_slice(&[0; 10]); // chaddr padding
+        p.extend_from_slice(&[0; 192]); // sname, file
+        // cookie and options
+        p.extend_from_slice(&[0x63, 0x82, 0x53, 0x63]);
+        p.extend_from_slice(&self.options);
+        p.push(255);
+        p
+    }
+
+    fn run(&self) -> (c_int, TestSkb) {
+        let mut skb = TestSkb::new(&self.build(), IFINDEX);
+        let verdict = skb.run(tc_dhcp_ingress);
+        (verdict, skb)
+    }
+}
+
+fn parse_opts(pkt: &[u8]) -> Vec<(u8, Vec<u8>)> {
+    let mut opts = Vec::new();
+    let mut off = OPTS_OFF;
+    loop {
+        let code = pkt[off];
+        if code == 255 {
+            break;
+        }
+        if code == 0 {
+            off += 1;
+            continue;
+        }
+        let len = pkt[off + 1] as usize;
+        opts.push((code, pkt[off + 2..off + 2 + len].to_vec()));
+        off += 2 + len;
+    }
+    opts
+}
+
+fn ip_csum_ok(pkt: &[u8]) -> bool {
+    let mut sum = 0u32;
+    for w in pkt[14..34].chunks(2) {
+        sum += u32::from(u16::from_be_bytes([w[0], w[1]]));
+    }
+    while sum > 0xffff {
+        sum = (sum & 0xffff) + (sum >> 16);
+    }
+    sum == 0xffff
+}
+
+/// The offset just past the end option of a reply.
+fn opts_end(pkt: &[u8]) -> usize {
+    let mut off = OPTS_OFF;
+    loop {
+        match pkt[off] {
+            255 => return off + 1,
+            0 => off += 1,
+            _ => off += 2 + pkt[off + 1] as usize,
+        }
+    }
+}
+
+/// What every reply has to look like whatever the request was. The addressing and the answer
+/// itself are the caller's to check.
+fn assert_reply_invariants(pkt: &[u8]) {
+    assert_eq!(&pkt[6..12], &SRC_MAC, "eth src");
+    assert_eq!(&pkt[12..14], &[0x08, 0x00], "ethertype");
+
+    assert_eq!(pkt[14], 0x45, "ihl/version");
+    assert_eq!(pkt[22], 64, "ttl");
+    assert_eq!(pkt[23], 17, "protocol");
+    assert_eq!(&pkt[26..30], &GW, "ip src is the server id");
+    assert!(ip_csum_ok(pkt), "ip checksum");
+    let tot_len = u16::from_be_bytes([pkt[16], pkt[17]]) as usize;
+    assert_eq!(tot_len, pkt.len() - 14, "ip total length");
+
+    assert_eq!(&pkt[34..36], &67u16.to_be_bytes(), "udp sport");
+    assert_eq!(&pkt[36..38], &68u16.to_be_bytes(), "udp dport");
+    let udp_len = u16::from_be_bytes([pkt[38], pkt[39]]) as usize;
+    assert_eq!(udp_len, pkt.len() - 34, "udp length");
+    assert_eq!(&pkt[40..42], &[0, 0], "udp checksum left zero");
+    assert!(pkt.len() - 42 >= 300, "bootp minimum message size");
+
+    assert_eq!(&pkt[42..45], &[2, 1, 6], "bootp reply op, htype, hlen");
+    assert_eq!(&pkt[46..50], &[0xde, 0xad, 0xbe, 0xef], "xid preserved");
+    assert_eq!(&pkt[70..76], &MAC, "chaddr");
+    assert_eq!(&pkt[278..282], &[0x63, 0x82, 0x53, 0x63], "cookie");
+    assert!(
+        matches!(parse_opts(pkt).first(), Some((53, t)) if t.len() == 1 && [2, 5, 6].contains(&t[0])),
+        "message type leads the options"
+    );
+    let end = opts_end(pkt);
+    assert!(
+        pkt[end..].iter().all(|&b| b == 0),
+        "padding after the end option is zero"
+    );
+}
+
+fn assert_reply_headers(pkt: &[u8], eth_dst: [u8; 6], ip_dst: [u8; 4], yiaddr: [u8; 4]) {
+    assert_reply_invariants(pkt);
+    assert_eq!(&pkt[0..6], &eth_dst, "eth dst");
+    assert_eq!(&pkt[30..34], &ip_dst, "ip dst");
+    assert_eq!(&pkt[58..62], &yiaddr, "yiaddr");
+}
+
+#[test]
+fn discover_gets_full_offer() {
+    setup_full_record();
+    let (verdict, skb) = Request::default().run();
+
+    assert_eq!(verdict, TC_ACT_REDIRECT);
+    assert_eq!(redirected(), Some(IFINDEX));
+
+    let pkt = skb.packet();
+    assert_reply_headers(pkt, MAC, [255, 255, 255, 255], IP);
+    assert_eq!(
+        parse_opts(pkt),
+        vec![
+            (53, vec![2]),
+            (54, GW.to_vec()),
+            (51, LEASE.to_be_bytes().to_vec()),
+            (1, MASK.to_vec()),
+            (3, GW.to_vec()),
+            (6, DNS.to_vec()),
+            (26, MTU.to_be_bytes().to_vec()),
+            (252, vec![b'\n']),
+        ],
+    );
+}
+
+#[test]
+fn offer_skips_unset_options() {
+    register_map(records_map(), 8);
+    map_insert(
+        records_map(),
+        &key(MAC),
+        &record(IP, MASK, [0; 4], [0; 4], GW, LEASE, 0),
+    );
+    let (verdict, skb) = Request::default().run();
+
+    assert_eq!(verdict, TC_ACT_REDIRECT);
+    let codes: Vec<u8> = parse_opts(skb.packet()).iter().map(|(c, _)| *c).collect();
+    assert_eq!(codes, vec![53, 54, 51, 1, 252]);
+}
+
+#[test]
+fn matching_request_gets_ack() {
+    setup_full_record();
+    let mut options = REQUEST.to_vec();
+    options.extend_from_slice(&[50, 4]);
+    options.extend_from_slice(&IP);
+    let (verdict, skb) = Request {
+        options,
+        ..Request::default()
+    }
+    .run();
+
+    assert_eq!(verdict, TC_ACT_REDIRECT);
+    let pkt = skb.packet();
+    assert_reply_headers(pkt, MAC, [255, 255, 255, 255], IP);
+    assert_eq!(parse_opts(pkt)[0], (53, vec![5]));
+}
+
+#[test]
+fn stale_request_gets_nak() {
+    setup_full_record();
+    let mut options = REQUEST.to_vec();
+    options.extend_from_slice(&[50, 4, 10, 0, 0, 77]);
+    let (verdict, skb) = Request {
+        options,
+        ..Request::default()
+    }
+    .run();
+
+    assert_eq!(verdict, TC_ACT_REDIRECT);
+    let pkt = skb.packet();
+    assert_reply_headers(pkt, BCAST, [255, 255, 255, 255], [0; 4]);
+    assert_eq!(parse_opts(pkt), vec![(53, vec![6]), (54, GW.to_vec())]);
+}
+
+#[test]
+fn renewal_is_answered_unicast() {
+    setup_full_record();
+    let (verdict, skb) = Request {
+        options: REQUEST.to_vec(),
+        ciaddr: IP,
+        ..Request::default()
+    }
+    .run();
+
+    assert_eq!(verdict, TC_ACT_REDIRECT);
+    let pkt = skb.packet();
+    assert_reply_headers(pkt, MAC, IP, IP);
+    assert_eq!(parse_opts(pkt)[0], (53, vec![5]));
+}
+
+#[test]
+fn request_selecting_this_server_gets_ack() {
+    setup_full_record();
+    let mut options = REQUEST.to_vec();
+    options.extend_from_slice(&[54, 4]);
+    options.extend_from_slice(&GW);
+    options.extend_from_slice(&[50, 4]);
+    options.extend_from_slice(&IP);
+    let (verdict, skb) = Request {
+        options,
+        ..Request::default()
+    }
+    .run();
+
+    assert_eq!(verdict, TC_ACT_REDIRECT);
+    assert_eq!(parse_opts(skb.packet())[0], (53, vec![5]));
+}
+
+#[test]
+fn request_selecting_another_server_passes_untouched() {
+    setup_full_record();
+    let mut options = REQUEST.to_vec();
+    options.extend_from_slice(&[54, 4, 192, 168, 7, 7]);
+    options.extend_from_slice(&[50, 4, 10, 0, 0, 77]);
+    let req = Request {
+        options,
+        ..Request::default()
+    };
+    let (verdict, skb) = req.run();
+
+    assert_eq!(verdict, TC_ACT_OK);
+    assert_eq!(redirected(), None);
+    assert_eq!(skb.packet(), &req.build()[..]);
+}
+
+#[test]
+fn relayed_request_passes_untouched() {
+    setup_full_record();
+    let req = Request {
+        giaddr: [10, 0, 0, 254],
+        ..Request::default()
+    };
+    let (verdict, skb) = req.run();
+
+    assert_eq!(verdict, TC_ACT_OK);
+    assert_eq!(redirected(), None);
+    assert_eq!(skb.packet(), &req.build()[..]);
+}
+
+#[test]
+fn fragments_pass_untouched() {
+    setup_full_record();
+    for frag_off in [[0x20, 0x00], [0x00, 0x01]] {
+        let req = Request {
+            frag_off,
+            ..Request::default()
+        };
+        let (verdict, skb) = req.run();
+        assert_eq!(verdict, TC_ACT_OK);
+        assert_eq!(skb.packet(), &req.build()[..]);
+    }
+
+    let (verdict, _) = Request {
+        frag_off: [0x40, 0x00],
+        ..Request::default()
+    }
+    .run();
+    assert_eq!(verdict, TC_ACT_REDIRECT, "DF alone is not a fragment");
+}
+
+#[test]
+fn broadcast_flag_gets_l2_broadcast_reply() {
+    setup_full_record();
+    let (verdict, skb) = Request {
+        flags: [0x80, 0],
+        ..Request::default()
+    }
+    .run();
+
+    assert_eq!(verdict, TC_ACT_REDIRECT);
+    let pkt = skb.packet();
+    assert_reply_headers(pkt, BCAST, [255, 255, 255, 255], IP);
+    assert_eq!(&pkt[52..54], &[0x80, 0], "flags echoed");
+
+    // ciaddr wins over the flag, a renewing client is addressable
+    let (verdict, skb) = Request {
+        flags: [0x80, 0],
+        ciaddr: IP,
+        options: REQUEST.to_vec(),
+        ..Request::default()
+    }
+    .run();
+    assert_eq!(verdict, TC_ACT_REDIRECT);
+    assert_reply_headers(skb.packet(), MAC, IP, IP);
+}
+
+#[test]
+fn unknown_mac_passes_untouched() {
+    register_map(records_map(), 8);
+    let req = Request::default();
+    let (verdict, skb) = req.run();
+
+    assert_eq!(verdict, TC_ACT_OK);
+    assert_eq!(redirected(), None);
+    assert_eq!(skb.packet(), &req.build()[..]);
+}
+
+#[test]
+fn non_dhcp_traffic_passes_untouched() {
+    setup_full_record();
+    let variants = [
+        Request {
+            dport: 53,
+            ..Request::default()
+        },
+        Request {
+            ethertype: [0x86, 0xdd],
+            ..Request::default()
+        },
+        Request {
+            ipproto: 6,
+            ..Request::default()
+        },
+    ];
+    for req in variants {
+        let (verdict, skb) = req.run();
+        assert_eq!(verdict, TC_ACT_OK);
+        assert_eq!(skb.packet(), &req.build()[..]);
+    }
+
+    let frame = Request::default().build();
+    let mut skb = TestSkb::new(&frame[..100], IFINDEX);
+    assert_eq!(skb.run(tc_dhcp_ingress), TC_ACT_OK);
+
+    let mut corrupted = frame.clone();
+    corrupted[278] = 0;
+    let mut skb = TestSkb::new(&corrupted, IFINDEX);
+    assert_eq!(skb.run(tc_dhcp_ingress), TC_ACT_OK);
+}
+
+#[test]
+fn options_walk_handles_padding_and_early_end() {
+    setup_full_record();
+
+    let mut options = vec![0, 0, 0];
+    options.extend_from_slice(DISCOVER);
+    let (verdict, _) = Request {
+        options,
+        ..Request::default()
+    }
+    .run();
+    assert_eq!(verdict, TC_ACT_REDIRECT);
+
+    let mut options = vec![255];
+    options.extend_from_slice(DISCOVER);
+    let (verdict, skb) = Request {
+        options,
+        ..Request::default()
+    }
+    .run();
+    assert_eq!(verdict, TC_ACT_OK, "message type after end is not parsed");
+    assert_eq!(redirected(), None);
+    drop(skb);
+}
+
+#[test]
+fn stale_renewal_gets_broadcast_nak() {
+    setup_full_record();
+    let (verdict, skb) = Request {
+        options: REQUEST.to_vec(),
+        ciaddr: [10, 0, 0, 77],
+        ..Request::default()
+    }
+    .run();
+
+    assert_eq!(verdict, TC_ACT_REDIRECT);
+    let pkt = skb.packet();
+    assert_reply_headers(pkt, BCAST, [255, 255, 255, 255], [0; 4]);
+    assert_eq!(parse_opts(pkt), vec![(53, vec![6]), (54, GW.to_vec())]);
+}
+
+#[test]
+fn request_without_address_gets_nak() {
+    setup_full_record();
+    let (verdict, skb) = Request {
+        options: REQUEST.to_vec(),
+        ..Request::default()
+    }
+    .run();
+
+    assert_eq!(verdict, TC_ACT_REDIRECT);
+    let pkt = skb.packet();
+    assert_reply_headers(pkt, BCAST, [255, 255, 255, 255], [0; 4]);
+    assert_eq!(parse_opts(pkt)[0], (53, vec![6]));
+}
+
+#[test]
+fn realistic_request_is_answered_and_shrunk() {
+    setup_full_record();
+    let mut options = REQUEST.to_vec();
+    // a client identifier naming something else, records are keyed by chaddr alone
+    options.extend_from_slice(&[61, 7, 1, 0xde, 0xad, 0xbe, 0xef, 0x00, 0x01]);
+    options.extend_from_slice(&[50, 4]);
+    options.extend_from_slice(&IP);
+    options.extend_from_slice(&[54, 4]);
+    options.extend_from_slice(&GW);
+    options.extend_from_slice(&[12, 14]);
+    options.extend_from_slice(b"dhcp-client-01");
+    options.extend_from_slice(&[60, 32]);
+    options.extend_from_slice(b"PXEClient:Arch:00007:UNDI:003016");
+    options.extend_from_slice(&[55, 20]);
+    options.extend_from_slice(&[
+        1, 2, 3, 6, 12, 15, 17, 26, 28, 40, 41, 42, 51, 54, 58, 59, 66, 67, 119, 121,
+    ]);
+    options.extend_from_slice(&[57, 2, 0x05, 0xdc]);
+    options.extend_from_slice(&[93, 2, 0, 7]);
+    options.extend_from_slice(&[94, 3, 1, 3, 16]);
+    options.extend_from_slice(&[97, 17, 0]);
+    options.extend_from_slice(&[0x11; 16]);
+    let req = Request {
+        options,
+        ..Request::default()
+    };
+    let request_len = req.build().len();
+    let (verdict, skb) = req.run();
+
+    assert_eq!(verdict, TC_ACT_REDIRECT);
+    let pkt = skb.packet();
+    assert!(pkt.len() < request_len, "reply is shorter than the request");
+    assert_eq!(
+        pkt.len(),
+        14 + 20 + 8 + 300,
+        "trimmed to the padded minimum"
+    );
+    assert_reply_headers(pkt, MAC, [255, 255, 255, 255], IP);
+    assert_eq!(parse_opts(pkt)[0], (53, vec![5]));
+}
+
+#[test]
+fn other_message_types_pass_untouched() {
+    setup_full_record();
+    for msgtype in [4, 7, 8] {
+        let req = Request {
+            options: vec![53, 1, msgtype],
+            ..Request::default()
+        };
+        let (verdict, skb) = req.run();
+        assert_eq!(verdict, TC_ACT_OK, "message type {msgtype}");
+        assert_eq!(redirected(), None);
+        assert_eq!(skb.packet(), &req.build()[..]);
+    }
+}
+
+#[test]
+fn foreign_bootp_passes_untouched() {
+    setup_full_record();
+    let variants = [
+        // a reply coming in from the guest side, a guest running its own server
+        Request {
+            op: 2,
+            ..Request::default()
+        },
+        Request {
+            htype: 6,
+            ..Request::default()
+        },
+        Request {
+            hlen: 16,
+            ..Request::default()
+        },
+        Request {
+            ip_options: vec![1, 1, 1, 1],
+            ..Request::default()
+        },
+    ];
+    for req in variants {
+        let (verdict, skb) = req.run();
+        assert_eq!(verdict, TC_ACT_OK);
+        assert_eq!(redirected(), None);
+        assert_eq!(skb.packet(), &req.build()[..]);
+    }
+}
+
+#[test]
+fn truncated_option_ends_the_walk() {
+    setup_full_record();
+
+    // a length running past the packet before the message type leaves nothing to answer
+    let mut options = vec![12, 200];
+    options.extend_from_slice(DISCOVER);
+    let req = Request {
+        options,
+        ..Request::default()
+    };
+    let (verdict, skb) = req.run();
+    assert_eq!(verdict, TC_ACT_OK);
+    assert_eq!(redirected(), None);
+    assert_eq!(skb.packet(), &req.build()[..]);
+
+    // after the message type it only cuts the walk short, what was read still counts
+    let mut options = DISCOVER.to_vec();
+    options.extend_from_slice(&[12, 200]);
+    let (verdict, skb) = Request {
+        options,
+        ..Request::default()
+    }
+    .run();
+    assert_eq!(verdict, TC_ACT_REDIRECT);
+    assert_reply_headers(skb.packet(), MAC, [255, 255, 255, 255], IP);
+}
+
+#[test]
+fn arbitrary_option_bytes_never_break_the_reply() {
+    setup_full_record();
+    let mut lcg = 0x2545f4914f6cdd1du64;
+    let mut rand = move || {
+        lcg = lcg
+            .wrapping_mul(6364136223846793005)
+            .wrapping_add(1442695040888963407);
+        (lcg >> 33) as u8
+    };
+
+    for _ in 0..500 {
+        let len = (rand() % 60) as usize;
+        let options: Vec<u8> = (0..len).map(|_| rand()).collect();
+        let (verdict, skb) = Request {
+            options,
+            ..Request::default()
+        }
+        .run();
+        assert!(verdict == TC_ACT_OK || verdict == TC_ACT_REDIRECT);
+        if verdict == TC_ACT_REDIRECT {
+            assert_reply_invariants(skb.packet());
+        }
+    }
+}
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 14+ messages in thread

* [PATCH proxmox-ebpf 02/12] dhcp: add responder subsystem
  2026-09-04  9:38 [PATCH manager/network/proxmox{-ebpf,-perl-rs} 00/12] sdn: implement DHCP for all zones using eBPF Hannes Laimer
  2026-09-04  9:38 ` [PATCH proxmox-ebpf 01/12] dhcp: add per-tap responder BPF program Hannes Laimer
@ 2026-09-04  9:38 ` Hannes Laimer
  2026-09-04  9:38 ` [PATCH proxmox-perl-rs 03/12] pve-rs: sdn: add dhcp responder bindings Hannes Laimer
                   ` (9 subsequent siblings)
  11 siblings, 0 replies; 14+ messages in thread
From: Hannes Laimer @ 2026-09-04  9:38 UTC (permalink / raw)
  To: pve-devel

The record map is fed entirely by the consumer, so the subsystem needs
no state source of its own. A single full pass is the only writer of
pinned state, it makes the programs current, attaches them to exactly
the interfaces the consumer names and diffs the map against the full
record set, so the empty state a rebuild leaves behind is refilled in
the same pass. A tap plug only attaches, to whatever consistent pair of
program and map the last pass established.

Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
 src/dhcp/mod.rs   | 247 ++++++++++++++++++++++++++++++++++++++++++++++
 src/dhcp/types.rs |  53 ++++++++++
 src/lib.rs        |   3 +
 3 files changed, 303 insertions(+)
 create mode 100644 src/dhcp/mod.rs
 create mode 100644 src/dhcp/types.rs

diff --git a/src/dhcp/mod.rs b/src/dhcp/mod.rs
new file mode 100644
index 0000000..8daf190
--- /dev/null
+++ b/src/dhcp/mod.rs
@@ -0,0 +1,247 @@
+//! The dhcp subsystem, a per-tap DHCPv4 responder answering from a pinned per-MAC record map.
+//!
+//! The subsystem has no state source of its own, the consumer hands in [`Record`]s, each carrying
+//! everything a reply needs. This side owns only the map and the programs.
+
+mod types;
+
+use std::collections::{HashMap, HashSet};
+use std::net::Ipv4Addr;
+
+use anyhow::{Context, bail};
+use aya::include_bytes_aligned;
+
+use self::types::*;
+use crate::subsystem::TcPrograms;
+use crate::tc::Direction;
+
+/// One MAC's answer, the address and every option the reply carries.
+pub struct Record {
+    pub mac: [u8; 6],
+    pub ip: Ipv4Addr,
+    pub prefixlen: u8,
+    pub server_id: Ipv4Addr,
+    pub lease: u32,
+    pub router: Option<Ipv4Addr>,
+    pub dns: Option<Ipv4Addr>,
+    pub mtu: Option<u16>,
+}
+
+const NAME: &str = "dhcp";
+const RECORDS_MAP: &str = "dhcp_records";
+
+const DHCP_OBJ: &[u8] = include_bytes_aligned!(concat!(env!("OUT_DIR"), "/dhcp.bpf.o"));
+const DHCP_FINGERPRINT: u64 = TcPrograms::obj_fingerprint(DHCP_OBJ);
+
+// BUMP THIS when a semantically-incompatible change to a map definition in dhcp.bpf.c is made
+const SCHEMA_VERSION: u32 = 1;
+
+// requests only arrive from the guest side and replies leave through a redirect, so only ingress
+// carries a program
+const DIRECTIONS: [Direction; 1] = [Direction::Ingress];
+
+fn program_name(dir: Direction) -> &'static str {
+    match dir {
+        Direction::Ingress => "tc_dhcp_ingress",
+        Direction::Egress => unreachable!("dhcp is ingress-only"),
+    }
+}
+
+pub struct DhcpSubsystem {
+    programs: TcPrograms,
+}
+
+impl DhcpSubsystem {
+    pub fn new() -> Self {
+        Self {
+            programs: TcPrograms::new(
+                NAME,
+                DHCP_OBJ,
+                DHCP_FINGERPRINT,
+                program_name,
+                &DIRECTIONS,
+                SCHEMA_VERSION,
+            ),
+        }
+    }
+
+    /// The full pass and the only writer of pinned state. Make the programs current, attach them
+    /// to exactly the given interfaces and make the record map hold exactly the given records.
+    /// Both are diffed against the kernel state, so this converges from any starting point, the
+    /// empty state a rebuild on a schema change leaves behind included.
+    pub fn apply(&mut self, ifaces: &[&str], records: &[Record]) -> anyhow::Result<()> {
+        let desired = map_entries(records)?;
+        let lock = self.programs.lock_exclusive()?;
+        lock.ensure_loaded()?;
+
+        let mut map = lock.hash_map::<DhcpMacKey, DhcpRecord>(RECORDS_MAP)?;
+        let live: HashMap<DhcpMacKey, DhcpRecord> = map.iter().filter_map(|r| r.ok()).collect();
+        let mut written = 0usize;
+        for (key, rec) in &desired {
+            if live.get(key) != Some(rec) {
+                map.insert(key, rec, 0)?;
+                written += 1;
+            }
+        }
+        let stale: Vec<_> = live
+            .keys()
+            .filter(|k| !desired.contains_key(k))
+            .copied()
+            .collect();
+        for key in &stale {
+            let _ = map.remove(key);
+        }
+        if written + stale.len() > 0 {
+            log::info!("dhcp: {written} records written, {} removed", stale.len());
+        } else {
+            log::debug!("dhcp: {} records, no changes", desired.len());
+        }
+
+        let mut attached = HashSet::new();
+        for iface in ifaces {
+            match nix::net::if_::if_nametoindex(*iface) {
+                Ok(ifindex) => {
+                    attached.insert(ifindex);
+                }
+                // gone between the caller's enumeration and here
+                Err(e) => log::info!("dhcp apply: {iface}: {e}, skipping"),
+            }
+        }
+        lock.reconcile(&attached)
+    }
+
+    /// Detach all responder programs and drop pinned/run state, for package removal or the last
+    /// zone leaving the backend.
+    pub fn clear(&self) -> anyhow::Result<()> {
+        self.programs.clear()
+    }
+
+    /// Attach the responder to one guest interface (a tap plug). A link operation on the pinned
+    /// programs only, it never loads anything. Whatever a full pass pinned is a program and a map
+    /// consistent with each other, so at worst a stale pair serves until the next pass.
+    pub fn attach(&mut self, iface: &str) -> anyhow::Result<()> {
+        let Ok(ifindex) = nix::net::if_::if_nametoindex(iface) else {
+            log::info!("dhcp attach: {iface} is gone, nothing to do");
+            return Ok(());
+        };
+        let lock = self.programs.lock_shared()?;
+        lock.attach_iface(ifindex)
+            .with_context(|| format!("dhcp responder for {iface}"))
+    }
+}
+
+fn map_entries(records: &[Record]) -> anyhow::Result<HashMap<DhcpMacKey, DhcpRecord>> {
+    records
+        .iter()
+        .map(|r| {
+            r.map_entry()
+                .with_context(|| format!("record for {}", fmt_mac(&r.mac)))
+        })
+        .collect()
+}
+
+impl Record {
+    fn map_entry(&self) -> anyhow::Result<(DhcpMacKey, DhcpRecord)> {
+        if self.prefixlen > 32 {
+            bail!("prefixlen {} out of range", self.prefixlen);
+        }
+        let netmask = if self.prefixlen == 0 {
+            0
+        } else {
+            u32::MAX << (32 - self.prefixlen)
+        };
+        Ok((
+            DhcpMacKey {
+                addr: self.mac,
+                _pad: [0; 2],
+            },
+            DhcpRecord {
+                ip: u32::from(self.ip).to_be(),
+                netmask: netmask.to_be(),
+                router: u32::from(self.router.unwrap_or(Ipv4Addr::UNSPECIFIED)).to_be(),
+                dns: u32::from(self.dns.unwrap_or(Ipv4Addr::UNSPECIFIED)).to_be(),
+                server_id: u32::from(self.server_id).to_be(),
+                lease: self.lease.to_be(),
+                mtu: self.mtu.unwrap_or(0).to_be(),
+                _pad: [0; 2],
+            },
+        ))
+    }
+}
+
+fn fmt_mac(addr: &[u8; 6]) -> String {
+    format!(
+        "{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
+        addr[0], addr[1], addr[2], addr[3], addr[4], addr[5]
+    )
+}
+
+#[cfg(test)]
+mod test {
+    use super::*;
+
+    fn record() -> Record {
+        Record {
+            mac: [0xda, 0x65, 0x8f, 0x18, 0x9b, 0x6f],
+            ip: Ipv4Addr::new(10, 0, 0, 100),
+            prefixlen: 24,
+            server_id: Ipv4Addr::new(10, 0, 0, 1),
+            lease: 300,
+            router: Some(Ipv4Addr::new(10, 0, 0, 1)),
+            dns: Some(Ipv4Addr::new(1, 1, 1, 1)),
+            mtu: Some(1500),
+        }
+    }
+
+    #[test]
+    fn converts_full_record() {
+        let (key, rec) = record().map_entry().unwrap();
+        assert_eq!(key.addr, [0xda, 0x65, 0x8f, 0x18, 0x9b, 0x6f]);
+        assert_eq!(
+            u32::from_be(rec.ip),
+            u32::from(Ipv4Addr::new(10, 0, 0, 100))
+        );
+        assert_eq!(u32::from_be(rec.netmask), 0xffffff00);
+        assert_eq!(
+            u32::from_be(rec.router),
+            u32::from(Ipv4Addr::new(10, 0, 0, 1))
+        );
+        assert_eq!(u32::from_be(rec.dns), u32::from(Ipv4Addr::new(1, 1, 1, 1)));
+        assert_eq!(
+            u32::from_be(rec.server_id),
+            u32::from(Ipv4Addr::new(10, 0, 0, 1))
+        );
+        assert_eq!(u32::from_be(rec.lease), 300);
+        assert_eq!(u16::from_be(rec.mtu), 1500);
+    }
+
+    #[test]
+    fn absent_options_read_as_zero() {
+        let rec = Record {
+            prefixlen: 32,
+            router: None,
+            dns: None,
+            mtu: None,
+            ..record()
+        };
+        let (_, rec) = rec.map_entry().unwrap();
+        assert_eq!(rec.router, 0);
+        assert_eq!(rec.dns, 0);
+        assert_eq!(rec.mtu, 0);
+        assert_eq!(u32::from_be(rec.netmask), 0xffffffff);
+    }
+
+    #[test]
+    fn rejects_out_of_range_prefixlen() {
+        let rec = Record {
+            prefixlen: 33,
+            ..record()
+        };
+        assert!(rec.map_entry().is_err());
+        let rec = Record {
+            prefixlen: 0,
+            ..record()
+        };
+        assert_eq!(u32::from_be(rec.map_entry().unwrap().1.netmask), 0);
+    }
+}
diff --git a/src/dhcp/types.rs b/src/dhcp/types.rs
new file mode 100644
index 0000000..38ea36b
--- /dev/null
+++ b/src/dhcp/types.rs
@@ -0,0 +1,53 @@
+//! Keep in sync with 'bpf/types.h'.
+
+#[repr(C)]
+#[derive(Copy, Clone, Hash, PartialEq, Eq)]
+pub struct DhcpMacKey {
+    pub addr: [u8; 6],
+    pub _pad: [u8; 2],
+}
+
+/// Addresses, lease and mtu are stored in network byte order, so a reply copies them into the
+/// packet as-is.
+#[repr(C)]
+#[derive(Copy, Clone, PartialEq)]
+pub struct DhcpRecord {
+    pub ip: u32,
+    pub netmask: u32,
+    /// 0 = not served
+    pub router: u32,
+    /// 0 = not served
+    pub dns: u32,
+    pub server_id: u32,
+    pub lease: u32,
+    /// 0 = not served
+    pub mtu: u16,
+    pub _pad: [u8; 2],
+}
+
+unsafe impl aya::Pod for DhcpMacKey {}
+unsafe impl aya::Pod for DhcpRecord {}
+
+#[cfg(test)]
+mod layout {
+    use core::mem::{offset_of, size_of};
+
+    use super::*;
+
+    #[test]
+    fn matches_bpf_abi() {
+        assert_eq!(size_of::<DhcpMacKey>(), 8);
+        assert_eq!(offset_of!(DhcpMacKey, addr), 0);
+        assert_eq!(offset_of!(DhcpMacKey, _pad), 6);
+
+        assert_eq!(size_of::<DhcpRecord>(), 28);
+        assert_eq!(offset_of!(DhcpRecord, ip), 0);
+        assert_eq!(offset_of!(DhcpRecord, netmask), 4);
+        assert_eq!(offset_of!(DhcpRecord, router), 8);
+        assert_eq!(offset_of!(DhcpRecord, dns), 12);
+        assert_eq!(offset_of!(DhcpRecord, server_id), 16);
+        assert_eq!(offset_of!(DhcpRecord, lease), 20);
+        assert_eq!(offset_of!(DhcpRecord, mtu), 24);
+        assert_eq!(offset_of!(DhcpRecord, _pad), 26);
+    }
+}
diff --git a/src/lib.rs b/src/lib.rs
index 572d861..a8c94ba 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,5 +1,8 @@
 //! eBPF subsystems, one per concern, each behind a cargo feature of its name. Nothing here runs
 //! on its own, consumers pull in only the subsystem they drive.
 
+#[cfg(feature = "dhcp")]
+pub mod dhcp;
+
 pub mod subsystem;
 pub mod tc;
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 14+ messages in thread

* [PATCH proxmox-perl-rs 03/12] pve-rs: sdn: add dhcp responder bindings
  2026-09-04  9:38 [PATCH manager/network/proxmox{-ebpf,-perl-rs} 00/12] sdn: implement DHCP for all zones using eBPF Hannes Laimer
  2026-09-04  9:38 ` [PATCH proxmox-ebpf 01/12] dhcp: add per-tap responder BPF program Hannes Laimer
  2026-09-04  9:38 ` [PATCH proxmox-ebpf 02/12] dhcp: add responder subsystem Hannes Laimer
@ 2026-09-04  9:38 ` Hannes Laimer
  2026-09-04  9:38 ` [PATCH pve-network 04/12] sdn: ipam: do not cache negative per-MAC answers, lock the write Hannes Laimer
                   ` (8 subsequent siblings)
  11 siblings, 0 replies; 14+ messages in thread
From: Hannes Laimer @ 2026-09-04  9:38 UTC (permalink / raw)
  To: pve-devel

The consumer hands the responder subsystem of proxmox-ebpf its full
state on every trigger, the interfaces to serve and all records, and
the subsystem diffs that against the kernel state.

Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
 pve-rs/Cargo.toml               |  2 +
 pve-rs/Makefile                 |  1 +
 pve-rs/debian/control           |  2 +
 pve-rs/src/bindings/sdn/dhcp.rs | 81 +++++++++++++++++++++++++++++++++
 pve-rs/src/bindings/sdn/mod.rs  |  1 +
 5 files changed, 87 insertions(+)
 create mode 100644 pve-rs/src/bindings/sdn/dhcp.rs

diff --git a/pve-rs/Cargo.toml b/pve-rs/Cargo.toml
index 5ae9082..0adf00b 100644
--- a/pve-rs/Cargo.toml
+++ b/pve-rs/Cargo.toml
@@ -34,6 +34,7 @@ proxmox-apt = { version = "1.0.1", features = ["cache"] }
 proxmox-apt-api-types = "3"
 proxmox-base64 = "1"
 proxmox-config-digest = "1"
+proxmox-ebpf = { version = "0.1", features = ["dhcp"] }
 proxmox-frr = { version = "0.5.1" }
 proxmox-http = { version = "1.0.2", features = ["client-sync", "client-trait"] }
 proxmox-http-error = "1"
@@ -70,6 +71,7 @@ proxmox-wireguard = { version = "0.1.2" }
 # proxmox-config-digest = { path = "../../proxmox/proxmox-config-digest" }
 # proxmox-daemon = { path = "../../proxmox/proxmox-daemon" }
 # proxmox-dns-api = { path = "../../proxmox/proxmox-dns-api" }
+# proxmox-ebpf = { path = "../../proxmox-ebpf" }
 # proxmox-http-error = { path = "../../proxmox/proxmox-http-error" }
 # proxmox-http = { path = "../../proxmox/proxmox-http" }
 # proxmox-human-byte = { path = "../../proxmox/proxmox-human-byte" }
diff --git a/pve-rs/Makefile b/pve-rs/Makefile
index bb1cd2d..7609a1e 100644
--- a/pve-rs/Makefile
+++ b/pve-rs/Makefile
@@ -32,6 +32,7 @@ PERLMOD_PACKAGES := \
 	  PVE::RS::OpenId \
 	  PVE::RS::ResourceScheduling::Static \
 	  PVE::RS::ResourceScheduling::Dynamic \
+	  PVE::RS::SDN::Dhcp \
 	  PVE::RS::SDN::Fabrics \
 	  PVE::RS::SDN::PrefixLists \
 	  PVE::RS::SDN::RouteMaps \
diff --git a/pve-rs/debian/control b/pve-rs/debian/control
index 70367dc..521d966 100644
--- a/pve-rs/debian/control
+++ b/pve-rs/debian/control
@@ -20,6 +20,8 @@ Build-Depends: debhelper-compat (= 13),
                librust-proxmox-apt-api-types-3+default-dev (>= 3.0.0-~~),
                librust-proxmox-base64-1+default-dev,
                librust-proxmox-config-digest-1+default-dev,
+               librust-proxmox-ebpf-0.1+default-dev,
+               librust-proxmox-ebpf-0.1+dhcp-dev,
                librust-proxmox-frr-0.5+default-dev (>= 0.5.1-~~),
                librust-proxmox-http-1+client-sync-dev (>= 1.0.2-~~),
                librust-proxmox-http-1+client-trait-dev (>= 1.0.2-~~),
diff --git a/pve-rs/src/bindings/sdn/dhcp.rs b/pve-rs/src/bindings/sdn/dhcp.rs
new file mode 100644
index 0000000..510e115
--- /dev/null
+++ b/pve-rs/src/bindings/sdn/dhcp.rs
@@ -0,0 +1,81 @@
+#[perlmod::package(name = "PVE::RS::SDN::Dhcp", lib = "pve_rs")]
+pub mod pve_rs_sdn_dhcp {
+    //! The `PVE::RS::SDN::Dhcp` package.
+    //!
+    //! Bindings for the per-tap eBPF DHCP responder. Records handed in here are
+    //! answered directly from the kernel.
+
+    use std::net::Ipv4Addr;
+
+    use anyhow::{Context, Error, bail};
+    use serde::Deserialize;
+
+    use proxmox_ebpf::dhcp::{DhcpSubsystem, Record};
+
+    /// One MAC's DHCP answer, the address and every option the reply carries.
+    #[derive(Deserialize)]
+    pub struct DhcpRecord {
+        mac: String,
+        ip: Ipv4Addr,
+        prefixlen: u8,
+        server_id: Ipv4Addr,
+        lease: u32,
+        router: Option<Ipv4Addr>,
+        dns: Option<Ipv4Addr>,
+        mtu: Option<u16>,
+    }
+
+    fn parse_mac(s: &str) -> Result<[u8; 6], Error> {
+        let mut addr = [0u8; 6];
+        let mut n = 0;
+        for part in s.split(':') {
+            if n >= 6 || part.len() != 2 {
+                bail!("invalid MAC {s:?}");
+            }
+            addr[n] = u8::from_str_radix(part, 16).with_context(|| format!("invalid MAC {s:?}"))?;
+            n += 1;
+        }
+        if n != 6 {
+            bail!("invalid MAC {s:?}");
+        }
+        Ok(addr)
+    }
+
+    fn to_records(records: Vec<DhcpRecord>) -> Result<Vec<Record>, Error> {
+        records
+            .into_iter()
+            .map(|r| {
+                Ok(Record {
+                    mac: parse_mac(&r.mac)?,
+                    ip: r.ip,
+                    prefixlen: r.prefixlen,
+                    server_id: r.server_id,
+                    lease: r.lease,
+                    router: r.router,
+                    dns: r.dns,
+                    mtu: r.mtu,
+                })
+            })
+            .collect()
+    }
+
+    /// The full pass. Make the responder programs current, attach them to exactly the given
+    /// interfaces and make the record map hold exactly the given records.
+    #[export]
+    pub fn apply(ifaces: Vec<String>, records: Vec<DhcpRecord>) -> Result<(), Error> {
+        let ifaces: Vec<&str> = ifaces.iter().map(String::as_str).collect();
+        DhcpSubsystem::new().apply(&ifaces, &to_records(records)?)
+    }
+
+    /// Attach the responder to a guest interface.
+    #[export]
+    pub fn attach(iface: &str) -> Result<(), Error> {
+        DhcpSubsystem::new().attach(iface)
+    }
+
+    /// Detach the responder everywhere and drop its pinned state.
+    #[export]
+    pub fn clear() -> Result<(), Error> {
+        DhcpSubsystem::new().clear()
+    }
+}
diff --git a/pve-rs/src/bindings/sdn/mod.rs b/pve-rs/src/bindings/sdn/mod.rs
index dcae046..4b99b8f 100644
--- a/pve-rs/src/bindings/sdn/mod.rs
+++ b/pve-rs/src/bindings/sdn/mod.rs
@@ -1,3 +1,4 @@
+pub(crate) mod dhcp;
 pub(crate) mod fabrics;
 pub(crate) mod prefix_lists;
 pub(crate) mod route_maps;
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 14+ messages in thread

* [PATCH pve-network 04/12] sdn: ipam: do not cache negative per-MAC answers, lock the write
  2026-09-04  9:38 [PATCH manager/network/proxmox{-ebpf,-perl-rs} 00/12] sdn: implement DHCP for all zones using eBPF Hannes Laimer
                   ` (2 preceding siblings ...)
  2026-09-04  9:38 ` [PATCH proxmox-perl-rs 03/12] pve-rs: sdn: add dhcp responder bindings Hannes Laimer
@ 2026-09-04  9:38 ` Hannes Laimer
  2026-09-04  9:38 ` [PATCH pve-network 05/12] sdn: subnets: add dhcp-lease-time property Hannes Laimer
                   ` (7 subsequent siblings)
  11 siblings, 0 replies; 14+ messages in thread
From: Hannes Laimer @ 2026-09-04  9:38 UTC (permalink / raw)
  To: pve-devel

The lookup cache wrote an entry even when the plugin returned
nothing, and that entry short-circuits every later lookup, so a
record created after the first miss was never seen again. The
read-modify-write also ran without the cluster lock the other cache
writers take, allowing concurrent lookups to drop each other's
entries.

Only cache actual answers and take the lock for the write, keeping
the common cache-hit path lock-free.

Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
 src/PVE/Network/SDN/Ipams.pm | 20 ++++++++++++++++----
 1 file changed, 16 insertions(+), 4 deletions(-)

diff --git a/src/PVE/Network/SDN/Ipams.pm b/src/PVE/Network/SDN/Ipams.pm
index 179bdf7..9292386 100644
--- a/src/PVE/Network/SDN/Ipams.pm
+++ b/src/PVE/Network/SDN/Ipams.pm
@@ -140,12 +140,24 @@ sub get_ips_from_mac {
 
     my $plugin_config = get_plugin_config($zone);
     my $plugin = PVE::Network::SDN::Ipams::Plugin->lookup($plugin_config->{type});
-    ($macdb->{macs}->{$mac}->{ip4}, $macdb->{macs}->{$mac}->{ip6}) =
-        $plugin->get_ips_from_mac($plugin_config, $mac, $zoneid);
+    my ($ip4, $ip6) = $plugin->get_ips_from_mac($plugin_config, $mac, $zoneid);
 
-    write_macdb($macdb);
+    # an empty answer is not cached, the record may simply not exist yet
+    return if !defined($ip4) && !defined($ip6);
 
-    return ($macdb->{macs}->{$mac}->{ip4}, $macdb->{macs}->{$mac}->{ip6});
+    cfs_lock_file(
+        $macdb_filename,
+        undef,
+        sub {
+            my $db = read_macdb();
+            $db->{macs}->{$mac}->{ip4} = $ip4 if defined($ip4);
+            $db->{macs}->{$mac}->{ip6} = $ip6 if defined($ip6);
+            write_macdb($db);
+        },
+    );
+    warn "$@" if $@;
+
+    return ($ip4, $ip6);
 }
 
 1;
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 14+ messages in thread

* [PATCH pve-network 05/12] sdn: subnets: add dhcp-lease-time property
  2026-09-04  9:38 [PATCH manager/network/proxmox{-ebpf,-perl-rs} 00/12] sdn: implement DHCP for all zones using eBPF Hannes Laimer
                   ` (3 preceding siblings ...)
  2026-09-04  9:38 ` [PATCH pve-network 04/12] sdn: ipam: do not cache negative per-MAC answers, lock the write Hannes Laimer
@ 2026-09-04  9:38 ` Hannes Laimer
  2026-09-04  9:38 ` [PATCH pve-network 06/12] sdn: dhcp: only assert a backend's availability for zones using it Hannes Laimer
                   ` (6 subsequent siblings)
  11 siblings, 0 replies; 14+ messages in thread
From: Hannes Laimer @ 2026-09-04  9:38 UTC (permalink / raw)
  To: pve-devel

Lease time is the convergence knob for mapping edits: a client picks
up a changed record at the latest one lease after the edit. Make it
configurable per subnet for dhcp backends that honor it.

Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
 src/PVE/Network/SDN/Dhcp/Dnsmasq.pm | 3 ++-
 src/PVE/Network/SDN/SubnetPlugin.pm | 9 +++++++++
 2 files changed, 11 insertions(+), 1 deletion(-)

diff --git a/src/PVE/Network/SDN/Dhcp/Dnsmasq.pm b/src/PVE/Network/SDN/Dhcp/Dnsmasq.pm
index 4677330..861a3ed 100644
--- a/src/PVE/Network/SDN/Dhcp/Dnsmasq.pm
+++ b/src/PVE/Network/SDN/Dhcp/Dnsmasq.pm
@@ -185,7 +185,8 @@ sub configure_subnet {
         $mask = join('.', unpack("C4", pack("N", $mask)));
     }
 
-    push @{$config}, "dhcp-range=set:$tag,$network,static,$mask,infinite";
+    my $lease = $subnet_config->{'dhcp-lease-time'} // 'infinite';
+    push @{$config}, "dhcp-range=set:$tag,$network,static,$mask,$lease";
 
     my $option_string;
     if (ip_is_ipv6($subnet_config->{network})) {
diff --git a/src/PVE/Network/SDN/SubnetPlugin.pm b/src/PVE/Network/SDN/SubnetPlugin.pm
index e2a0e50..c362c78 100644
--- a/src/PVE/Network/SDN/SubnetPlugin.pm
+++ b/src/PVE/Network/SDN/SubnetPlugin.pm
@@ -177,6 +177,14 @@ sub properties {
             description => 'IP address for the DNS server',
             optional => 1,
         },
+        'dhcp-lease-time' => {
+            type => 'integer',
+            minimum => 60,
+            description =>
+                'Lease time in seconds for DHCP answers. Without it dnsmasq hands out'
+                . ' infinite leases and the ebpf responder ten minutes.',
+            optional => 1,
+        },
     };
 }
 
@@ -189,6 +197,7 @@ sub options {
         dnszoneprefix => { optional => 1 },
         'dhcp-range' => { optional => 1 },
         'dhcp-dns-server' => { optional => 1 },
+        'dhcp-lease-time' => { optional => 1 },
     };
 }
 
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 14+ messages in thread

* [PATCH pve-network 06/12] sdn: dhcp: only assert a backend's availability for zones using it
  2026-09-04  9:38 [PATCH manager/network/proxmox{-ebpf,-perl-rs} 00/12] sdn: implement DHCP for all zones using eBPF Hannes Laimer
                   ` (4 preceding siblings ...)
  2026-09-04  9:38 ` [PATCH pve-network 05/12] sdn: subnets: add dhcp-lease-time property Hannes Laimer
@ 2026-09-04  9:38 ` Hannes Laimer
  2026-09-04  9:38 ` [PATCH pve-network 07/12] sdn: dhcp: add ebpf plugin Hannes Laimer
                   ` (5 subsequent siblings)
  11 siblings, 0 replies; 14+ messages in thread
From: Hannes Laimer @ 2026-09-04  9:38 UTC (permalink / raw)
  To: pve-devel

before_regenerate got a single any-zone-needs-dhcp flag, so any zone
with dhcp configured made every registered backend assert its own
availability, and a failing assert aborts the whole regenerate. With
one backend that was the same thing, with a second one it breaks
network reloads on nodes that only run the other backend. Gate each
backend on the zones actually using it.

Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
 src/PVE/Network/SDN/Dhcp.pm | 9 ++++++---
 1 file changed, 6 insertions(+), 3 deletions(-)

diff --git a/src/PVE/Network/SDN/Dhcp.pm b/src/PVE/Network/SDN/Dhcp.pm
index a9459b4..ec4898a 100644
--- a/src/PVE/Network/SDN/Dhcp.pm
+++ b/src/PVE/Network/SDN/Dhcp.pm
@@ -90,11 +90,14 @@ sub regenerate_config {
 
     my $plugins = PVE::Network::SDN::Dhcp::Plugin->lookup_types();
 
-    my $any_zone_needs_dhcp = grep { $_->{dhcp} } values $zone_cfg->{ids}->%*;
+    my %plugin_needed = ();
+    for my $zone (values $zone_cfg->{ids}->%*) {
+        $plugin_needed{ $zone->{dhcp} } = 1 if $zone->{dhcp};
+    }
 
     foreach my $plugin_name (@$plugins) {
         my $plugin = PVE::Network::SDN::Dhcp::Plugin->lookup($plugin_name);
-        eval { $plugin->before_regenerate(!$any_zone_needs_dhcp) };
+        eval { $plugin->before_regenerate(!$plugin_needed{$plugin_name}) };
         die "Could not run before_regenerate for DHCP plugin $plugin_name $@\n" if $@;
     }
 
@@ -141,7 +144,7 @@ sub regenerate_config {
             warn "Could not configure vnet $vnetid: $@\n" if $@;
         }
 
-        eval { $dhcp_plugin->after_configure($zoneid, !$any_zone_needs_dhcp) };
+        eval { $dhcp_plugin->after_configure($zoneid, !$plugin_needed{$dhcp_plugin_name}) };
         warn "Could not run after_configure for DHCP server $zoneid $@\n" if $@;
 
     }
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 14+ messages in thread

* [PATCH pve-network 07/12] sdn: dhcp: add ebpf plugin
  2026-09-04  9:38 [PATCH manager/network/proxmox{-ebpf,-perl-rs} 00/12] sdn: implement DHCP for all zones using eBPF Hannes Laimer
                   ` (5 preceding siblings ...)
  2026-09-04  9:38 ` [PATCH pve-network 06/12] sdn: dhcp: only assert a backend's availability for zones using it Hannes Laimer
@ 2026-09-04  9:38 ` Hannes Laimer
  2026-09-04  9:38 ` [PATCH pve-network 08/12] sdn: zones: attach the dhcp responder on tap plug Hannes Laimer
                   ` (4 subsequent siblings)
  11 siblings, 0 replies; 14+ messages in thread
From: Hannes Laimer @ 2026-09-04  9:38 UTC (permalink / raw)
  To: pve-devel

A dhcp backend that programs the proxmox-ebpf per-tap DHCP responder
instead of driving a dnsmasq instance, selectable per zone with
dhcp=ebpf. Answers come from the same per-MAC records dnsmasq serves
reservations from, handed in-process to the responder through the
pve-rs bindings as complete records, so each mapping push and the
full regenerate sync are self-contained.

Guests get answers without a DHCP daemon per zone and, once records
are pushed, independent of IPAM reachability. Subnets without a
gateway are skipped, the responder identifies itself with the
gateway address. IPv4 only.

Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
 src/PVE/API2/Network/SDN/Zones.pm  |   2 +-
 src/PVE/Network/SDN/Dhcp.pm        |  13 ++
 src/PVE/Network/SDN/Dhcp/Ebpf.pm   | 187 +++++++++++++++++++++++++++++
 src/PVE/Network/SDN/Dhcp/Makefile  |   2 +-
 src/PVE/Network/SDN/Dhcp/Plugin.pm |   6 +
 src/PVE/Network/SDN/Ipams.pm       |   4 +
 6 files changed, 212 insertions(+), 2 deletions(-)
 create mode 100644 src/PVE/Network/SDN/Dhcp/Ebpf.pm

diff --git a/src/PVE/API2/Network/SDN/Zones.pm b/src/PVE/API2/Network/SDN/Zones.pm
index b897cbd..ad16bef 100644
--- a/src/PVE/API2/Network/SDN/Zones.pm
+++ b/src/PVE/API2/Network/SDN/Zones.pm
@@ -90,7 +90,7 @@ my $ZONE_PROPERTIES = {
     },
     dhcp => {
         type => 'string',
-        enum => ['dnsmasq'],
+        enum => ['dnsmasq', 'ebpf'],
         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 ec4898a..45e9a94 100644
--- a/src/PVE/Network/SDN/Dhcp.pm
+++ b/src/PVE/Network/SDN/Dhcp.pm
@@ -10,6 +10,7 @@ use PVE::Network::SDN::Ipams;
 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;
 
@@ -18,6 +19,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();
 }
@@ -76,6 +80,15 @@ sub update_mapping {
     warn "could not update dhcp mapping for $mac: $@" if $@;
 }
 
+sub tap_plug {
+    my ($zoneid, $zone, $iface) = @_;
+
+    return if !$zone->{dhcp};
+
+    my $dhcp_plugin = PVE::Network::SDN::Dhcp::Plugin->lookup($zone->{dhcp});
+    $dhcp_plugin->tap_plug($zoneid, $iface);
+}
+
 sub regenerate_config {
     my ($reload) = @_;
 
diff --git a/src/PVE/Network/SDN/Dhcp/Ebpf.pm b/src/PVE/Network/SDN/Dhcp/Ebpf.pm
new file mode 100644
index 0000000..673c36e
--- /dev/null
+++ b/src/PVE/Network/SDN/Dhcp/Ebpf.pm
@@ -0,0 +1,187 @@
+package PVE::Network::SDN::Dhcp::Ebpf;
+
+use strict;
+use warnings;
+
+use base qw(PVE::Network::SDN::Dhcp::Plugin);
+
+use Net::IP qw(:PROC);
+use Net::Subnet qw(subnet_matcher);
+
+use PVE::Cluster qw(cfs_lock_file);
+use PVE::RESTEnvironment qw(log_warn);
+use PVE::Tools;
+
+use PVE::RS::SDN::Dhcp;
+
+my $DEFAULT_LEASE_TIME = 600;
+
+sub type {
+    return 'ebpf';
+}
+
+# The responder identifies itself with the subnet gateway, a subnet
+# without one cannot be served.
+my sub dhcp_record {
+    my ($mac, $ip4, $subnet, $mtu) = @_;
+
+    my $gateway = $subnet->{gateway};
+    return undef if !$gateway;
+
+    # a resolver has to be reachable over the address family served
+    my $dns = $subnet->{'dhcp-dns-server'};
+    $dns = undef if defined($dns) && !Net::IP::ip_is_ipv4($dns);
+
+    # the config hands its numbers over as strings, the bindings take integers only
+    return {
+        mac => $mac,
+        ip => $ip4,
+        prefixlen => int($subnet->{mask}),
+        server_id => $gateway,
+        lease => int($subnet->{'dhcp-lease-time'} // $DEFAULT_LEASE_TIME),
+        router => $gateway,
+        dns => $dns,
+        mtu => defined($mtu) ? int($mtu) : undef,
+    };
+}
+
+# the guest interfaces plugged into a vnet bridge. Behind a firewall bridge
+# the port is the fwpr side of the veth pair, the guest interface carries
+# the same ids.
+my sub guest_ifaces {
+    my ($bridge) = @_;
+
+    my $ifaces = [];
+    PVE::Tools::dir_glob_foreach(
+        "/sys/class/net/$bridge/brif",
+        '(?:tap|veth)\d+i\d+|fwpr(\d+)p(\d+)',
+        sub {
+            my ($port, $vmid, $netid) = @_;
+            if (!defined($vmid)) {
+                push @$ifaces, $port;
+                return;
+            }
+            for my $prefix (qw(tap veth)) {
+                my $iface = "$prefix${vmid}i$netid";
+                push @$ifaces, $iface if -d "/sys/class/net/$iface";
+            }
+        },
+    );
+
+    return $ifaces;
+}
+
+# The complete desired state of this node's responder, the records of
+# every ebpf zone and every guest interface currently plugged into their
+# vnets, from the running config and the macdb.
+my sub full_state {
+    my $cfg = PVE::Network::SDN::running_config();
+    my $macdb = PVE::Network::SDN::Ipams::read_macdb();
+
+    my ($zones, $ifaces, $records) = (0, [], []);
+    for my $zoneid (sort keys %{ $cfg->{zones}->{ids} // {} }) {
+        my $zone = $cfg->{zones}->{ids}->{$zoneid};
+        next if ($zone->{dhcp} // '') ne 'ebpf';
+        $zones++;
+        my $mtu = PVE::Network::SDN::Zones::get_mtu($zone);
+
+        for my $vnetid (sort keys %{ $cfg->{vnets}->{ids} // {} }) {
+            next if $cfg->{vnets}->{ids}->{$vnetid}->{zone} ne $zoneid;
+            push @$ifaces, @{ guest_ifaces($vnetid) };
+
+            my $subnets = PVE::Network::SDN::Vnets::get_subnets($vnetid, 1) // {};
+            for my $subnetid (sort keys %$subnets) {
+                my $subnet = $subnets->{$subnetid};
+                next if !Net::IP::ip_is_ipv4($subnet->{network});
+                if (!$subnet->{gateway}) {
+                    log_warn("subnet $subnetid has no gateway, not serving DHCP for it");
+                    next;
+                }
+                log_warn("subnet $subnetid has an IPv6 DNS server, not handing it out over IPv4")
+                    if defined($subnet->{'dhcp-dns-server'})
+                    && !Net::IP::ip_is_ipv4($subnet->{'dhcp-dns-server'});
+                my $matcher = subnet_matcher($subnet->{cidr});
+                for my $mac (sort keys %{ $macdb->{macs} }) {
+                    my $ip4 = $macdb->{macs}->{$mac}->{ip4};
+                    next if !$ip4 || !$matcher->($ip4);
+                    # the vnet's own gateway address is cached too and never a lease
+                    next if $ip4 eq $subnet->{gateway};
+                    push @$records, dhcp_record($mac, $ip4, $subnet, $mtu);
+                }
+            }
+        }
+    }
+
+    return ($zones, $ifaces, $records);
+}
+
+# Every trigger is the same full pass, the responder diffs the state
+# against the kernel. Once no zone uses the backend anymore the state
+# is torn down instead. The macdb is read under its lock, so a record
+# a concurrent guest start writes either lands in this pass or the
+# guest's own pass runs after this one swept.
+my sub full_pass {
+    my ($zones, $ifaces, $records);
+    cfs_lock_file(
+        PVE::Network::SDN::Ipams::macdb_filename(),
+        undef,
+        sub { ($zones, $ifaces, $records) = full_state(); },
+    );
+    if (my $err = $@) {
+        log_warn("could not collect the DHCP responder state: $err");
+        return;
+    }
+
+    if (!$zones) {
+        eval { PVE::RS::SDN::Dhcp::clear() };
+        log_warn("could not clear the DHCP responder: $@") if $@;
+        return;
+    }
+
+    eval { PVE::RS::SDN::Dhcp::apply($ifaces, $records) };
+    log_warn("could not apply the DHCP responder state: $@") if $@;
+}
+
+sub add_ip_mapping {
+    my ($class, $dhcpid, $macdb, $mac, $ip4, $ip6) = @_;
+
+    full_pass();
+}
+
+sub del_ip_mapping {
+    my ($class, $dhcpid, $mac) = @_;
+
+    full_pass();
+}
+
+sub update_ip_mapping {
+    my ($class, $dhcpid, $macdb, $mac, $ip4, $ip6) = @_;
+
+    full_pass();
+}
+
+# the dispatcher's per-zone walk is not needed, the full pass collects
+# the state itself
+sub before_regenerate { }
+sub before_configure { }
+sub configure_subnet { }
+sub configure_range { }
+sub configure_vnet { }
+sub after_configure { }
+
+sub after_regenerate {
+    my ($class) = @_;
+
+    full_pass();
+}
+
+# attaches the responder program to the plugged guest interface. Best
+# effort, a guest start must not fail on it.
+sub tap_plug {
+    my ($class, $dhcpid, $iface) = @_;
+
+    eval { PVE::RS::SDN::Dhcp::attach($iface) };
+    log_warn("could not attach DHCP responder to $iface: $@") if $@;
+}
+
+1;
diff --git a/src/PVE/Network/SDN/Dhcp/Makefile b/src/PVE/Network/SDN/Dhcp/Makefile
index 6546513..ce86aae 100644
--- a/src/PVE/Network/SDN/Dhcp/Makefile
+++ b/src/PVE/Network/SDN/Dhcp/Makefile
@@ -1,4 +1,4 @@
-SOURCES=Plugin.pm Dnsmasq.pm
+SOURCES=Plugin.pm Dnsmasq.pm Ebpf.pm
 
 
 PERL5DIR=${DESTDIR}/usr/share/perl5
diff --git a/src/PVE/Network/SDN/Dhcp/Plugin.pm b/src/PVE/Network/SDN/Dhcp/Plugin.pm
index 659c938..cac5388 100644
--- a/src/PVE/Network/SDN/Dhcp/Plugin.pm
+++ b/src/PVE/Network/SDN/Dhcp/Plugin.pm
@@ -75,4 +75,10 @@ sub after_regenerate {
     die 'implement in sub class';
 }
 
+# a guest interface was plugged into a vnet of a zone using this backend,
+# nothing to do for backends serving the bridge rather than the interface
+sub tap_plug {
+    my ($class, $dhcpid, $iface) = @_;
+}
+
 1;
diff --git a/src/PVE/Network/SDN/Ipams.pm b/src/PVE/Network/SDN/Ipams.pm
index 9292386..09858a7 100644
--- a/src/PVE/Network/SDN/Ipams.pm
+++ b/src/PVE/Network/SDN/Ipams.pm
@@ -35,6 +35,10 @@ sub json_writer {
     return encode_json($data);
 }
 
+sub macdb_filename {
+    return $macdb_filename;
+}
+
 sub read_macdb {
     my () = @_;
 
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 14+ messages in thread

* [PATCH pve-network 08/12] sdn: zones: attach the dhcp responder on tap plug
  2026-09-04  9:38 [PATCH manager/network/proxmox{-ebpf,-perl-rs} 00/12] sdn: implement DHCP for all zones using eBPF Hannes Laimer
                   ` (6 preceding siblings ...)
  2026-09-04  9:38 ` [PATCH pve-network 07/12] sdn: dhcp: add ebpf plugin Hannes Laimer
@ 2026-09-04  9:38 ` Hannes Laimer
  2026-09-04  9:38 ` [PATCH pve-network 09/12] sdn: dhcp: apply mapping edits on the node serving the guest Hannes Laimer
                   ` (3 subsequent siblings)
  11 siblings, 0 replies; 14+ messages in thread
From: Hannes Laimer @ 2026-09-04  9:38 UTC (permalink / raw)
  To: pve-devel

Program attach is per guest interface and the plug is the one moment
every such interface passes through on this node, covering guest
start, hotplug and incoming migration. Attach failures only warn, a
guest start must not depend on the responder.

Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
 src/PVE/Network/SDN/Zones.pm | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/src/PVE/Network/SDN/Zones.pm b/src/PVE/Network/SDN/Zones.pm
index f668303..622edb1 100644
--- a/src/PVE/Network/SDN/Zones.pm
+++ b/src/PVE/Network/SDN/Zones.pm
@@ -344,6 +344,9 @@ sub tap_plug {
 
     my $plugin = PVE::Network::SDN::Zones::Plugin->lookup($plugin_config->{type});
     $plugin->tap_plug($plugin_config, $vnet, $tag, $iface, $bridge, $firewall, $trunks, $rate);
+
+    # loaded through Vnets, a use here would close a load-order cycle
+    PVE::Network::SDN::Dhcp::tap_plug($vnet, $plugin_config, $iface);
 }
 
 sub add_bridge_fdb {
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 14+ messages in thread

* [PATCH pve-network 09/12] sdn: dhcp: apply mapping edits on the node serving the guest
  2026-09-04  9:38 [PATCH manager/network/proxmox{-ebpf,-perl-rs} 00/12] sdn: implement DHCP for all zones using eBPF Hannes Laimer
                   ` (7 preceding siblings ...)
  2026-09-04  9:38 ` [PATCH pve-network 08/12] sdn: zones: attach the dhcp responder on tap plug Hannes Laimer
@ 2026-09-04  9:38 ` Hannes Laimer
  2026-09-04  9:38 ` [PATCH pve-network 10/12] sdn: zones: offer dhcp on all zone types, keep dnsmasq simple-only Hannes Laimer
                   ` (2 subsequent siblings)
  11 siblings, 0 replies; 14+ messages in thread
From: Hannes Laimer @ 2026-09-04  9:38 UTC (permalink / raw)
  To: pve-devel

Mapping edits change cluster-wide records, but the ebpf backend
answers from a per-node map, so an edit made through another node's
API left the map of the node running the guest stale until the next
apply or guest start there. Resolve the MAC to its guest and re-apply
the mapping on that node through a node-scoped call, the same
remove-and-add the editing node runs locally. No other node needs the
record, each picks it up at that guest's next start there, so the
cost of an edit stays one call however large the cluster is.

The push runs detached from the edit request, an unreachable node
cannot hold the edit up, it just catches up on its next apply or the
guest's next start.

Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
 src/PVE/API2/Network/SDN/Ips.pm          |  3 ++
 src/PVE/API2/Network/SDN/Nodes/Status.pm | 42 ++++++++++++++-
 src/PVE/Network/SDN/Dhcp.pm              | 65 ++++++++++++++++++++++++
 3 files changed, 109 insertions(+), 1 deletion(-)

diff --git a/src/PVE/API2/Network/SDN/Ips.pm b/src/PVE/API2/Network/SDN/Ips.pm
index d7b682d..5b45de7 100644
--- a/src/PVE/API2/Network/SDN/Ips.pm
+++ b/src/PVE/API2/Network/SDN/Ips.pm
@@ -47,6 +47,7 @@ __PACKAGE__->register_method({
         die "$@\n" if $@;
 
         PVE::Network::SDN::Dhcp::update_mapping($vnet, $mac);
+        PVE::Network::SDN::Dhcp::notify_guest_node($vnet, $mac);
 
         return undef;
     },
@@ -85,6 +86,7 @@ __PACKAGE__->register_method({
         PVE::Network::SDN::Vnets::add_ip($vnet, $ip, '', $mac, undef);
 
         PVE::Network::SDN::Dhcp::update_mapping($vnet, $mac);
+        PVE::Network::SDN::Dhcp::notify_guest_node($vnet, $mac);
 
         return undef;
     },
@@ -138,6 +140,7 @@ __PACKAGE__->register_method({
         die "$error\n" if $error;
 
         PVE::Network::SDN::Dhcp::update_mapping($vnet, $mac);
+        PVE::Network::SDN::Dhcp::notify_guest_node($vnet, $mac);
 
         return undef;
     },
diff --git a/src/PVE/API2/Network/SDN/Nodes/Status.pm b/src/PVE/API2/Network/SDN/Nodes/Status.pm
index 7977e0c..7054b9d 100644
--- a/src/PVE/API2/Network/SDN/Nodes/Status.pm
+++ b/src/PVE/API2/Network/SDN/Nodes/Status.pm
@@ -9,6 +9,9 @@ use PVE::API2::Network::SDN::Nodes::Vnets;
 
 use PVE::JSONSchema qw(get_standard_option);
 
+use PVE::Network::SDN::Dhcp;
+use PVE::Network::SDN::Vnets;
+
 use PVE::RESTHandler;
 use base qw(PVE::RESTHandler);
 
@@ -27,6 +30,40 @@ __PACKAGE__->register_method({
     path => 'vnets',
 });
 
+__PACKAGE__->register_method({
+    name => 'dhcp_mapping',
+    path => 'dhcp-mapping',
+    method => 'POST',
+    description =>
+        'Re-apply the DHCP mapping of a MAC address on this node from the current records.',
+    permissions => {
+        check => ['perm', '/sdn/zones/{zone}/{vnet}', ['SDN.Allocate']],
+    },
+    protected => 1,
+    proxyto => 'node',
+    parameters => {
+        additionalProperties => 0,
+        properties => {
+            node => get_standard_option('pve-node'),
+            zone => get_standard_option('pve-sdn-zone-id'),
+            vnet => get_standard_option('pve-sdn-vnet-id'),
+            mac => get_standard_option('mac-addr'),
+        },
+    },
+    returns => { type => 'null' },
+    code => sub {
+        my ($param) = @_;
+
+        my $vnet = PVE::Network::SDN::Vnets::get_vnet($param->{vnet}, 1);
+        die "vnet '$param->{vnet}' does not exist in zone '$param->{zone}'\n"
+            if !$vnet || $vnet->{zone} ne $param->{zone};
+
+        PVE::Network::SDN::Dhcp::update_mapping($param->{vnet}, $param->{mac});
+
+        return undef;
+    },
+});
+
 __PACKAGE__->register_method({
     name => 'sdnindex',
     path => '',
@@ -52,7 +89,10 @@ __PACKAGE__->register_method({
         my ($param) = @_;
 
         my $result = [
-            { name => 'fabrics' }, { name => 'vnets' }, { name => 'zones' },
+            { name => 'dhcp-mapping' },
+            { name => 'fabrics' },
+            { name => 'vnets' },
+            { name => 'zones' },
         ];
         return $result;
     },
diff --git a/src/PVE/Network/SDN/Dhcp.pm b/src/PVE/Network/SDN/Dhcp.pm
index 45e9a94..413e7cd 100644
--- a/src/PVE/Network/SDN/Dhcp.pm
+++ b/src/PVE/Network/SDN/Dhcp.pm
@@ -3,6 +3,8 @@ package PVE::Network::SDN::Dhcp;
 use strict;
 use warnings;
 
+use POSIX qw();
+
 use PVE::Cluster;
 
 use PVE::Network::SDN;
@@ -80,6 +82,69 @@ sub update_mapping {
     warn "could not update dhcp mapping for $mac: $@" if $@;
 }
 
+# the guest config lines come from pmxcfs in one go, no guest config
+# gets parsed for this
+my sub guest_node_by_mac {
+    my ($mac) = @_;
+
+    my $vmlist = PVE::Cluster::get_vmlist();
+    my $nets = PVE::Cluster::get_guest_config_properties([map { "net$_" } 0 .. 31]);
+    for my $vmid (keys %$nets) {
+        for my $net (values %{ $nets->{$vmid} }) {
+            return $vmlist->{ids}->{$vmid}->{node} if $net =~ m/=\Q$mac\E(?:,|$)/i;
+        }
+    }
+
+    return undef;
+}
+
+# The records are cluster-wide, but a node answers from its own map,
+# so an edit has to reach the node running the guest behind the MAC.
+# Every other node picks the record up at that guest's next start
+# there. The push runs detached from the request, an unreachable node
+# must not hold the edit up.
+sub notify_guest_node {
+    my ($vnetid, $mac) = @_;
+
+    my $vnet = PVE::Network::SDN::Vnets::get_vnet($vnetid, 1);
+    return if !$vnet;
+
+    my $zone = PVE::Network::SDN::Zones::get_zone($vnet->{zone}, 1);
+    return if !$zone || !$zone->{dhcp};
+
+    my $node = guest_node_by_mac($mac);
+    return if !$node || $node eq PVE::INotify::nodename();
+
+    # double fork, the grandchild is reparented to init so nothing on
+    # the request path ever waits on it
+    my $pid = fork();
+    if (!defined($pid)) {
+        warn "could not fork for the dhcp mapping push to $node: $!\n";
+        return;
+    }
+    if ($pid) {
+        waitpid($pid, 0);
+        return;
+    }
+
+    POSIX::setsid();
+    my $child = fork();
+    POSIX::_exit(1) if !defined($child);
+    POSIX::_exit(0) if $child;
+
+    exec(
+        'pvesh',
+        'create',
+        "/nodes/$node/sdn/dhcp-mapping",
+        '--zone',
+        $vnet->{zone},
+        '--vnet',
+        $vnetid,
+        '--mac',
+        $mac,
+    ) or POSIX::_exit(1);
+}
+
 sub tap_plug {
     my ($zoneid, $zone, $iface) = @_;
 
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 14+ messages in thread

* [PATCH pve-network 10/12] sdn: zones: offer dhcp on all zone types, keep dnsmasq simple-only
  2026-09-04  9:38 [PATCH manager/network/proxmox{-ebpf,-perl-rs} 00/12] sdn: implement DHCP for all zones using eBPF Hannes Laimer
                   ` (8 preceding siblings ...)
  2026-09-04  9:38 ` [PATCH pve-network 09/12] sdn: dhcp: apply mapping edits on the node serving the guest Hannes Laimer
@ 2026-09-04  9:38 ` Hannes Laimer
  2026-09-04  9:38 ` [PATCH pve-network 11/12] tests: cover the ebpf dhcp backend and ipam API mapping pushes Hannes Laimer
  2026-09-04  9:38 ` [PATCH pve-manager 12/12] ui: sdn: dhcp backend selector on all zones, expose dhcp options Hannes Laimer
  11 siblings, 0 replies; 14+ messages in thread
From: Hannes Laimer @ 2026-09-04  9:38 UTC (permalink / raw)
  To: pve-devel

The dnsmasq backend is confined to simple zones because it is a
node-local process on the vnet bridge, on a zone spanning nodes
every node's instance would answer the shared broadcast domain. The
ebpf backend answers on the guest's own tap and has no such
restriction, so the dhcp property moves from a simple-zone option to
a common one, with dnsmasq rejected on other zone types.

The backends ask the zone for the guest-facing MTU to serve, so the
zone types gaining dhcp implement get_mtu, with vxlan derived zones
accounting for the encapsulation overhead.

Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
 src/PVE/API2/Network/SDN/Zones.pm         | 6 ++++++
 src/PVE/Network/SDN/Zones/EvpnPlugin.pm   | 1 +
 src/PVE/Network/SDN/Zones/FaucetPlugin.pm | 1 +
 src/PVE/Network/SDN/Zones/QinQPlugin.pm   | 7 +++++++
 src/PVE/Network/SDN/Zones/VlanPlugin.pm   | 7 +++++++
 src/PVE/Network/SDN/Zones/VxlanPlugin.pm  | 9 +++++++++
 6 files changed, 31 insertions(+)

diff --git a/src/PVE/API2/Network/SDN/Zones.pm b/src/PVE/API2/Network/SDN/Zones.pm
index ad16bef..b39c0c9 100644
--- a/src/PVE/API2/Network/SDN/Zones.pm
+++ b/src/PVE/API2/Network/SDN/Zones.pm
@@ -447,6 +447,9 @@ __PACKAGE__->register_method({
                 raise_param_exc({ ipam => "$ipam not existing" })
                     if $ipam && !$ipam_cfg->{ids}->{$ipam};
 
+                raise_param_exc({ dhcp => "the dnsmasq backend only supports simple zones" })
+                    if ($opts->{dhcp} // '') eq 'dnsmasq' && $plugin->type() ne 'simple';
+
                 $zone_cfg->{ids}->{$id} = $opts;
                 $plugin->on_update_hook($id, $zone_cfg, $controller_cfg);
 
@@ -543,6 +546,9 @@ __PACKAGE__->register_method({
                 raise_param_exc({ ipam => "$ipam not existing" })
                     if $ipam && !$ipam_cfg->{ids}->{$ipam};
 
+                raise_param_exc({ dhcp => "the dnsmasq backend only supports simple zones" })
+                    if ($opts->{dhcp} // '') eq 'dnsmasq' && $plugin->type() ne 'simple';
+
                 $plugin->on_update_hook($id, $zone_cfg, $controller_cfg);
 
                 PVE::Network::SDN::Zones::write_config($zone_cfg);
diff --git a/src/PVE/Network/SDN/Zones/EvpnPlugin.pm b/src/PVE/Network/SDN/Zones/EvpnPlugin.pm
index 0e79707..7f1fd90 100644
--- a/src/PVE/Network/SDN/Zones/EvpnPlugin.pm
+++ b/src/PVE/Network/SDN/Zones/EvpnPlugin.pm
@@ -125,6 +125,7 @@ sub options {
         reversedns => { optional => 1 },
         dnszone => { optional => 1 },
         ipam => { optional => 1 },
+        dhcp => { optional => 1 },
     };
 }
 
diff --git a/src/PVE/Network/SDN/Zones/FaucetPlugin.pm b/src/PVE/Network/SDN/Zones/FaucetPlugin.pm
index 5f069ae..4e3f5ef 100644
--- a/src/PVE/Network/SDN/Zones/FaucetPlugin.pm
+++ b/src/PVE/Network/SDN/Zones/FaucetPlugin.pm
@@ -30,6 +30,7 @@ sub options {
         reversedns => { optional => 1 },
         dnszone => { optional => 1 },
         ipam => { optional => 1 },
+        dhcp => { optional => 1 },
     };
 }
 
diff --git a/src/PVE/Network/SDN/Zones/QinQPlugin.pm b/src/PVE/Network/SDN/Zones/QinQPlugin.pm
index a75940c..e70912a 100644
--- a/src/PVE/Network/SDN/Zones/QinQPlugin.pm
+++ b/src/PVE/Network/SDN/Zones/QinQPlugin.pm
@@ -36,6 +36,12 @@ sub properties {
     };
 }
 
+sub get_mtu {
+    my ($class, $plugin_config) = @_;
+
+    return $plugin_config->{mtu};
+}
+
 sub options {
     return {
         nodes => { optional => 1 },
@@ -48,6 +54,7 @@ sub options {
         reversedns => { optional => 1 },
         dnszone => { optional => 1 },
         ipam => { optional => 1 },
+        dhcp => { optional => 1 },
     };
 }
 
diff --git a/src/PVE/Network/SDN/Zones/VlanPlugin.pm b/src/PVE/Network/SDN/Zones/VlanPlugin.pm
index 9102b34..4f24b2a 100644
--- a/src/PVE/Network/SDN/Zones/VlanPlugin.pm
+++ b/src/PVE/Network/SDN/Zones/VlanPlugin.pm
@@ -36,6 +36,12 @@ sub properties {
     };
 }
 
+sub get_mtu {
+    my ($class, $plugin_config) = @_;
+
+    return $plugin_config->{mtu};
+}
+
 sub options {
 
     return {
@@ -47,6 +53,7 @@ sub options {
         reversedns => { optional => 1 },
         dnszone => { optional => 1 },
         ipam => { optional => 1 },
+        dhcp => { optional => 1 },
     };
 }
 
diff --git a/src/PVE/Network/SDN/Zones/VxlanPlugin.pm b/src/PVE/Network/SDN/Zones/VxlanPlugin.pm
index a408261..6ae1998 100644
--- a/src/PVE/Network/SDN/Zones/VxlanPlugin.pm
+++ b/src/PVE/Network/SDN/Zones/VxlanPlugin.pm
@@ -47,6 +47,14 @@ sub properties {
     };
 }
 
+# without an explicit zone MTU the vnet bridges default to 1450,
+# leaving room for the vxlan encapsulation
+sub get_mtu {
+    my ($class, $plugin_config) = @_;
+
+    return $plugin_config->{mtu} // 1450;
+}
+
 sub options {
     return {
         nodes => { optional => 1 },
@@ -58,6 +66,7 @@ sub options {
         dnszone => { optional => 1 },
         ipam => { optional => 1 },
         fabric => { optional => 1 },
+        dhcp => { optional => 1 },
     };
 }
 
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 14+ messages in thread

* [PATCH pve-network 11/12] tests: cover the ebpf dhcp backend and ipam API mapping pushes
  2026-09-04  9:38 [PATCH manager/network/proxmox{-ebpf,-perl-rs} 00/12] sdn: implement DHCP for all zones using eBPF Hannes Laimer
                   ` (9 preceding siblings ...)
  2026-09-04  9:38 ` [PATCH pve-network 10/12] sdn: zones: offer dhcp on all zone types, keep dnsmasq simple-only Hannes Laimer
@ 2026-09-04  9:38 ` Hannes Laimer
  2026-09-04  9:38 ` [PATCH pve-manager 12/12] ui: sdn: dhcp backend selector on all zones, expose dhcp options Hannes Laimer
  11 siblings, 0 replies; 14+ messages in thread
From: Hannes Laimer @ 2026-09-04  9:38 UTC (permalink / raw)
  To: pve-devel

Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
 src/test/run_test_vnets_blackbox.pl | 231 ++++++++++++++++++++++++++++
 1 file changed, 231 insertions(+)

diff --git a/src/test/run_test_vnets_blackbox.pl b/src/test/run_test_vnets_blackbox.pl
index 9f4c424..5b35320 100755
--- a/src/test/run_test_vnets_blackbox.pl
+++ b/src/test/run_test_vnets_blackbox.pl
@@ -46,6 +46,7 @@ sub clear_test_state {
         vnets_config => {},
         macdb => {},
         ipamdb => {},
+        ebpf_calls => [],
         ipam_config => {
             'ids' => {
                 'pve' => {
@@ -103,6 +104,13 @@ my $mocked_pve_sdn;
 $mocked_pve_sdn = Test::MockModule->new('PVE::Network::SDN');
 $mocked_pve_sdn->mock(
     cfs_lock_file => $mocked_cfs_lock_file,
+    running_config => sub {
+        return {
+            zones => $test_state->{zones_config},
+            vnets => $test_state->{vnets_config},
+            subnets => $test_state->{subnets_config},
+        };
+    },
 );
 
 my $mocked_pve_tools = Test::MockModule->new('PVE::Tools');
@@ -224,6 +232,26 @@ $mocked_sdn_dhcp_dnsmasq->mock(
     update_lease => sub { },
 );
 
+my $mocked_sdn_dhcp = Test::MockModule->new('PVE::Network::SDN::Dhcp');
+$mocked_sdn_dhcp->mock(
+    notify_guest_node => sub { },
+);
+
+my $mocked_sdn_dhcp_ebpf = Test::MockModule->new('PVE::Network::SDN::Dhcp::Ebpf');
+$mocked_sdn_dhcp_ebpf->mock(
+    cfs_lock_file => $mocked_cfs_lock_file,
+);
+
+my $mocked_pve_rs_dhcp = Test::MockModule->new('PVE::RS::SDN::Dhcp');
+$mocked_pve_rs_dhcp->mock(
+    map {
+        my $method = $_;
+        $method => sub {
+            push $test_state->{ebpf_calls}->@*, { method => $method, args => [@_] };
+        };
+    } qw(apply attach clear)
+);
+
 my $mocked_api_zones = Test::MockModule->new('PVE::API2::Network::SDN::Zones');
 $mocked_api_zones->mock(
     create_etc_interfaces_sdn_dir => sub { },
@@ -321,6 +349,16 @@ sub create_subnet {
     PVE::API2::Network::SDN::Subnets->create($params);
 }
 
+sub update_zone {
+    my ($zoneid, $params) = @_;
+    PVE::API2::Network::SDN::Zones->update({ zone => $zoneid, %$params });
+}
+
+sub update_subnet {
+    my ($params) = @_;
+    PVE::API2::Network::SDN::Subnets->update($params);
+}
+
 sub get_ipam_entries {
     return PVE::API2::Network::SDN::Ipams->ipamindex({ ipam => "pve" });
 }
@@ -330,6 +368,22 @@ sub create_ip {
     return PVE::API2::Network::SDN::Ips->ipcreate($param);
 }
 
+sub update_ip {
+    my ($param) = @_;
+    return PVE::API2::Network::SDN::Ips->ipupdate($param);
+}
+
+sub delete_ip {
+    my ($param) = @_;
+    return PVE::API2::Network::SDN::Ips->ipdelete($param);
+}
+
+sub take_ebpf_calls {
+    my $calls = $test_state->{ebpf_calls};
+    $test_state->{ebpf_calls} = [];
+    return $calls;
+}
+
 sub run_test {
     my $test = shift;
     clear_test_state();
@@ -963,4 +1017,181 @@ run_test(
     2,
 );
 
+# -------------- ebpf dhcp backend
+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"],
+        'dhcp-lease-time' => 300,
+    });
+
+    take_ebpf_calls();
+
+    # guest start allocates from the range and applies the full state
+    eval { nic_start($vnetid, $mac, "999", "testhostname"); };
+    if ($@) {
+        fail("$test_name: nic_start: $@");
+        return;
+    }
+
+    my $calls = take_ebpf_calls();
+    my $record = sub {
+        my ($ip) = @_;
+        return {
+            mac => $mac,
+            ip => $ip,
+            prefixlen => 24,
+            server_id => "10.0.0.1",
+            lease => 300,
+            router => "10.0.0.1",
+            dns => undef,
+            mtu => 1500,
+        };
+    };
+
+    eq_or_diff(
+        $calls,
+        [{ method => 'apply', args => [[], [$record->("10.0.0.100")]] }],
+        "$test_name: guest start applies the full state",
+    );
+
+    # a mapping edit through the API applies the full state once
+    update_ip({
+        zone => $zoneid,
+        vnet => $vnetid,
+        mac => $mac,
+        ip => "10.0.0.150",
+    });
+
+    $calls = take_ebpf_calls();
+    eq_or_diff(
+        $calls,
+        [{ method => 'apply', args => [[], [$record->("10.0.0.150")]] }],
+        "$test_name: mapping edit applies the new state",
+    );
+
+    # a full regenerate is the same full pass
+    PVE::Network::SDN::Dhcp::regenerate_config();
+
+    $calls = take_ebpf_calls();
+    eq_or_diff(
+        $calls,
+        [{ method => 'apply', args => [[], [$record->("10.0.0.150")]] }],
+        "$test_name: regenerate applies the full state",
+    );
+
+    # deleting the mapping drops the record
+    delete_ip({
+        zone => $zoneid,
+        vnet => $vnetid,
+        mac => $mac,
+        ip => "10.0.0.150",
+    });
+
+    $calls = take_ebpf_calls();
+    eq_or_diff(
+        $calls,
+        [{ method => 'apply', args => [[], []] }],
+        "$test_name: mapping delete applies the emptied state",
+    );
+}
+
+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",
+    );
+
+    # the zone switches to ebpf, its subnets lack what a record needs
+    update_zone($zoneid, { dhcp => "ebpf" });
+    create_vnet({
+        type => "vnet",
+        zone => $zoneid,
+        vnet => $vnetid,
+    });
+    create_subnet({
+        type => "subnet",
+        vnet => $vnetid,
+        subnet => "10.0.0.0/24",
+        'dhcp-range' => ["start-address=10.0.0.100,end-address=10.0.0.200"],
+    });
+    create_subnet({
+        type => "subnet",
+        vnet => $vnetid,
+        subnet => "fd00::/64",
+        gateway => "fd00::1",
+        'dhcp-range' => ["start-address=fd00::100,end-address=fd00::200"],
+    });
+
+    take_ebpf_calls();
+    eval { nic_start($vnetid, $mac, "999", "testhostname"); };
+    if ($@) {
+        fail("$test_name: nic_start: $@");
+        return;
+    }
+
+    # a gateway-less v4 subnet and a v6 subnet produce no record, the pass
+    # still runs once per allocated family with the guest interfaces alone
+    my $calls = take_ebpf_calls();
+    eq_or_diff(
+        $calls,
+        [{ method => 'apply', args => [[], []] }, { method => 'apply', args => [[], []] }],
+        "$test_name: subnets without a gateway or over IPv6 yield no record",
+    );
+
+    # 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",
+    });
+    take_ebpf_calls();
+    PVE::Network::SDN::Dhcp::regenerate_config();
+    $calls = take_ebpf_calls();
+    is(scalar(@$calls), 1, "$test_name: regenerate applies once");
+    my $records = $calls->[0]->{args}->[1];
+    is(scalar(@$records), 1, "$test_name: the v4 subnet now yields the record");
+    is($records->[0]->{dns}, undef, "$test_name: the IPv6 resolver is not handed out");
+}
+
+run_test(\&test_ebpf_backend);
+run_test(\&test_ebpf_backend_edge_cases);
+
 done_testing();
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 14+ messages in thread

* [PATCH pve-manager 12/12] ui: sdn: dhcp backend selector on all zones, expose dhcp options
  2026-09-04  9:38 [PATCH manager/network/proxmox{-ebpf,-perl-rs} 00/12] sdn: implement DHCP for all zones using eBPF Hannes Laimer
                   ` (10 preceding siblings ...)
  2026-09-04  9:38 ` [PATCH pve-network 11/12] tests: cover the ebpf dhcp backend and ipam API mapping pushes Hannes Laimer
@ 2026-09-04  9:38 ` Hannes Laimer
  11 siblings, 0 replies; 14+ messages in thread
From: Hannes Laimer @ 2026-09-04  9:38 UTC (permalink / raw)
  To: pve-devel

The zone dhcp property is now common to all zone types with two
backends, so the simple-zone automatic-DHCP checkbox hardcoding
dnsmasq becomes a selector on the common zone panel, offering
dnsmasq only where the backend is supported. The subnet gains fields
for the dns-server option, which so far was settable through the
API only and is the only way guests get a resolver with the ebpf
backend, and for the new lease-time knob, which bounds how long an
edited mapping takes to reach a leased guest.

Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
 www/manager6/sdn/SubnetEdit.js       | 24 ++++++++++++++++++++++++
 www/manager6/sdn/zones/Base.js       | 17 +++++++++++++++++
 www/manager6/sdn/zones/SimpleEdit.js | 11 -----------
 3 files changed, 41 insertions(+), 11 deletions(-)

diff --git a/www/manager6/sdn/SubnetEdit.js b/www/manager6/sdn/SubnetEdit.js
index a3608428..f98f65af 100644
--- a/www/manager6/sdn/SubnetEdit.js
+++ b/www/manager6/sdn/SubnetEdit.js
@@ -56,6 +56,30 @@ Ext.define('PVE.sdn.SubnetInputPanel', {
                 deleteEmpty: '{!isCreate}',
             },
         },
+        {
+            xtype: 'proxmoxtextfield',
+            name: 'dhcp-dns-server',
+            vtype: 'IP64Address',
+            fieldLabel: gettext('DHCP DNS Server'),
+            allowBlank: true,
+            skipEmptyText: true,
+            cbind: {
+                deleteEmpty: '{!isCreate}',
+            },
+        },
+        {
+            xtype: 'proxmoxintegerfield',
+            // TRANSLATORS: 's' is the SI abbreviation for seconds, the unit of the value
+            fieldLabel: gettext('DHCP Lease Time') + ' (s)',
+            emptyText: gettext('default'),
+            name: 'dhcp-lease-time',
+            minValue: 60,
+            allowBlank: true,
+            skipEmptyText: true,
+            cbind: {
+                deleteEmpty: '{!isCreate}',
+            },
+        },
     ],
 });
 
diff --git a/www/manager6/sdn/zones/Base.js b/www/manager6/sdn/zones/Base.js
index 66f93de0..3039b99f 100644
--- a/www/manager6/sdn/zones/Base.js
+++ b/www/manager6/sdn/zones/Base.js
@@ -82,6 +82,23 @@ Ext.define('PVE.panel.SDNZoneBase', {
             },
         );
 
+        let dhcpBackends = [
+            ['__default__', Proxmox.Utils.NoneText],
+            ['ebpf', 'eBPF'],
+        ];
+        if (me.type === 'simple') {
+            dhcpBackends.splice(1, 0, ['dnsmasq', 'dnsmasq']);
+        }
+
+        me.advancedItems.push({
+            xtype: 'proxmoxKVComboBox',
+            name: 'dhcp',
+            fieldLabel: gettext('Automatic DHCP'),
+            comboItems: dhcpBackends,
+            value: '__default__',
+            deleteEmpty: !me.isCreate,
+        });
+
         me.callParent();
     },
 });
diff --git a/www/manager6/sdn/zones/SimpleEdit.js b/www/manager6/sdn/zones/SimpleEdit.js
index ba10bb36..aeb076ae 100644
--- a/www/manager6/sdn/zones/SimpleEdit.js
+++ b/www/manager6/sdn/zones/SimpleEdit.js
@@ -19,17 +19,6 @@ Ext.define('PVE.sdn.zones.SimpleInputPanel', {
         var me = this;
 
         me.items = [];
-        me.advancedItems = [
-            {
-                xtype: 'proxmoxcheckbox',
-                name: 'dhcp',
-                inputValue: 'dnsmasq',
-                uncheckedValue: null,
-                checked: false,
-                fieldLabel: gettext('Automatic DHCP'),
-                deleteEmpty: !me.isCreate,
-            },
-        ];
 
         me.callParent();
     },
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 14+ messages in thread

end of thread, other threads:[~2026-09-04  9:40 UTC | newest]

Thread overview: 14+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
2026-09-04  9:38 [PATCH manager/network/proxmox{-ebpf,-perl-rs} 00/12] sdn: implement DHCP for all zones using eBPF Hannes Laimer
2026-09-04  9:38 ` [PATCH proxmox-ebpf 01/12] dhcp: add per-tap responder BPF program Hannes Laimer
2026-09-04  9:38 ` [PATCH proxmox-ebpf 02/12] dhcp: add responder subsystem Hannes Laimer
2026-09-04  9:38 ` [PATCH proxmox-perl-rs 03/12] pve-rs: sdn: add dhcp responder bindings Hannes Laimer
2026-09-04  9:38 ` [PATCH pve-network 04/12] sdn: ipam: do not cache negative per-MAC answers, lock the write Hannes Laimer
2026-09-04  9:38 ` [PATCH pve-network 05/12] sdn: subnets: add dhcp-lease-time property Hannes Laimer
2026-09-04  9:38 ` [PATCH pve-network 06/12] sdn: dhcp: only assert a backend's availability for zones using it Hannes Laimer
2026-09-04  9:38 ` [PATCH pve-network 07/12] sdn: dhcp: add ebpf plugin Hannes Laimer
2026-09-04  9:38 ` [PATCH pve-network 08/12] sdn: zones: attach the dhcp responder on tap plug Hannes Laimer
2026-09-04  9:38 ` [PATCH pve-network 09/12] sdn: dhcp: apply mapping edits on the node serving the guest Hannes Laimer
2026-09-04  9:38 ` [PATCH pve-network 10/12] sdn: zones: offer dhcp on all zone types, keep dnsmasq simple-only Hannes Laimer
2026-09-04  9:38 ` [PATCH pve-network 11/12] tests: cover the ebpf dhcp backend and ipam API mapping pushes Hannes Laimer
2026-09-04  9:38 ` [PATCH pve-manager 12/12] ui: sdn: dhcp backend selector on all zones, expose dhcp options Hannes Laimer
  -- strict thread matches above, loose matches on Subject: below --
2026-09-02 12:47 [RFC manager/network/proxmox{-ebpf,-perl-rs} 00/12] sdn: implement DHCP for all zones using eBPF Hannes Laimer
2026-09-02 12:47 ` [PATCH pve-network 07/12] sdn: dhcp: add ebpf plugin Hannes Laimer

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