From: Hannes Laimer <h.laimer@proxmox.com>
To: pve-devel@lists.proxmox.com
Subject: [PATCH pve-network 07/12] sdn: dhcp: add ebpf plugin
Date: Wed, 2 Sep 2026 14:47:34 +0200 [thread overview]
Message-ID: <20260902124739.750853-8-h.laimer@proxmox.com> (raw)
In-Reply-To: <20260902124739.750853-1-h.laimer@proxmox.com>
A dhcp backend that programs the proxmox-ebpf per-tap DHCP responder
instead of driving a dnsmasq instance, selectable per zone with
dhcp=ebpf. Answers come from the same per-MAC records dnsmasq serves
reservations from, handed in-process to the responder through the
pve-rs bindings 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
next prev parent reply other threads:[~2026-09-02 12:49 UTC|newest]
Thread overview: 14+ messages / expand[flat|nested] mbox.gz Atom feed top
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 proxmox-ebpf 01/12] dhcp: add per-tap responder BPF program Hannes Laimer
2026-09-02 12:47 ` [PATCH proxmox-ebpf 02/12] dhcp: add responder subsystem Hannes Laimer
2026-09-02 12:47 ` [PATCH proxmox-perl-rs 03/12] pve-rs: sdn: add dhcp responder bindings Hannes Laimer
2026-09-02 12:47 ` [PATCH pve-network 04/12] sdn: ipam: do not cache negative per-MAC answers, lock the write Hannes Laimer
2026-09-02 12:47 ` [PATCH pve-network 05/12] sdn: subnets: add dhcp-lease-time property Hannes Laimer
2026-09-02 12:47 ` [PATCH pve-network 06/12] sdn: dhcp: only assert a backend's availability for zones using it Hannes Laimer
2026-09-02 12:47 ` Hannes Laimer [this message]
2026-09-02 12:47 ` [PATCH pve-network 08/12] sdn: zones: attach the dhcp responder on tap plug Hannes Laimer
2026-09-02 12:47 ` [PATCH pve-network 09/12] sdn: dhcp: apply mapping edits on the node serving the guest Hannes Laimer
2026-09-02 12:47 ` [PATCH pve-network 10/12] sdn: zones: offer dhcp on all zone types, keep dnsmasq simple-only Hannes Laimer
2026-09-02 12:47 ` [PATCH pve-network 11/12] tests: cover the ebpf dhcp backend and ipam API mapping pushes Hannes Laimer
2026-09-02 12:47 ` [PATCH pve-manager 12/12] ui: sdn: dhcp backend selector on all zones, expose dhcp options Hannes Laimer
2026-09-02 12:54 ` [RFC manager/network/proxmox{-ebpf,-perl-rs} 00/12] sdn: implement DHCP for all zones using eBPF Hannes Laimer
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=20260902124739.750853-8-h.laimer@proxmox.com \
--to=h.laimer@proxmox.com \
--cc=pve-devel@lists.proxmox.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox