public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
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





  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
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal