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: Fri, 4 Sep 2026 11:38:30 +0200 [thread overview]
Message-ID: <20260904093835.1050030-8-h.laimer@proxmox.com> (raw)
In-Reply-To: <20260904093835.1050030-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 | 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
next prev parent reply other threads:[~2026-09-04 9:40 UTC|newest]
Thread overview: 14+ messages / expand[flat|nested] mbox.gz Atom feed top
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 ` Hannes Laimer [this message]
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
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=20260904093835.1050030-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 an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.