From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from gate001.proxmox.com (gate001.proxmox.com [45.144.208.40]) by lore.proxmox.com (Postfix) with ESMTPS id 1B62F1FF0A7 for ; Wed, 02 Sep 2026 14:49:00 +0200 (CEST) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 8ACD821537; Wed, 02 Sep 2026 14:48:28 +0200 (CEST) From: Hannes Laimer 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 Message-ID: <20260902124739.750853-8-h.laimer@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260902124739.750853-1-h.laimer@proxmox.com> References: <20260902124739.750853-1-h.laimer@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1788353264017 X-SPAM-LEVEL: Spam detection results: 0 AWL -0.336 Adjusted score from AWL reputation of From: address DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment (newer systems) KAM_MAILER 2 Automated Mailer Tag Left in Email RCVD_IN_DNSWL_MED -2.3 Sender listed at https://www.dnswl.org/, medium trust SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Message-ID-Hash: 2JJVYOVHWIBNR557M76QPDHHXJTWUY3M X-Message-ID-Hash: 2JJVYOVHWIBNR557M76QPDHHXJTWUY3M X-MailFrom: h.laimer@proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox VE development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: 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 --- 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