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 AFD361FF0A5 for ; Fri, 04 Sep 2026 11:40:00 +0200 (CEST) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 2B365215D2; Fri, 04 Sep 2026 11:39:49 +0200 (CEST) From: Hannes Laimer 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 Message-ID: <20260904093835.1050030-8-h.laimer@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260904093835.1050030-1-h.laimer@proxmox.com> References: <20260904093835.1050030-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: 1788514721713 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.618 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) 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: F3UKI7RQLAEVGWTFTT3VO6ZFMLSDXYNE X-Message-ID-Hash: F3UKI7RQLAEVGWTFTT3VO6ZFMLSDXYNE 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 | 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