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 <[email protected]>
---
 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




Reply via email to