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 a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox