public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
* [RFC manager/network/proxmox{-ebpf,-perl-rs} 00/12] sdn: implement DHCP for all zones using eBPF
@ 2026-09-02 12:47 Hannes Laimer
  2026-09-02 12:47 ` [PATCH proxmox-ebpf 01/12] dhcp: add per-tap responder BPF program Hannes Laimer
                   ` (12 more replies)
  0 siblings, 13 replies; 14+ messages in thread
From: Hannes Laimer @ 2026-09-02 12:47 UTC (permalink / raw)
  To: pve-devel

Adds a second DHCP backend, `ebpf`, next to dnsmasq, selectable per
zone. It aims to replace dnsmasq eventually, for now it is a second
implementation, which keeps a migration simple. Every zone type can
enable DHCP through a dropdown selector, `dnsmasq` stays limited to
simple zones.

The responder is a subsystem of `proxmox-ebpf` [1], Perl reaches it
through new pve-rs bindings (PVE::RS::SDN::Dhcp), so the pve-network
patches need the pve-rs of this series.

Currently only supports DHCPv4, but adding v6 is very possible once we're happy
with the overall design.

# How
An eBPF program on the ingress of every guest tap parses DHCP requests,
looks the client MAC up in a mac -> ip+options map and rewrites the
request into the reply in place, redirected back out of the tap. The
exchange never reaches the bridge. Everything else, including MACs
without a map entry, passes untouched, so attaching is a no-op for
unmanaged MACs.

IPAM is the source of the assignments, the map is a per-node copy of
the records, kept current by:
 - guest start / NIC hotplug / migration: add_dhcp_mapping already
   fires here and pushes the MAC's record before the interface is
   plugged, tap_plug then attaches the program.
 - mapping create/update/delete through the API: the editing node
   updates its own map and pokes the node running the guest to do the
   same, detached from the request. Best effort, an unreachable node
   catches up on its next apply or the guest's next start.
 - SDN apply: refreshes the programs, drops the link pins of departed
   guests and rebuilds the map from the current records.
 - boot: maps start empty, every guest start seeds its own record.

Changes made directly on an external IPAM service are not detectable
and the per-MAC answers are cached, so they are not picked up on apply
either, exactly like with dnsmasq today.

The pve-network part applies on top of the separately posted patch
pushing ipam API mapping changes to the dhcp backend. Its first
patches are preparatory, no negative per-MAC cache entries and a
locked cache write, a lease time property on subnets, and asserting a
backend's availability only for zones using it.

[1] https://lore.proxmox.com/pve-devel/8d63974f-0a73-480b-9407-c6bdc2d576d7@proxmox.com


proxmox-ebpf:

Hannes Laimer (2):
  dhcp: add per-tap responder BPF program
  dhcp: add responder subsystem

 Cargo.toml              |   5 +
 debian/control          |   6 +-
 src/dhcp/bpf/dhcp.bpf.c | 324 +++++++++++++++++++
 src/dhcp/bpf/types.h    |  25 ++
 src/dhcp/mod.rs         | 288 +++++++++++++++++
 src/dhcp/types.rs       |  53 ++++
 src/lib.rs              |   3 +
 src/subsystem.rs        |  35 +++
 tests/dhcp.rs           | 668 ++++++++++++++++++++++++++++++++++++++++
 9 files changed, 1406 insertions(+), 1 deletion(-)
 create mode 100644 src/dhcp/bpf/dhcp.bpf.c
 create mode 100644 src/dhcp/bpf/types.h
 create mode 100644 src/dhcp/mod.rs
 create mode 100644 src/dhcp/types.rs
 create mode 100644 tests/dhcp.rs


proxmox-perl-rs:

Hannes Laimer (1):
  pve-rs: sdn: add dhcp responder bindings

 pve-rs/Cargo.toml               |  2 +
 pve-rs/Makefile                 |  1 +
 pve-rs/debian/control           |  2 +
 pve-rs/src/bindings/sdn/dhcp.rs | 91 +++++++++++++++++++++++++++++++++
 pve-rs/src/bindings/sdn/mod.rs  |  1 +
 5 files changed, 97 insertions(+)
 create mode 100644 pve-rs/src/bindings/sdn/dhcp.rs


pve-network:

Hannes Laimer (8):
  sdn: ipam: do not cache negative per-MAC answers, lock the write
  sdn: subnets: add dhcp-lease-time property
  sdn: dhcp: only assert a backend's availability for zones using it
  sdn: dhcp: add ebpf plugin
  sdn: zones: attach the dhcp responder on tap plug
  sdn: dhcp: apply mapping edits on the node serving the guest
  sdn: zones: offer dhcp on all zone types, keep dnsmasq simple-only
  tests: cover the ebpf dhcp backend and ipam API mapping pushes

 src/PVE/API2/Network/SDN/Ips.pm           |   5 +-
 src/PVE/API2/Network/SDN/Nodes/Status.pm  |  37 ++++-
 src/PVE/API2/Network/SDN/Zones.pm         |   8 +-
 src/PVE/Network/SDN/Dhcp.pm               |  87 ++++++++++-
 src/PVE/Network/SDN/Dhcp/Ebpf.pm          | 173 ++++++++++++++++++++++
 src/PVE/Network/SDN/Dhcp/Makefile         |   2 +-
 src/PVE/Network/SDN/Ipams.pm              |  20 ++-
 src/PVE/Network/SDN/SubnetPlugin.pm       |   7 +
 src/PVE/Network/SDN/Zones.pm              |   4 +
 src/PVE/Network/SDN/Zones/EvpnPlugin.pm   |   1 +
 src/PVE/Network/SDN/Zones/FaucetPlugin.pm |   1 +
 src/PVE/Network/SDN/Zones/QinQPlugin.pm   |   7 +
 src/PVE/Network/SDN/Zones/VlanPlugin.pm   |   7 +
 src/PVE/Network/SDN/Zones/VxlanPlugin.pm  |   9 ++
 src/test/run_test_vnets_blackbox.pl       | 147 ++++++++++++++++++
 15 files changed, 502 insertions(+), 13 deletions(-)
 create mode 100644 src/PVE/Network/SDN/Dhcp/Ebpf.pm


pve-manager:

Hannes Laimer (1):
  ui: sdn: dhcp backend selector on all zones, expose dhcp options

 www/manager6/sdn/SubnetEdit.js       | 24 ++++++++++++++++++++++++
 www/manager6/sdn/zones/Base.js       | 17 +++++++++++++++++
 www/manager6/sdn/zones/SimpleEdit.js | 11 -----------
 3 files changed, 41 insertions(+), 11 deletions(-)


Summary over all repositories:
  32 files changed, 2046 insertions(+), 25 deletions(-)

-- 
Generated by murpp 0.12.0




^ permalink raw reply	[flat|nested] 14+ messages in thread

* [PATCH proxmox-ebpf 01/12] dhcp: add per-tap responder BPF program
  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 ` Hannes Laimer
  2026-09-02 12:47 ` [PATCH proxmox-ebpf 02/12] dhcp: add responder subsystem Hannes Laimer
                   ` (11 subsequent siblings)
  12 siblings, 0 replies; 14+ messages in thread
From: Hannes Laimer @ 2026-09-02 12:47 UTC (permalink / raw)
  To: pve-devel

Attached on tap ingress, the responder rewrites a request from a MAC
with a record into the reply in place and redirects it back out the
tap, so the exchange never reaches the bridge. Everything else passes
untouched, so attaching is harmless on interfaces that end up
unmanaged, and a REQUEST for a stale address is NAKed so edited
records converge within one lease.

Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
 Cargo.toml              |   5 +
 debian/control          |   6 +-
 src/dhcp/bpf/dhcp.bpf.c | 324 +++++++++++++++++++
 src/dhcp/bpf/types.h    |  25 ++
 tests/dhcp.rs           | 668 ++++++++++++++++++++++++++++++++++++++++
 5 files changed, 1027 insertions(+), 1 deletion(-)
 create mode 100644 src/dhcp/bpf/dhcp.bpf.c
 create mode 100644 src/dhcp/bpf/types.h
 create mode 100644 tests/dhcp.rs

diff --git a/Cargo.toml b/Cargo.toml
index a87bb2e..a1171d9 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -11,6 +11,7 @@ homepage = "https://proxmox.com"
 exclude = ["build", "debian"]
 
 [features]
+dhcp = []
 # When enabled, BPF programs compile with -DBPF_DEBUG so their DBG() macros
 # expand to bpf_printk. Off by default
 bpf-debug = []
@@ -20,3 +21,7 @@ aya = "0.13"
 anyhow = "1"
 log = "0.4"
 nix = { version = "0.29", features = ["fs", "net"] }
+
+[[test]]
+name = "dhcp"
+required-features = ["dhcp"]
diff --git a/debian/control b/debian/control
index d1400c6..8830dbd 100644
--- a/debian/control
+++ b/debian/control
@@ -39,14 +39,18 @@ Depends:
 Provides:
  librust-proxmox-ebpf+bpf-debug-dev (= ${binary:Version}),
  librust-proxmox-ebpf+default-dev (= ${binary:Version}),
+ librust-proxmox-ebpf+dhcp-dev (= ${binary:Version}),
  librust-proxmox-ebpf-0-dev (= ${binary:Version}),
  librust-proxmox-ebpf-0+bpf-debug-dev (= ${binary:Version}),
  librust-proxmox-ebpf-0+default-dev (= ${binary:Version}),
+ librust-proxmox-ebpf-0+dhcp-dev (= ${binary:Version}),
  librust-proxmox-ebpf-0.1-dev (= ${binary:Version}),
  librust-proxmox-ebpf-0.1+bpf-debug-dev (= ${binary:Version}),
  librust-proxmox-ebpf-0.1+default-dev (= ${binary:Version}),
+ librust-proxmox-ebpf-0.1+dhcp-dev (= ${binary:Version}),
  librust-proxmox-ebpf-0.1.0-dev (= ${binary:Version}),
  librust-proxmox-ebpf-0.1.0+bpf-debug-dev (= ${binary:Version}),
- librust-proxmox-ebpf-0.1.0+default-dev (= ${binary:Version})
+ librust-proxmox-ebpf-0.1.0+default-dev (= ${binary:Version}),
+ librust-proxmox-ebpf-0.1.0+dhcp-dev (= ${binary:Version})
 Description: Proxmox VE eBPF - Rust source code
  Source code for Debianized Rust crate "proxmox-ebpf"
diff --git a/src/dhcp/bpf/dhcp.bpf.c b/src/dhcp/bpf/dhcp.bpf.c
new file mode 100644
index 0000000..8f9034e
--- /dev/null
+++ b/src/dhcp/bpf/dhcp.bpf.c
@@ -0,0 +1,324 @@
+// Per-tap DHCPv4 responder. Attached on tap ingress, it answers DISCOVER and
+// REQUEST for MACs with a record by rewriting the request into the reply in
+// place and redirecting it back out the same interface. The redirect consumes
+// the request, so nothing upstream on the bridge sees it. Everything else,
+// including requests from MACs without a record, passes untouched.
+
+#include "vmlinux.h"
+#include <bpf/bpf_helpers.h>
+#include <bpf/bpf_endian.h>
+#include "types.h"
+#include "bpf_debug.h"
+
+char LICENSE[] SEC("license") = "GPL";
+
+#define TC_ACT_OK 0
+
+#define ETH_HLEN 14
+#define ETH_P_IP 0x0800
+#define IP_PROTO_UDP 17
+
+#define BOOTP_LEN 236
+// replies are padded up to the minimal BOOTP message size (RFC 1542), some old
+// client stacks drop anything shorter
+#define BOOTP_MIN_LEN 300
+#define DHCP_MAGIC 0x63825363
+#define BOOTP_FLAG_BCAST 0x8000
+
+#define BOOTREQUEST 1
+#define BOOTREPLY 2
+
+#define DHCP_DISCOVER 1
+#define DHCP_OFFER 2
+#define DHCP_REQUEST 3
+#define DHCP_ACK 5
+#define DHCP_NAK 6
+
+#define OPT_PAD 0
+#define OPT_NETMASK 1
+#define OPT_ROUTER 3
+#define OPT_DNS 6
+#define OPT_MTU 26
+#define OPT_REQUESTED_IP 50
+#define OPT_LEASE 51
+#define OPT_MSGTYPE 53
+#define OPT_SERVER_ID 54
+#define OPT_WPAD 252
+#define OPT_END 255
+
+struct {
+    __uint(type, BPF_MAP_TYPE_HASH);
+    __type(key, struct dhcp_mac_key);
+    __type(value, struct dhcp_record);
+    __uint(max_entries, 65536);
+    __uint(map_flags, BPF_F_NO_PREALLOC);
+    __uint(pinning, LIBBPF_PIN_BY_NAME);
+} dhcp_records SEC(".maps");
+
+struct bootp {
+    __u8 op;
+    __u8 htype;
+    __u8 hlen;
+    __u8 hops;
+    __u32 xid;
+    __u16 secs;
+    __u16 flags;
+    __u32 ciaddr;
+    __u32 yiaddr;
+    __u32 siaddr;
+    __u32 giaddr;
+    __u8 chaddr[16];
+    __u8 sname[64];
+    __u8 file[128];
+};
+
+struct reply_pkt {
+    struct iphdr ip;
+    struct udphdr udp;
+    struct bootp bp;
+    __u8 cookie[4];
+    __u8 opts[BOOTP_MIN_LEN - BOOTP_LEN - 4];
+} __attribute__((packed));
+
+// the responder has no address on the tap, replies carry a fixed locally
+// administered source MAC that nothing ever learns
+static const __u8 SRC_MAC[6] = { 0x02, 0x00, 0x00, 0x00, 0x00, 0x01 };
+static const __u8 BCAST_MAC[6] = { 0xff, 0xff, 0xff, 0xff, 0xff, 0xff };
+
+static __always_inline __u16 ip_csum(struct iphdr *ip)
+{
+    __u32 sum = 0;
+    __u16 *p = (__u16 *)ip;
+#pragma unroll
+    for (int i = 0; i < 10; i++)
+        sum += p[i];
+    sum = (sum & 0xffff) + (sum >> 16);
+    sum = (sum & 0xffff) + (sum >> 16);
+    return ~sum;
+}
+
+SEC("classifier")
+int tc_dhcp_ingress(struct __sk_buff *skb)
+{
+    __u32 min_len = ETH_HLEN + sizeof(struct iphdr) + sizeof(struct udphdr) + BOOTP_LEN + 4;
+
+    void *data = (void *)(long)skb->data;
+    void *data_end = (void *)(long)skb->data_end;
+
+    if (data + min_len > data_end) {
+        // request may sit in a non-linear skb, everything shorter is
+        // not a DHCP request anyway
+        if (skb->len < min_len || bpf_skb_pull_data(skb, min_len))
+            return TC_ACT_OK;
+        data = (void *)(long)skb->data;
+        data_end = (void *)(long)skb->data_end;
+        if (data + min_len > data_end)
+            return TC_ACT_OK;
+    }
+
+    struct ethhdr *eth = data;
+    if (eth->h_proto != bpf_htons(ETH_P_IP))
+        return TC_ACT_OK;
+
+    struct iphdr *ip = data + ETH_HLEN;
+    if (ip->version != 4 || ip->ihl != 5 || ip->protocol != IP_PROTO_UDP)
+        return TC_ACT_OK;
+
+    // a fragment is never a whole request, and past the first one the bytes at
+    // the UDP header offset are arbitrary payload
+    if (ip->frag_off & bpf_htons(0x3fff))
+        return TC_ACT_OK;
+
+    struct udphdr *udp = data + ETH_HLEN + sizeof(struct iphdr);
+    if (udp->dest != bpf_htons(67))
+        return TC_ACT_OK;
+
+    struct bootp *req = data + ETH_HLEN + sizeof(struct iphdr) + sizeof(struct udphdr);
+    if (req->op != BOOTREQUEST || req->htype != 1 || req->hlen != 6)
+        return TC_ACT_OK;
+
+    // a reply to a relayed request must go unicast to the relay instead of the
+    // client, leave those to a server that speaks relay
+    if (req->giaddr) {
+        DBG("dhcp: request relayed via %pI4 passed", &req->giaddr);
+        return TC_ACT_OK;
+    }
+
+    __u32 *magic = (void *)req + BOOTP_LEN;
+    if (*magic != bpf_htonl(DHCP_MAGIC))
+        return TC_ACT_OK;
+
+    // clients are identified by chaddr alone, a client identifier option is
+    // deliberately not honored since the records are keyed by MAC (manual
+    // allocation in RFC 2131 terms)
+    struct dhcp_mac_key key = {};
+    __builtin_memcpy(key.addr, req->chaddr, 6);
+    struct dhcp_record *rec = bpf_map_lookup_elem(&dhcp_records, &key);
+    if (!rec) {
+        DBG("dhcp: no record for %02x:%02x:%02x:%02x:%02x:%02x", key.addr[0], key.addr[1],
+            key.addr[2], key.addr[3], key.addr[4], key.addr[5]);
+        return TC_ACT_OK;
+    }
+
+    __u8 msgtype = 0;
+    __u32 requested_ip = 0;
+    __u32 server_id = 0;
+    __u32 off = min_len;
+
+    for (int i = 0; i < 128; i++) {
+        __u8 code, len;
+        if (bpf_skb_load_bytes(skb, off, &code, 1))
+            break;
+        if (code == OPT_END)
+            break;
+        if (code == OPT_PAD) {
+            off += 1;
+            continue;
+        }
+        if (bpf_skb_load_bytes(skb, off + 1, &len, 1))
+            break;
+        if (code == OPT_MSGTYPE && len == 1)
+            bpf_skb_load_bytes(skb, off + 2, &msgtype, 1);
+        else if (code == OPT_REQUESTED_IP && len == 4)
+            bpf_skb_load_bytes(skb, off + 2, &requested_ip, 4);
+        else if (code == OPT_SERVER_ID && len == 4)
+            bpf_skb_load_bytes(skb, off + 2, &server_id, 4);
+        off += 2 + len;
+    }
+
+    __u8 reply;
+    if (msgtype == DHCP_DISCOVER) {
+        reply = DHCP_OFFER;
+    } else if (msgtype == DHCP_REQUEST) {
+        // a REQUEST naming another server declines our offer and is
+        // answered, if at all, by that server
+        if (server_id && server_id != rec->server_id) {
+            DBG("dhcp: request selected server %pI4, passed", &server_id);
+            return TC_ACT_OK;
+        }
+        // a NAK on a stale address makes the client re-DISCOVER, which
+        // is how an edited record converges within one lease
+        __u32 want = requested_ip ? requested_ip : req->ciaddr;
+        reply = want == rec->ip ? DHCP_ACK : DHCP_NAK;
+        if (reply == DHCP_NAK)
+            DBG("dhcp: request for %pI4 against record %pI4, nak", &want, &rec->ip);
+    } else {
+        DBG("dhcp: message type %d passed", msgtype);
+        return TC_ACT_OK;
+    }
+
+    __u32 xid = req->xid;
+    __u16 flags = req->flags;
+    __u32 ciaddr = req->ciaddr;
+
+    struct reply_pkt buf = {};
+
+    buf.bp.op = BOOTREPLY;
+    buf.bp.htype = 1;
+    buf.bp.hlen = 6;
+    buf.bp.xid = xid;
+    buf.bp.flags = flags;
+    __builtin_memcpy(buf.bp.chaddr, key.addr, 6);
+
+    buf.cookie[0] = 0x63;
+    buf.cookie[1] = 0x82;
+    buf.cookie[2] = 0x53;
+    buf.cookie[3] = 0x63;
+
+    __u32 o = 0;
+    buf.opts[o++] = OPT_MSGTYPE;
+    buf.opts[o++] = 1;
+    buf.opts[o++] = reply;
+    buf.opts[o++] = OPT_SERVER_ID;
+    buf.opts[o++] = 4;
+    __builtin_memcpy(&buf.opts[o], &rec->server_id, 4);
+    o += 4;
+
+    if (reply != DHCP_NAK) {
+        buf.bp.yiaddr = rec->ip;
+
+        buf.opts[o++] = OPT_LEASE;
+        buf.opts[o++] = 4;
+        __builtin_memcpy(&buf.opts[o], &rec->lease, 4);
+        o += 4;
+        buf.opts[o++] = OPT_NETMASK;
+        buf.opts[o++] = 4;
+        __builtin_memcpy(&buf.opts[o], &rec->netmask, 4);
+        o += 4;
+        if (rec->router) {
+            buf.opts[o++] = OPT_ROUTER;
+            buf.opts[o++] = 4;
+            __builtin_memcpy(&buf.opts[o], &rec->router, 4);
+            o += 4;
+        }
+        if (rec->dns) {
+            buf.opts[o++] = OPT_DNS;
+            buf.opts[o++] = 4;
+            __builtin_memcpy(&buf.opts[o], &rec->dns, 4);
+            o += 4;
+        }
+        if (rec->mtu) {
+            buf.opts[o++] = OPT_MTU;
+            buf.opts[o++] = 2;
+            __builtin_memcpy(&buf.opts[o], &rec->mtu, 2);
+            o += 2;
+        }
+        // an empty WPAD answer, without it some windows versions
+        // probe for a proxy config indefinitely
+        buf.opts[o++] = OPT_WPAD;
+        buf.opts[o++] = 1;
+        buf.opts[o++] = '\n';
+    }
+    buf.opts[o++] = OPT_END;
+
+    // the zeroed tail of opts pads short replies out to the minimum, on the
+    // wire those zeros read as pad options after the end option
+    __u32 bootp_out = BOOTP_LEN + 4 + o;
+    if (bootp_out < BOOTP_MIN_LEN)
+        bootp_out = BOOTP_MIN_LEN;
+
+    __u32 payload = sizeof(struct udphdr) + bootp_out;
+
+    buf.ip.version = 4;
+    buf.ip.ihl = 5;
+    buf.ip.ttl = 64;
+    buf.ip.protocol = IP_PROTO_UDP;
+    buf.ip.tot_len = bpf_htons(sizeof(struct iphdr) + payload);
+    buf.ip.saddr = rec->server_id;
+    // a renewing client has an address and gets a unicast answer,
+    // everything else cannot be addressed yet
+    buf.ip.daddr = (reply != DHCP_NAK && ciaddr) ? ciaddr : 0xffffffff;
+    buf.ip.check = ip_csum(&buf.ip);
+
+    buf.udp.source = bpf_htons(67);
+    buf.udp.dest = bpf_htons(68);
+    buf.udp.len = bpf_htons(payload);
+    buf.udp.check = 0; // optional for IPv4
+
+    __u32 wlen = sizeof(struct iphdr) + payload;
+    if (wlen > sizeof(buf))
+        return TC_ACT_OK;
+
+    if (bpf_skb_change_tail(skb, ETH_HLEN + wlen, 0))
+        return TC_ACT_OK;
+
+    // NAKs are always broadcast, and a client without an address that set the
+    // BROADCAST flag asked for a broadcast reply
+    __u8 eth_hdr[ETH_HLEN];
+    if (reply == DHCP_NAK || (!ciaddr && (flags & bpf_htons(BOOTP_FLAG_BCAST)))) {
+        __builtin_memcpy(eth_hdr, BCAST_MAC, 6);
+    } else {
+        __builtin_memcpy(eth_hdr, key.addr, 6);
+    }
+    __builtin_memcpy(eth_hdr + 6, SRC_MAC, 6);
+    eth_hdr[12] = 0x08;
+    eth_hdr[13] = 0x00;
+
+    if (bpf_skb_store_bytes(skb, 0, eth_hdr, ETH_HLEN, 0))
+        return TC_ACT_OK;
+    if (bpf_skb_store_bytes(skb, ETH_HLEN, &buf, wlen, 0))
+        return TC_ACT_OK;
+
+    DBG("dhcp: reply type %d with %pI4 to ifindex %d", reply, &buf.bp.yiaddr, skb->ifindex);
+    return bpf_redirect(skb->ifindex, 0);
+}
diff --git a/src/dhcp/bpf/types.h b/src/dhcp/bpf/types.h
new file mode 100644
index 0000000..84f1564
--- /dev/null
+++ b/src/dhcp/bpf/types.h
@@ -0,0 +1,25 @@
+#ifndef PROXMOX_EBPF_DHCP_TYPES_H
+#define PROXMOX_EBPF_DHCP_TYPES_H
+
+// Keep in sync with ../types.rs
+// __u8/__u16/__u32 come from vmlinux.h
+
+struct dhcp_mac_key {
+    __u8 addr[6];
+    __u8 _pad[2];
+};
+
+// addresses, lease and mtu are stored in network byte order, so a
+// reply copies them into the packet as-is
+struct dhcp_record {
+    __u32 ip;
+    __u32 netmask;
+    __u32 router; // 0 = not served
+    __u32 dns; // 0 = not served
+    __u32 server_id;
+    __u32 lease;
+    __u16 mtu; // 0 = not served
+    __u8 _pad[2];
+};
+
+#endif
diff --git a/tests/dhcp.rs b/tests/dhcp.rs
new file mode 100644
index 0000000..13a9ecd
--- /dev/null
+++ b/tests/dhcp.rs
@@ -0,0 +1,668 @@
+//! The dhcp responder program run natively: crafted requests in, the rewritten reply out.
+
+mod common;
+
+use std::ffi::{c_int, c_void};
+
+use common::*;
+
+unsafe extern "C" {
+    fn tc_dhcp_ingress(skb: *mut SkBuff) -> c_int;
+    static dhcp_records: u8;
+}
+
+const MAC: [u8; 6] = [0xda, 0x65, 0x8f, 0x18, 0x9b, 0x6f];
+const SRC_MAC: [u8; 6] = [0x02, 0x00, 0x00, 0x00, 0x00, 0x01];
+const BCAST: [u8; 6] = [0xff; 6];
+const IFINDEX: u32 = 42;
+
+const IP: [u8; 4] = [10, 0, 0, 100];
+const MASK: [u8; 4] = [255, 255, 255, 0];
+const GW: [u8; 4] = [10, 0, 0, 1];
+const DNS: [u8; 4] = [1, 1, 1, 1];
+const LEASE: u32 = 300;
+const MTU: u16 = 1500;
+
+const OPTS_OFF: usize = 14 + 20 + 8 + 236 + 4;
+
+const DISCOVER: &[u8] = &[53, 1, 1];
+const REQUEST: &[u8] = &[53, 1, 3];
+
+fn records_map() -> *const c_void {
+    (unsafe { &dhcp_records } as *const u8).cast()
+}
+
+fn key(mac: [u8; 6]) -> [u8; 8] {
+    let mut k = [0u8; 8];
+    k[..6].copy_from_slice(&mac);
+    k
+}
+
+fn record(
+    ip: [u8; 4],
+    mask: [u8; 4],
+    router: [u8; 4],
+    dns: [u8; 4],
+    server: [u8; 4],
+    lease: u32,
+    mtu: u16,
+) -> [u8; 28] {
+    let mut r = [0u8; 28];
+    r[0..4].copy_from_slice(&ip);
+    r[4..8].copy_from_slice(&mask);
+    r[8..12].copy_from_slice(&router);
+    r[12..16].copy_from_slice(&dns);
+    r[16..20].copy_from_slice(&server);
+    r[20..24].copy_from_slice(&lease.to_be_bytes());
+    r[24..26].copy_from_slice(&mtu.to_be_bytes());
+    r
+}
+
+fn setup_full_record() {
+    register_map(records_map(), 8);
+    map_insert(
+        records_map(),
+        &key(MAC),
+        &record(IP, MASK, GW, DNS, GW, LEASE, MTU),
+    );
+}
+
+struct Request {
+    mac: [u8; 6],
+    xid: [u8; 4],
+    flags: [u8; 2],
+    ciaddr: [u8; 4],
+    giaddr: [u8; 4],
+    options: Vec<u8>,
+    dport: u16,
+    ethertype: [u8; 2],
+    ipproto: u8,
+    frag_off: [u8; 2],
+    ip_options: Vec<u8>,
+    op: u8,
+    htype: u8,
+    hlen: u8,
+}
+
+impl Default for Request {
+    fn default() -> Self {
+        Request {
+            mac: MAC,
+            xid: [0xde, 0xad, 0xbe, 0xef],
+            flags: [0, 0],
+            ciaddr: [0; 4],
+            giaddr: [0; 4],
+            options: DISCOVER.to_vec(),
+            dport: 67,
+            ethertype: [0x08, 0x00],
+            ipproto: 17,
+            frag_off: [0, 0],
+            ip_options: Vec::new(),
+            op: 1,
+            htype: 1,
+            hlen: 6,
+        }
+    }
+}
+
+impl Request {
+    fn build(&self) -> Vec<u8> {
+        assert_eq!(self.ip_options.len() % 4, 0);
+        let ihl = 5 + self.ip_options.len() / 4;
+        let payload = 8 + 236 + 4 + self.options.len() + 1;
+        let mut p = Vec::new();
+        // ethernet
+        p.extend_from_slice(&BCAST);
+        p.extend_from_slice(&self.mac);
+        p.extend_from_slice(&self.ethertype);
+        // ipv4
+        p.extend_from_slice(&[0x40 | ihl as u8, 0]);
+        p.extend_from_slice(&((ihl * 4 + payload) as u16).to_be_bytes());
+        p.extend_from_slice(&[0, 0]); // id
+        p.extend_from_slice(&self.frag_off);
+        p.extend_from_slice(&[64, self.ipproto, 0, 0]);
+        p.extend_from_slice(&[0, 0, 0, 0]); // saddr
+        p.extend_from_slice(&[255, 255, 255, 255]); // daddr
+        p.extend_from_slice(&self.ip_options);
+        // udp
+        p.extend_from_slice(&68u16.to_be_bytes());
+        p.extend_from_slice(&self.dport.to_be_bytes());
+        p.extend_from_slice(&(payload as u16).to_be_bytes());
+        p.extend_from_slice(&[0, 0]);
+        // bootp
+        p.extend_from_slice(&[self.op, self.htype, self.hlen, 0]);
+        p.extend_from_slice(&self.xid);
+        p.extend_from_slice(&[0, 0]); // secs
+        p.extend_from_slice(&self.flags);
+        p.extend_from_slice(&self.ciaddr);
+        p.extend_from_slice(&[0; 8]); // yiaddr, siaddr
+        p.extend_from_slice(&self.giaddr);
+        p.extend_from_slice(&self.mac);
+        p.extend_from_slice(&[0; 10]); // chaddr padding
+        p.extend_from_slice(&[0; 192]); // sname, file
+        // cookie and options
+        p.extend_from_slice(&[0x63, 0x82, 0x53, 0x63]);
+        p.extend_from_slice(&self.options);
+        p.push(255);
+        p
+    }
+
+    fn run(&self) -> (c_int, TestSkb) {
+        let mut skb = TestSkb::new(&self.build(), IFINDEX);
+        let verdict = skb.run(tc_dhcp_ingress);
+        (verdict, skb)
+    }
+}
+
+fn parse_opts(pkt: &[u8]) -> Vec<(u8, Vec<u8>)> {
+    let mut opts = Vec::new();
+    let mut off = OPTS_OFF;
+    loop {
+        let code = pkt[off];
+        if code == 255 {
+            break;
+        }
+        if code == 0 {
+            off += 1;
+            continue;
+        }
+        let len = pkt[off + 1] as usize;
+        opts.push((code, pkt[off + 2..off + 2 + len].to_vec()));
+        off += 2 + len;
+    }
+    opts
+}
+
+fn ip_csum_ok(pkt: &[u8]) -> bool {
+    let mut sum = 0u32;
+    for w in pkt[14..34].chunks(2) {
+        sum += u32::from(u16::from_be_bytes([w[0], w[1]]));
+    }
+    while sum > 0xffff {
+        sum = (sum & 0xffff) + (sum >> 16);
+    }
+    sum == 0xffff
+}
+
+/// The offset just past the end option of a reply.
+fn opts_end(pkt: &[u8]) -> usize {
+    let mut off = OPTS_OFF;
+    loop {
+        match pkt[off] {
+            255 => return off + 1,
+            0 => off += 1,
+            _ => off += 2 + pkt[off + 1] as usize,
+        }
+    }
+}
+
+/// What every reply has to look like whatever the request was. The addressing and the answer
+/// itself are the caller's to check.
+fn assert_reply_invariants(pkt: &[u8]) {
+    assert_eq!(&pkt[6..12], &SRC_MAC, "eth src");
+    assert_eq!(&pkt[12..14], &[0x08, 0x00], "ethertype");
+
+    assert_eq!(pkt[14], 0x45, "ihl/version");
+    assert_eq!(pkt[22], 64, "ttl");
+    assert_eq!(pkt[23], 17, "protocol");
+    assert_eq!(&pkt[26..30], &GW, "ip src is the server id");
+    assert!(ip_csum_ok(pkt), "ip checksum");
+    let tot_len = u16::from_be_bytes([pkt[16], pkt[17]]) as usize;
+    assert_eq!(tot_len, pkt.len() - 14, "ip total length");
+
+    assert_eq!(&pkt[34..36], &67u16.to_be_bytes(), "udp sport");
+    assert_eq!(&pkt[36..38], &68u16.to_be_bytes(), "udp dport");
+    let udp_len = u16::from_be_bytes([pkt[38], pkt[39]]) as usize;
+    assert_eq!(udp_len, pkt.len() - 34, "udp length");
+    assert_eq!(&pkt[40..42], &[0, 0], "udp checksum left zero");
+    assert!(pkt.len() - 42 >= 300, "bootp minimum message size");
+
+    assert_eq!(&pkt[42..45], &[2, 1, 6], "bootp reply op, htype, hlen");
+    assert_eq!(&pkt[46..50], &[0xde, 0xad, 0xbe, 0xef], "xid preserved");
+    assert_eq!(&pkt[70..76], &MAC, "chaddr");
+    assert_eq!(&pkt[278..282], &[0x63, 0x82, 0x53, 0x63], "cookie");
+    assert!(
+        matches!(parse_opts(pkt).first(), Some((53, t)) if t.len() == 1 && [2, 5, 6].contains(&t[0])),
+        "message type leads the options"
+    );
+    let end = opts_end(pkt);
+    assert!(
+        pkt[end..].iter().all(|&b| b == 0),
+        "padding after the end option is zero"
+    );
+}
+
+fn assert_reply_headers(pkt: &[u8], eth_dst: [u8; 6], ip_dst: [u8; 4], yiaddr: [u8; 4]) {
+    assert_reply_invariants(pkt);
+    assert_eq!(&pkt[0..6], &eth_dst, "eth dst");
+    assert_eq!(&pkt[30..34], &ip_dst, "ip dst");
+    assert_eq!(&pkt[58..62], &yiaddr, "yiaddr");
+}
+
+#[test]
+fn discover_gets_full_offer() {
+    setup_full_record();
+    let (verdict, skb) = Request::default().run();
+
+    assert_eq!(verdict, TC_ACT_REDIRECT);
+    assert_eq!(redirected(), Some(IFINDEX));
+
+    let pkt = skb.packet();
+    assert_reply_headers(pkt, MAC, [255, 255, 255, 255], IP);
+    assert_eq!(
+        parse_opts(pkt),
+        vec![
+            (53, vec![2]),
+            (54, GW.to_vec()),
+            (51, LEASE.to_be_bytes().to_vec()),
+            (1, MASK.to_vec()),
+            (3, GW.to_vec()),
+            (6, DNS.to_vec()),
+            (26, MTU.to_be_bytes().to_vec()),
+            (252, vec![b'\n']),
+        ],
+    );
+}
+
+#[test]
+fn offer_skips_unset_options() {
+    register_map(records_map(), 8);
+    map_insert(
+        records_map(),
+        &key(MAC),
+        &record(IP, MASK, [0; 4], [0; 4], GW, LEASE, 0),
+    );
+    let (verdict, skb) = Request::default().run();
+
+    assert_eq!(verdict, TC_ACT_REDIRECT);
+    let codes: Vec<u8> = parse_opts(skb.packet()).iter().map(|(c, _)| *c).collect();
+    assert_eq!(codes, vec![53, 54, 51, 1, 252]);
+}
+
+#[test]
+fn matching_request_gets_ack() {
+    setup_full_record();
+    let mut options = REQUEST.to_vec();
+    options.extend_from_slice(&[50, 4]);
+    options.extend_from_slice(&IP);
+    let (verdict, skb) = Request {
+        options,
+        ..Request::default()
+    }
+    .run();
+
+    assert_eq!(verdict, TC_ACT_REDIRECT);
+    let pkt = skb.packet();
+    assert_reply_headers(pkt, MAC, [255, 255, 255, 255], IP);
+    assert_eq!(parse_opts(pkt)[0], (53, vec![5]));
+}
+
+#[test]
+fn stale_request_gets_nak() {
+    setup_full_record();
+    let mut options = REQUEST.to_vec();
+    options.extend_from_slice(&[50, 4, 10, 0, 0, 77]);
+    let (verdict, skb) = Request {
+        options,
+        ..Request::default()
+    }
+    .run();
+
+    assert_eq!(verdict, TC_ACT_REDIRECT);
+    let pkt = skb.packet();
+    assert_reply_headers(pkt, BCAST, [255, 255, 255, 255], [0; 4]);
+    assert_eq!(parse_opts(pkt), vec![(53, vec![6]), (54, GW.to_vec())]);
+}
+
+#[test]
+fn renewal_is_answered_unicast() {
+    setup_full_record();
+    let (verdict, skb) = Request {
+        options: REQUEST.to_vec(),
+        ciaddr: IP,
+        ..Request::default()
+    }
+    .run();
+
+    assert_eq!(verdict, TC_ACT_REDIRECT);
+    let pkt = skb.packet();
+    assert_reply_headers(pkt, MAC, IP, IP);
+    assert_eq!(parse_opts(pkt)[0], (53, vec![5]));
+}
+
+#[test]
+fn request_selecting_this_server_gets_ack() {
+    setup_full_record();
+    let mut options = REQUEST.to_vec();
+    options.extend_from_slice(&[54, 4]);
+    options.extend_from_slice(&GW);
+    options.extend_from_slice(&[50, 4]);
+    options.extend_from_slice(&IP);
+    let (verdict, skb) = Request {
+        options,
+        ..Request::default()
+    }
+    .run();
+
+    assert_eq!(verdict, TC_ACT_REDIRECT);
+    assert_eq!(parse_opts(skb.packet())[0], (53, vec![5]));
+}
+
+#[test]
+fn request_selecting_another_server_passes_untouched() {
+    setup_full_record();
+    let mut options = REQUEST.to_vec();
+    options.extend_from_slice(&[54, 4, 192, 168, 7, 7]);
+    options.extend_from_slice(&[50, 4, 10, 0, 0, 77]);
+    let req = Request {
+        options,
+        ..Request::default()
+    };
+    let (verdict, skb) = req.run();
+
+    assert_eq!(verdict, TC_ACT_OK);
+    assert_eq!(redirected(), None);
+    assert_eq!(skb.packet(), &req.build()[..]);
+}
+
+#[test]
+fn relayed_request_passes_untouched() {
+    setup_full_record();
+    let req = Request {
+        giaddr: [10, 0, 0, 254],
+        ..Request::default()
+    };
+    let (verdict, skb) = req.run();
+
+    assert_eq!(verdict, TC_ACT_OK);
+    assert_eq!(redirected(), None);
+    assert_eq!(skb.packet(), &req.build()[..]);
+}
+
+#[test]
+fn fragments_pass_untouched() {
+    setup_full_record();
+    for frag_off in [[0x20, 0x00], [0x00, 0x01]] {
+        let req = Request {
+            frag_off,
+            ..Request::default()
+        };
+        let (verdict, skb) = req.run();
+        assert_eq!(verdict, TC_ACT_OK);
+        assert_eq!(skb.packet(), &req.build()[..]);
+    }
+
+    let (verdict, _) = Request {
+        frag_off: [0x40, 0x00],
+        ..Request::default()
+    }
+    .run();
+    assert_eq!(verdict, TC_ACT_REDIRECT, "DF alone is not a fragment");
+}
+
+#[test]
+fn broadcast_flag_gets_l2_broadcast_reply() {
+    setup_full_record();
+    let (verdict, skb) = Request {
+        flags: [0x80, 0],
+        ..Request::default()
+    }
+    .run();
+
+    assert_eq!(verdict, TC_ACT_REDIRECT);
+    let pkt = skb.packet();
+    assert_reply_headers(pkt, BCAST, [255, 255, 255, 255], IP);
+    assert_eq!(&pkt[52..54], &[0x80, 0], "flags echoed");
+
+    // ciaddr wins over the flag, a renewing client is addressable
+    let (verdict, skb) = Request {
+        flags: [0x80, 0],
+        ciaddr: IP,
+        options: REQUEST.to_vec(),
+        ..Request::default()
+    }
+    .run();
+    assert_eq!(verdict, TC_ACT_REDIRECT);
+    assert_reply_headers(skb.packet(), MAC, IP, IP);
+}
+
+#[test]
+fn unknown_mac_passes_untouched() {
+    register_map(records_map(), 8);
+    let req = Request::default();
+    let (verdict, skb) = req.run();
+
+    assert_eq!(verdict, TC_ACT_OK);
+    assert_eq!(redirected(), None);
+    assert_eq!(skb.packet(), &req.build()[..]);
+}
+
+#[test]
+fn non_dhcp_traffic_passes_untouched() {
+    setup_full_record();
+    let variants = [
+        Request {
+            dport: 53,
+            ..Request::default()
+        },
+        Request {
+            ethertype: [0x86, 0xdd],
+            ..Request::default()
+        },
+        Request {
+            ipproto: 6,
+            ..Request::default()
+        },
+    ];
+    for req in variants {
+        let (verdict, skb) = req.run();
+        assert_eq!(verdict, TC_ACT_OK);
+        assert_eq!(skb.packet(), &req.build()[..]);
+    }
+
+    let frame = Request::default().build();
+    let mut skb = TestSkb::new(&frame[..100], IFINDEX);
+    assert_eq!(skb.run(tc_dhcp_ingress), TC_ACT_OK);
+
+    let mut corrupted = frame.clone();
+    corrupted[278] = 0;
+    let mut skb = TestSkb::new(&corrupted, IFINDEX);
+    assert_eq!(skb.run(tc_dhcp_ingress), TC_ACT_OK);
+}
+
+#[test]
+fn options_walk_handles_padding_and_early_end() {
+    setup_full_record();
+
+    let mut options = vec![0, 0, 0];
+    options.extend_from_slice(DISCOVER);
+    let (verdict, _) = Request {
+        options,
+        ..Request::default()
+    }
+    .run();
+    assert_eq!(verdict, TC_ACT_REDIRECT);
+
+    let mut options = vec![255];
+    options.extend_from_slice(DISCOVER);
+    let (verdict, skb) = Request {
+        options,
+        ..Request::default()
+    }
+    .run();
+    assert_eq!(verdict, TC_ACT_OK, "message type after end is not parsed");
+    assert_eq!(redirected(), None);
+    drop(skb);
+}
+
+#[test]
+fn stale_renewal_gets_broadcast_nak() {
+    setup_full_record();
+    let (verdict, skb) = Request {
+        options: REQUEST.to_vec(),
+        ciaddr: [10, 0, 0, 77],
+        ..Request::default()
+    }
+    .run();
+
+    assert_eq!(verdict, TC_ACT_REDIRECT);
+    let pkt = skb.packet();
+    assert_reply_headers(pkt, BCAST, [255, 255, 255, 255], [0; 4]);
+    assert_eq!(parse_opts(pkt), vec![(53, vec![6]), (54, GW.to_vec())]);
+}
+
+#[test]
+fn request_without_address_gets_nak() {
+    setup_full_record();
+    let (verdict, skb) = Request {
+        options: REQUEST.to_vec(),
+        ..Request::default()
+    }
+    .run();
+
+    assert_eq!(verdict, TC_ACT_REDIRECT);
+    let pkt = skb.packet();
+    assert_reply_headers(pkt, BCAST, [255, 255, 255, 255], [0; 4]);
+    assert_eq!(parse_opts(pkt)[0], (53, vec![6]));
+}
+
+#[test]
+fn realistic_request_is_answered_and_shrunk() {
+    setup_full_record();
+    let mut options = REQUEST.to_vec();
+    // a client identifier naming something else, records are keyed by chaddr alone
+    options.extend_from_slice(&[61, 7, 1, 0xde, 0xad, 0xbe, 0xef, 0x00, 0x01]);
+    options.extend_from_slice(&[50, 4]);
+    options.extend_from_slice(&IP);
+    options.extend_from_slice(&[54, 4]);
+    options.extend_from_slice(&GW);
+    options.extend_from_slice(&[12, 14]);
+    options.extend_from_slice(b"dhcp-client-01");
+    options.extend_from_slice(&[60, 32]);
+    options.extend_from_slice(b"PXEClient:Arch:00007:UNDI:003016");
+    options.extend_from_slice(&[55, 20]);
+    options.extend_from_slice(&[
+        1, 2, 3, 6, 12, 15, 17, 26, 28, 40, 41, 42, 51, 54, 58, 59, 66, 67, 119, 121,
+    ]);
+    options.extend_from_slice(&[57, 2, 0x05, 0xdc]);
+    options.extend_from_slice(&[93, 2, 0, 7]);
+    options.extend_from_slice(&[94, 3, 1, 3, 16]);
+    options.extend_from_slice(&[97, 17, 0]);
+    options.extend_from_slice(&[0x11; 16]);
+    let req = Request {
+        options,
+        ..Request::default()
+    };
+    let request_len = req.build().len();
+    let (verdict, skb) = req.run();
+
+    assert_eq!(verdict, TC_ACT_REDIRECT);
+    let pkt = skb.packet();
+    assert!(pkt.len() < request_len, "reply is shorter than the request");
+    assert_eq!(
+        pkt.len(),
+        14 + 20 + 8 + 300,
+        "trimmed to the padded minimum"
+    );
+    assert_reply_headers(pkt, MAC, [255, 255, 255, 255], IP);
+    assert_eq!(parse_opts(pkt)[0], (53, vec![5]));
+}
+
+#[test]
+fn other_message_types_pass_untouched() {
+    setup_full_record();
+    for msgtype in [4, 7, 8] {
+        let req = Request {
+            options: vec![53, 1, msgtype],
+            ..Request::default()
+        };
+        let (verdict, skb) = req.run();
+        assert_eq!(verdict, TC_ACT_OK, "message type {msgtype}");
+        assert_eq!(redirected(), None);
+        assert_eq!(skb.packet(), &req.build()[..]);
+    }
+}
+
+#[test]
+fn foreign_bootp_passes_untouched() {
+    setup_full_record();
+    let variants = [
+        // a reply coming in from the guest side, a guest running its own server
+        Request {
+            op: 2,
+            ..Request::default()
+        },
+        Request {
+            htype: 6,
+            ..Request::default()
+        },
+        Request {
+            hlen: 16,
+            ..Request::default()
+        },
+        Request {
+            ip_options: vec![1, 1, 1, 1],
+            ..Request::default()
+        },
+    ];
+    for req in variants {
+        let (verdict, skb) = req.run();
+        assert_eq!(verdict, TC_ACT_OK);
+        assert_eq!(redirected(), None);
+        assert_eq!(skb.packet(), &req.build()[..]);
+    }
+}
+
+#[test]
+fn truncated_option_ends_the_walk() {
+    setup_full_record();
+
+    // a length running past the packet before the message type leaves nothing to answer
+    let mut options = vec![12, 200];
+    options.extend_from_slice(DISCOVER);
+    let req = Request {
+        options,
+        ..Request::default()
+    };
+    let (verdict, skb) = req.run();
+    assert_eq!(verdict, TC_ACT_OK);
+    assert_eq!(redirected(), None);
+    assert_eq!(skb.packet(), &req.build()[..]);
+
+    // after the message type it only cuts the walk short, what was read still counts
+    let mut options = DISCOVER.to_vec();
+    options.extend_from_slice(&[12, 200]);
+    let (verdict, skb) = Request {
+        options,
+        ..Request::default()
+    }
+    .run();
+    assert_eq!(verdict, TC_ACT_REDIRECT);
+    assert_reply_headers(skb.packet(), MAC, [255, 255, 255, 255], IP);
+}
+
+#[test]
+fn arbitrary_option_bytes_never_break_the_reply() {
+    setup_full_record();
+    let mut lcg = 0x2545f4914f6cdd1du64;
+    let mut rand = move || {
+        lcg = lcg
+            .wrapping_mul(6364136223846793005)
+            .wrapping_add(1442695040888963407);
+        (lcg >> 33) as u8
+    };
+
+    for _ in 0..500 {
+        let len = (rand() % 60) as usize;
+        let options: Vec<u8> = (0..len).map(|_| rand()).collect();
+        let (verdict, skb) = Request {
+            options,
+            ..Request::default()
+        }
+        .run();
+        assert!(verdict == TC_ACT_OK || verdict == TC_ACT_REDIRECT);
+        if verdict == TC_ACT_REDIRECT {
+            assert_reply_invariants(skb.packet());
+        }
+    }
+}
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 14+ messages in thread

* [PATCH proxmox-ebpf 02/12] dhcp: add responder subsystem
  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 proxmox-ebpf 01/12] dhcp: add per-tap responder BPF program Hannes Laimer
@ 2026-09-02 12:47 ` Hannes Laimer
  2026-09-02 12:47 ` [PATCH proxmox-perl-rs 03/12] pve-rs: sdn: add dhcp responder bindings Hannes Laimer
                   ` (10 subsequent siblings)
  12 siblings, 0 replies; 14+ messages in thread
From: Hannes Laimer @ 2026-09-02 12:47 UTC (permalink / raw)
  To: pve-devel

The record map is fed entirely by the consumer, so the subsystem needs
no state source of its own. Single pushes and a mark-and-sweep sync
are the only writers, the full apply keeps just the programs and link
pins current and never touches records.

Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
 src/dhcp/mod.rs   | 288 ++++++++++++++++++++++++++++++++++++++++++++++
 src/dhcp/types.rs |  53 +++++++++
 src/lib.rs        |   3 +
 src/subsystem.rs  |  35 ++++++
 4 files changed, 379 insertions(+)
 create mode 100644 src/dhcp/mod.rs
 create mode 100644 src/dhcp/types.rs

diff --git a/src/dhcp/mod.rs b/src/dhcp/mod.rs
new file mode 100644
index 0000000..96eeb7d
--- /dev/null
+++ b/src/dhcp/mod.rs
@@ -0,0 +1,288 @@
+//! The dhcp subsystem, a per-tap DHCPv4 responder answering from a pinned per-MAC record map.
+//!
+//! The subsystem has no state source of its own, the consumer hands in [`Record`]s, each carrying
+//! everything a reply needs. This side owns only the map and program mechanics.
+
+mod types;
+
+use std::collections::HashMap;
+use std::fs::File;
+use std::net::Ipv4Addr;
+
+use anyhow::{Context, bail};
+use aya::include_bytes_aligned;
+use nix::fcntl::Flock;
+
+use self::types::*;
+use crate::subsystem::TcPrograms;
+use crate::tc::Direction;
+
+/// One MAC's answer, the address and every option the reply carries.
+pub struct Record {
+    pub mac: [u8; 6],
+    pub ip: Ipv4Addr,
+    pub prefixlen: u8,
+    pub server_id: Ipv4Addr,
+    pub lease: u32,
+    pub router: Option<Ipv4Addr>,
+    pub dns: Option<Ipv4Addr>,
+    pub mtu: Option<u16>,
+}
+
+const NAME: &str = "dhcp";
+const RECORDS_MAP: &str = "dhcp_records";
+
+const DHCP_OBJ: &[u8] = include_bytes_aligned!(concat!(env!("OUT_DIR"), "/dhcp.bpf.o"));
+const DHCP_FINGERPRINT: u64 = TcPrograms::obj_fingerprint(DHCP_OBJ);
+
+// BUMP THIS when a semantically-incompatible change to a map definition in dhcp.bpf.c is made
+const SCHEMA_VERSION: u32 = 1;
+
+// requests only arrive from the guest side and replies leave through a redirect, so only ingress
+// carries a program
+const DIRECTIONS: [Direction; 1] = [Direction::Ingress];
+
+fn program_name(dir: Direction) -> &'static str {
+    match dir {
+        Direction::Ingress => "tc_dhcp_ingress",
+        Direction::Egress => unreachable!("dhcp is ingress-only"),
+    }
+}
+
+pub struct DhcpSubsystem {
+    programs: TcPrograms,
+}
+
+impl DhcpSubsystem {
+    pub fn new() -> Self {
+        Self {
+            programs: TcPrograms::new(
+                NAME,
+                DHCP_OBJ,
+                DHCP_FINGERPRINT,
+                program_name,
+                &DIRECTIONS,
+                Some(SCHEMA_VERSION),
+            ),
+        }
+    }
+
+    /// The full-pass share, make sure the programs are current and drop link pins of departed
+    /// interfaces. Records are not touched, they live in the pinned map and only their pushers
+    /// change them.
+    pub fn apply(&mut self) -> anyhow::Result<()> {
+        let _lock = self.programs.lock_exclusive()?;
+        self.programs.ensure_loaded()?;
+        self.programs.prune_dead_links();
+        Ok(())
+    }
+
+    pub fn clear(&self) -> anyhow::Result<()> {
+        self.programs.clear()
+    }
+
+    /// Attach the responder to one guest interface (a tap_plug).
+    pub fn attach(&mut self, iface: &str) -> anyhow::Result<()> {
+        let Ok(ifindex) = nix::net::if_::if_nametoindex(iface) else {
+            log::info!("dhcp attach: {iface} is gone, nothing to do");
+            return Ok(());
+        };
+        let _lock = self.ready()?;
+        self.programs
+            .attach_iface(ifindex)
+            .with_context(|| format!("dhcp responder for {iface}"))
+    }
+
+    /// Upsert the given records.
+    pub fn update(&mut self, records: &[Record]) -> anyhow::Result<()> {
+        let records = map_entries(records)?;
+        let _lock = self.ready()?;
+        let mut map = self
+            .programs
+            .hash_map::<DhcpMacKey, DhcpRecord>(RECORDS_MAP)?;
+        for (key, rec) in &records {
+            map.insert(key, rec, 0)?;
+            log::info!(
+                "dhcp: record {} -> {}",
+                fmt_mac(&key.addr),
+                Ipv4Addr::from(u32::from_be(rec.ip)),
+            );
+        }
+        Ok(())
+    }
+
+    pub fn remove(&mut self, mac: [u8; 6]) -> anyhow::Result<()> {
+        let key = DhcpMacKey {
+            addr: mac,
+            _pad: [0; 2],
+        };
+        let _lock = self.ready()?;
+        let mut map = self
+            .programs
+            .hash_map::<DhcpMacKey, DhcpRecord>(RECORDS_MAP)?;
+        let _ = map.remove(&key);
+        log::info!("dhcp: removed record for {}", fmt_mac(&mac));
+        Ok(())
+    }
+
+    /// Replace the record set with the given one, write what changed, drop what is no
+    /// longer there.
+    pub fn sync(&mut self, records: &[Record]) -> anyhow::Result<()> {
+        let desired = map_entries(records)?;
+        let _lock = self.programs.lock_exclusive()?;
+        self.programs.ensure_loaded()?;
+        let mut map = self
+            .programs
+            .hash_map::<DhcpMacKey, DhcpRecord>(RECORDS_MAP)?;
+        let live: HashMap<DhcpMacKey, DhcpRecord> = map.iter().filter_map(|r| r.ok()).collect();
+        let mut written = 0usize;
+        for (key, rec) in &desired {
+            if live.get(key) != Some(rec) {
+                map.insert(key, rec, 0)?;
+                written += 1;
+            }
+        }
+        let stale: Vec<_> = live
+            .keys()
+            .filter(|k| !desired.contains_key(k))
+            .copied()
+            .collect();
+        for key in &stale {
+            let _ = map.remove(key);
+        }
+        if written + stale.len() > 0 {
+            log::info!("dhcp: {written} records written, {} removed", stale.len());
+        } else {
+            log::debug!("dhcp: {} records, no changes", desired.len());
+        }
+        Ok(())
+    }
+
+    /// Take the apply lock for a map write or single attach. Shared while the programs are
+    /// current so concurrent pushes don't serialize, exclusive with an install when they are not.
+    fn ready(&mut self) -> anyhow::Result<Flock<File>> {
+        if self.programs.is_current() {
+            self.programs.lock_shared()
+        } else {
+            let lock = self.programs.lock_exclusive()?;
+            self.programs.ensure_loaded()?;
+            Ok(lock)
+        }
+    }
+}
+
+fn map_entries(records: &[Record]) -> anyhow::Result<HashMap<DhcpMacKey, DhcpRecord>> {
+    records
+        .iter()
+        .map(|r| {
+            r.map_entry()
+                .with_context(|| format!("record for {}", fmt_mac(&r.mac)))
+        })
+        .collect()
+}
+
+impl Record {
+    fn map_entry(&self) -> anyhow::Result<(DhcpMacKey, DhcpRecord)> {
+        if self.prefixlen > 32 {
+            bail!("prefixlen {} out of range", self.prefixlen);
+        }
+        let netmask = if self.prefixlen == 0 {
+            0
+        } else {
+            u32::MAX << (32 - self.prefixlen)
+        };
+        Ok((
+            DhcpMacKey {
+                addr: self.mac,
+                _pad: [0; 2],
+            },
+            DhcpRecord {
+                ip: u32::from(self.ip).to_be(),
+                netmask: netmask.to_be(),
+                router: u32::from(self.router.unwrap_or(Ipv4Addr::UNSPECIFIED)).to_be(),
+                dns: u32::from(self.dns.unwrap_or(Ipv4Addr::UNSPECIFIED)).to_be(),
+                server_id: u32::from(self.server_id).to_be(),
+                lease: self.lease.to_be(),
+                mtu: self.mtu.unwrap_or(0).to_be(),
+                _pad: [0; 2],
+            },
+        ))
+    }
+}
+
+fn fmt_mac(addr: &[u8; 6]) -> String {
+    format!(
+        "{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
+        addr[0], addr[1], addr[2], addr[3], addr[4], addr[5]
+    )
+}
+
+#[cfg(test)]
+mod test {
+    use super::*;
+
+    fn record() -> Record {
+        Record {
+            mac: [0xda, 0x65, 0x8f, 0x18, 0x9b, 0x6f],
+            ip: Ipv4Addr::new(10, 0, 0, 100),
+            prefixlen: 24,
+            server_id: Ipv4Addr::new(10, 0, 0, 1),
+            lease: 300,
+            router: Some(Ipv4Addr::new(10, 0, 0, 1)),
+            dns: Some(Ipv4Addr::new(1, 1, 1, 1)),
+            mtu: Some(1500),
+        }
+    }
+
+    #[test]
+    fn converts_full_record() {
+        let (key, rec) = record().map_entry().unwrap();
+        assert_eq!(key.addr, [0xda, 0x65, 0x8f, 0x18, 0x9b, 0x6f]);
+        assert_eq!(
+            u32::from_be(rec.ip),
+            u32::from(Ipv4Addr::new(10, 0, 0, 100))
+        );
+        assert_eq!(u32::from_be(rec.netmask), 0xffffff00);
+        assert_eq!(
+            u32::from_be(rec.router),
+            u32::from(Ipv4Addr::new(10, 0, 0, 1))
+        );
+        assert_eq!(u32::from_be(rec.dns), u32::from(Ipv4Addr::new(1, 1, 1, 1)));
+        assert_eq!(
+            u32::from_be(rec.server_id),
+            u32::from(Ipv4Addr::new(10, 0, 0, 1))
+        );
+        assert_eq!(u32::from_be(rec.lease), 300);
+        assert_eq!(u16::from_be(rec.mtu), 1500);
+    }
+
+    #[test]
+    fn absent_options_read_as_zero() {
+        let rec = Record {
+            prefixlen: 32,
+            router: None,
+            dns: None,
+            mtu: None,
+            ..record()
+        };
+        let (_, rec) = rec.map_entry().unwrap();
+        assert_eq!(rec.router, 0);
+        assert_eq!(rec.dns, 0);
+        assert_eq!(rec.mtu, 0);
+        assert_eq!(u32::from_be(rec.netmask), 0xffffffff);
+    }
+
+    #[test]
+    fn rejects_out_of_range_prefixlen() {
+        let rec = Record {
+            prefixlen: 33,
+            ..record()
+        };
+        assert!(rec.map_entry().is_err());
+        let rec = Record {
+            prefixlen: 0,
+            ..record()
+        };
+        assert_eq!(u32::from_be(rec.map_entry().unwrap().1.netmask), 0);
+    }
+}
diff --git a/src/dhcp/types.rs b/src/dhcp/types.rs
new file mode 100644
index 0000000..38ea36b
--- /dev/null
+++ b/src/dhcp/types.rs
@@ -0,0 +1,53 @@
+//! Keep in sync with 'bpf/types.h'.
+
+#[repr(C)]
+#[derive(Copy, Clone, Hash, PartialEq, Eq)]
+pub struct DhcpMacKey {
+    pub addr: [u8; 6],
+    pub _pad: [u8; 2],
+}
+
+/// Addresses, lease and mtu are stored in network byte order, so a reply copies them into the
+/// packet as-is.
+#[repr(C)]
+#[derive(Copy, Clone, PartialEq)]
+pub struct DhcpRecord {
+    pub ip: u32,
+    pub netmask: u32,
+    /// 0 = not served
+    pub router: u32,
+    /// 0 = not served
+    pub dns: u32,
+    pub server_id: u32,
+    pub lease: u32,
+    /// 0 = not served
+    pub mtu: u16,
+    pub _pad: [u8; 2],
+}
+
+unsafe impl aya::Pod for DhcpMacKey {}
+unsafe impl aya::Pod for DhcpRecord {}
+
+#[cfg(test)]
+mod layout {
+    use core::mem::{offset_of, size_of};
+
+    use super::*;
+
+    #[test]
+    fn matches_bpf_abi() {
+        assert_eq!(size_of::<DhcpMacKey>(), 8);
+        assert_eq!(offset_of!(DhcpMacKey, addr), 0);
+        assert_eq!(offset_of!(DhcpMacKey, _pad), 6);
+
+        assert_eq!(size_of::<DhcpRecord>(), 28);
+        assert_eq!(offset_of!(DhcpRecord, ip), 0);
+        assert_eq!(offset_of!(DhcpRecord, netmask), 4);
+        assert_eq!(offset_of!(DhcpRecord, router), 8);
+        assert_eq!(offset_of!(DhcpRecord, dns), 12);
+        assert_eq!(offset_of!(DhcpRecord, server_id), 16);
+        assert_eq!(offset_of!(DhcpRecord, lease), 20);
+        assert_eq!(offset_of!(DhcpRecord, mtu), 24);
+        assert_eq!(offset_of!(DhcpRecord, _pad), 26);
+    }
+}
diff --git a/src/lib.rs b/src/lib.rs
index 572d861..a8c94ba 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,5 +1,8 @@
 //! eBPF subsystems, one per concern, each behind a cargo feature of its name. Nothing here runs
 //! on its own, consumers pull in only the subsystem they drive.
 
+#[cfg(feature = "dhcp")]
+pub mod dhcp;
+
 pub mod subsystem;
 pub mod tc;
diff --git a/src/subsystem.rs b/src/subsystem.rs
index 466e5cb..144845c 100644
--- a/src/subsystem.rs
+++ b/src/subsystem.rs
@@ -264,6 +264,23 @@ impl TcPrograms {
         tc::read_pinned_links(&self.links_dir())
     }
 
+    /// Drop link pins whose interface is gone. For subsystems without a desired set to
+    /// [`reconcile`](Self::reconcile) against, where the attach set is whatever was plugged, so
+    /// pins of departed interfaces would collect indefinitely. Best effort, runs under the
+    /// caller's exclusive lock.
+    pub fn prune_dead_links(&self) {
+        for (ifindex, dir) in self.live_links().unwrap_or_default() {
+            if iface_exists(ifindex) {
+                continue;
+            }
+            let path = self.link_pin_path(ifindex, dir);
+            if let Err(e) = tc::detach_pinned_link(&path) {
+                log::debug!("{}: prune {ifindex}-{}: {e:#}", self.name, dir.as_str());
+                let _ = std::fs::remove_file(&path);
+            }
+        }
+    }
+
     fn swap_existing_links(&self) {
         let live = match self.live_links() {
             Ok(l) => l,
@@ -390,3 +407,21 @@ impl TcPrograms {
         Ok(())
     }
 }
+
+/// Whether an interface with this index exists. nix treats only a -1 pointer as the error
+/// sentinel, so the NULL `if_indextoname` returns for a vanished index comes back as Ok with an
+/// empty name.
+fn iface_exists(ifindex: u32) -> bool {
+    nix::net::if_::if_indextoname(ifindex).is_ok_and(|name| !name.as_bytes().is_empty())
+}
+
+#[cfg(test)]
+mod test {
+    use super::*;
+
+    #[test]
+    fn vanished_index_is_not_alive() {
+        assert!(iface_exists(1));
+        assert!(!iface_exists(u32::MAX - 1));
+    }
+}
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 14+ messages in thread

* [PATCH proxmox-perl-rs 03/12] pve-rs: sdn: add dhcp responder bindings
  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 proxmox-ebpf 01/12] dhcp: add per-tap responder BPF program Hannes Laimer
  2026-09-02 12:47 ` [PATCH proxmox-ebpf 02/12] dhcp: add responder subsystem Hannes Laimer
@ 2026-09-02 12:47 ` Hannes Laimer
  2026-09-02 12:47 ` [PATCH pve-network 04/12] sdn: ipam: do not cache negative per-MAC answers, lock the write Hannes Laimer
                   ` (9 subsequent siblings)
  12 siblings, 0 replies; 14+ messages in thread
From: Hannes Laimer @ 2026-09-02 12:47 UTC (permalink / raw)
  To: pve-devel

The records are handed straight to the responder subsystem of
proxmox-ebpf, single mapping pushes upsert or drop one record and a
sync does a full mark-and-sweep resync.

Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
 pve-rs/Cargo.toml               |  2 +
 pve-rs/Makefile                 |  1 +
 pve-rs/debian/control           |  2 +
 pve-rs/src/bindings/sdn/dhcp.rs | 91 +++++++++++++++++++++++++++++++++
 pve-rs/src/bindings/sdn/mod.rs  |  1 +
 5 files changed, 97 insertions(+)
 create mode 100644 pve-rs/src/bindings/sdn/dhcp.rs

diff --git a/pve-rs/Cargo.toml b/pve-rs/Cargo.toml
index 5ae9082..0adf00b 100644
--- a/pve-rs/Cargo.toml
+++ b/pve-rs/Cargo.toml
@@ -34,6 +34,7 @@ proxmox-apt = { version = "1.0.1", features = ["cache"] }
 proxmox-apt-api-types = "3"
 proxmox-base64 = "1"
 proxmox-config-digest = "1"
+proxmox-ebpf = { version = "0.1", features = ["dhcp"] }
 proxmox-frr = { version = "0.5.1" }
 proxmox-http = { version = "1.0.2", features = ["client-sync", "client-trait"] }
 proxmox-http-error = "1"
@@ -70,6 +71,7 @@ proxmox-wireguard = { version = "0.1.2" }
 # proxmox-config-digest = { path = "../../proxmox/proxmox-config-digest" }
 # proxmox-daemon = { path = "../../proxmox/proxmox-daemon" }
 # proxmox-dns-api = { path = "../../proxmox/proxmox-dns-api" }
+# proxmox-ebpf = { path = "../../proxmox-ebpf" }
 # proxmox-http-error = { path = "../../proxmox/proxmox-http-error" }
 # proxmox-http = { path = "../../proxmox/proxmox-http" }
 # proxmox-human-byte = { path = "../../proxmox/proxmox-human-byte" }
diff --git a/pve-rs/Makefile b/pve-rs/Makefile
index bb1cd2d..7609a1e 100644
--- a/pve-rs/Makefile
+++ b/pve-rs/Makefile
@@ -32,6 +32,7 @@ PERLMOD_PACKAGES := \
 	  PVE::RS::OpenId \
 	  PVE::RS::ResourceScheduling::Static \
 	  PVE::RS::ResourceScheduling::Dynamic \
+	  PVE::RS::SDN::Dhcp \
 	  PVE::RS::SDN::Fabrics \
 	  PVE::RS::SDN::PrefixLists \
 	  PVE::RS::SDN::RouteMaps \
diff --git a/pve-rs/debian/control b/pve-rs/debian/control
index 70367dc..521d966 100644
--- a/pve-rs/debian/control
+++ b/pve-rs/debian/control
@@ -20,6 +20,8 @@ Build-Depends: debhelper-compat (= 13),
                librust-proxmox-apt-api-types-3+default-dev (>= 3.0.0-~~),
                librust-proxmox-base64-1+default-dev,
                librust-proxmox-config-digest-1+default-dev,
+               librust-proxmox-ebpf-0.1+default-dev,
+               librust-proxmox-ebpf-0.1+dhcp-dev,
                librust-proxmox-frr-0.5+default-dev (>= 0.5.1-~~),
                librust-proxmox-http-1+client-sync-dev (>= 1.0.2-~~),
                librust-proxmox-http-1+client-trait-dev (>= 1.0.2-~~),
diff --git a/pve-rs/src/bindings/sdn/dhcp.rs b/pve-rs/src/bindings/sdn/dhcp.rs
new file mode 100644
index 0000000..4bea98f
--- /dev/null
+++ b/pve-rs/src/bindings/sdn/dhcp.rs
@@ -0,0 +1,91 @@
+#[perlmod::package(name = "PVE::RS::SDN::Dhcp", lib = "pve_rs")]
+pub mod pve_rs_sdn_dhcp {
+    //! The `PVE::RS::SDN::Dhcp` package.
+    //!
+    //! Bindings for the per-tap eBPF DHCP responder. Records handed in here are
+    //! answered directly from the kernel.
+
+    use std::net::Ipv4Addr;
+
+    use anyhow::{Context, Error, bail};
+    use serde::Deserialize;
+
+    use proxmox_ebpf::dhcp::{DhcpSubsystem, Record};
+
+    /// One MAC's DHCP answer, the address and every option the reply carries.
+    #[derive(Deserialize)]
+    pub struct DhcpRecord {
+        mac: String,
+        ip: Ipv4Addr,
+        prefixlen: u8,
+        server_id: Ipv4Addr,
+        lease: u32,
+        router: Option<Ipv4Addr>,
+        dns: Option<Ipv4Addr>,
+        mtu: Option<u16>,
+    }
+
+    fn parse_mac(s: &str) -> Result<[u8; 6], Error> {
+        let mut addr = [0u8; 6];
+        let mut n = 0;
+        for part in s.split(':') {
+            if n >= 6 || part.len() != 2 {
+                bail!("invalid MAC {s:?}");
+            }
+            addr[n] = u8::from_str_radix(part, 16).with_context(|| format!("invalid MAC {s:?}"))?;
+            n += 1;
+        }
+        if n != 6 {
+            bail!("invalid MAC {s:?}");
+        }
+        Ok(addr)
+    }
+
+    fn to_records(records: Vec<DhcpRecord>) -> Result<Vec<Record>, Error> {
+        records
+            .into_iter()
+            .map(|r| {
+                Ok(Record {
+                    mac: parse_mac(&r.mac)?,
+                    ip: r.ip,
+                    prefixlen: r.prefixlen,
+                    server_id: r.server_id,
+                    lease: r.lease,
+                    router: r.router,
+                    dns: r.dns,
+                    mtu: r.mtu,
+                })
+            })
+            .collect()
+    }
+
+    /// Make sure the responder programs are current and drop link pins of departed interfaces.
+    #[export]
+    pub fn apply() -> Result<(), Error> {
+        DhcpSubsystem::new().apply()
+    }
+
+    /// Attach the responder to a guest interface.
+    #[export]
+    pub fn attach(iface: &str) -> Result<(), Error> {
+        DhcpSubsystem::new().attach(iface)
+    }
+
+    /// Upsert the given records.
+    #[export]
+    pub fn update(records: Vec<DhcpRecord>) -> Result<(), Error> {
+        DhcpSubsystem::new().update(&to_records(records)?)
+    }
+
+    /// Drop one MAC's record.
+    #[export]
+    pub fn remove(mac: &str) -> Result<(), Error> {
+        DhcpSubsystem::new().remove(parse_mac(mac)?)
+    }
+
+    /// Replace the record set with the given one.
+    #[export]
+    pub fn sync(records: Vec<DhcpRecord>) -> Result<(), Error> {
+        DhcpSubsystem::new().sync(&to_records(records)?)
+    }
+}
diff --git a/pve-rs/src/bindings/sdn/mod.rs b/pve-rs/src/bindings/sdn/mod.rs
index dcae046..4b99b8f 100644
--- a/pve-rs/src/bindings/sdn/mod.rs
+++ b/pve-rs/src/bindings/sdn/mod.rs
@@ -1,3 +1,4 @@
+pub(crate) mod dhcp;
 pub(crate) mod fabrics;
 pub(crate) mod prefix_lists;
 pub(crate) mod route_maps;
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 14+ messages in thread

* [PATCH pve-network 04/12] sdn: ipam: do not cache negative per-MAC answers, lock the write
  2026-09-02 12:47 [RFC manager/network/proxmox{-ebpf,-perl-rs} 00/12] sdn: implement DHCP for all zones using eBPF Hannes Laimer
                   ` (2 preceding siblings ...)
  2026-09-02 12:47 ` [PATCH proxmox-perl-rs 03/12] pve-rs: sdn: add dhcp responder bindings Hannes Laimer
@ 2026-09-02 12:47 ` Hannes Laimer
  2026-09-02 12:47 ` [PATCH pve-network 05/12] sdn: subnets: add dhcp-lease-time property Hannes Laimer
                   ` (8 subsequent siblings)
  12 siblings, 0 replies; 14+ messages in thread
From: Hannes Laimer @ 2026-09-02 12:47 UTC (permalink / raw)
  To: pve-devel

The lookup cache wrote an entry even when the plugin returned
nothing, and that entry short-circuits every later lookup, so a
record created after the first miss was never seen again. The
read-modify-write also ran without the cluster lock the other cache
writers take, allowing concurrent lookups to drop each other's
entries.

Only cache actual answers and take the lock for the write, keeping
the common cache-hit path lock-free.

Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
 src/PVE/Network/SDN/Ipams.pm | 20 ++++++++++++++++----
 1 file changed, 16 insertions(+), 4 deletions(-)

diff --git a/src/PVE/Network/SDN/Ipams.pm b/src/PVE/Network/SDN/Ipams.pm
index 179bdf7..9292386 100644
--- a/src/PVE/Network/SDN/Ipams.pm
+++ b/src/PVE/Network/SDN/Ipams.pm
@@ -140,12 +140,24 @@ sub get_ips_from_mac {
 
     my $plugin_config = get_plugin_config($zone);
     my $plugin = PVE::Network::SDN::Ipams::Plugin->lookup($plugin_config->{type});
-    ($macdb->{macs}->{$mac}->{ip4}, $macdb->{macs}->{$mac}->{ip6}) =
-        $plugin->get_ips_from_mac($plugin_config, $mac, $zoneid);
+    my ($ip4, $ip6) = $plugin->get_ips_from_mac($plugin_config, $mac, $zoneid);
 
-    write_macdb($macdb);
+    # an empty answer is not cached, the record may simply not exist yet
+    return if !defined($ip4) && !defined($ip6);
 
-    return ($macdb->{macs}->{$mac}->{ip4}, $macdb->{macs}->{$mac}->{ip6});
+    cfs_lock_file(
+        $macdb_filename,
+        undef,
+        sub {
+            my $db = read_macdb();
+            $db->{macs}->{$mac}->{ip4} = $ip4 if defined($ip4);
+            $db->{macs}->{$mac}->{ip6} = $ip6 if defined($ip6);
+            write_macdb($db);
+        },
+    );
+    warn "$@" if $@;
+
+    return ($ip4, $ip6);
 }
 
 1;
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 14+ messages in thread

* [PATCH pve-network 05/12] sdn: subnets: add dhcp-lease-time property
  2026-09-02 12:47 [RFC manager/network/proxmox{-ebpf,-perl-rs} 00/12] sdn: implement DHCP for all zones using eBPF Hannes Laimer
                   ` (3 preceding siblings ...)
  2026-09-02 12:47 ` [PATCH pve-network 04/12] sdn: ipam: do not cache negative per-MAC answers, lock the write Hannes Laimer
@ 2026-09-02 12:47 ` Hannes Laimer
  2026-09-02 12:47 ` [PATCH pve-network 06/12] sdn: dhcp: only assert a backend's availability for zones using it Hannes Laimer
                   ` (7 subsequent siblings)
  12 siblings, 0 replies; 14+ messages in thread
From: Hannes Laimer @ 2026-09-02 12:47 UTC (permalink / raw)
  To: pve-devel

Lease time is the convergence knob for mapping edits: a client picks
up a changed record at the latest one lease after the edit. Make it
configurable per subnet for dhcp backends that honor it.

Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
 src/PVE/Network/SDN/SubnetPlugin.pm | 7 +++++++
 1 file changed, 7 insertions(+)

diff --git a/src/PVE/Network/SDN/SubnetPlugin.pm b/src/PVE/Network/SDN/SubnetPlugin.pm
index e2a0e50..85f37df 100644
--- a/src/PVE/Network/SDN/SubnetPlugin.pm
+++ b/src/PVE/Network/SDN/SubnetPlugin.pm
@@ -177,6 +177,12 @@ sub properties {
             description => 'IP address for the DNS server',
             optional => 1,
         },
+        'dhcp-lease-time' => {
+            type => 'integer',
+            minimum => 60,
+            description => 'Lease time in seconds for DHCP answers',
+            optional => 1,
+        },
     };
 }
 
@@ -189,6 +195,7 @@ sub options {
         dnszoneprefix => { optional => 1 },
         'dhcp-range' => { optional => 1 },
         'dhcp-dns-server' => { optional => 1 },
+        'dhcp-lease-time' => { optional => 1 },
     };
 }
 
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 14+ messages in thread

* [PATCH pve-network 06/12] sdn: dhcp: only assert a backend's availability for zones using it
  2026-09-02 12:47 [RFC manager/network/proxmox{-ebpf,-perl-rs} 00/12] sdn: implement DHCP for all zones using eBPF Hannes Laimer
                   ` (4 preceding siblings ...)
  2026-09-02 12:47 ` [PATCH pve-network 05/12] sdn: subnets: add dhcp-lease-time property Hannes Laimer
@ 2026-09-02 12:47 ` Hannes Laimer
  2026-09-02 12:47 ` [PATCH pve-network 07/12] sdn: dhcp: add ebpf plugin Hannes Laimer
                   ` (6 subsequent siblings)
  12 siblings, 0 replies; 14+ messages in thread
From: Hannes Laimer @ 2026-09-02 12:47 UTC (permalink / raw)
  To: pve-devel

before_regenerate got a single any-zone-needs-dhcp flag, so any zone
with dhcp configured made every registered backend assert its own
availability, and a failing assert aborts the whole regenerate. With
one backend that was the same thing, with a second one it breaks
network reloads on nodes that only run the other backend. Gate each
backend on the zones actually using it.

Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
 src/PVE/Network/SDN/Dhcp.pm | 9 ++++++---
 1 file changed, 6 insertions(+), 3 deletions(-)

diff --git a/src/PVE/Network/SDN/Dhcp.pm b/src/PVE/Network/SDN/Dhcp.pm
index 65e40d4..4d937dc 100644
--- a/src/PVE/Network/SDN/Dhcp.pm
+++ b/src/PVE/Network/SDN/Dhcp.pm
@@ -69,11 +69,14 @@ sub regenerate_config {
 
     my $plugins = PVE::Network::SDN::Dhcp::Plugin->lookup_types();
 
-    my $any_zone_needs_dhcp = grep { $_->{dhcp} } values $zone_cfg->{ids}->%*;
+    my %plugin_needed = ();
+    for my $zone (values $zone_cfg->{ids}->%*) {
+        $plugin_needed{ $zone->{dhcp} } = 1 if $zone->{dhcp};
+    }
 
     foreach my $plugin_name (@$plugins) {
         my $plugin = PVE::Network::SDN::Dhcp::Plugin->lookup($plugin_name);
-        eval { $plugin->before_regenerate(!$any_zone_needs_dhcp) };
+        eval { $plugin->before_regenerate(!$plugin_needed{$plugin_name}) };
         die "Could not run before_regenerate for DHCP plugin $plugin_name $@\n" if $@;
     }
 
@@ -120,7 +123,7 @@ sub regenerate_config {
             warn "Could not configure vnet $vnetid: $@\n" if $@;
         }
 
-        eval { $dhcp_plugin->after_configure($zoneid, !$any_zone_needs_dhcp) };
+        eval { $dhcp_plugin->after_configure($zoneid, !$plugin_needed{$dhcp_plugin_name}) };
         warn "Could not run after_configure for DHCP server $zoneid $@\n" if $@;
 
     }
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 14+ messages in thread

* [PATCH pve-network 07/12] sdn: dhcp: add ebpf plugin
  2026-09-02 12:47 [RFC manager/network/proxmox{-ebpf,-perl-rs} 00/12] sdn: implement DHCP for all zones using eBPF Hannes Laimer
                   ` (5 preceding siblings ...)
  2026-09-02 12:47 ` [PATCH pve-network 06/12] sdn: dhcp: only assert a backend's availability for zones using it Hannes Laimer
@ 2026-09-02 12:47 ` Hannes Laimer
  2026-09-02 12:47 ` [PATCH pve-network 08/12] sdn: zones: attach the dhcp responder on tap plug Hannes Laimer
                   ` (5 subsequent siblings)
  12 siblings, 0 replies; 14+ messages in thread
From: Hannes Laimer @ 2026-09-02 12:47 UTC (permalink / raw)
  To: pve-devel

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       |   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





^ permalink raw reply related	[flat|nested] 14+ messages in thread

* [PATCH pve-network 08/12] sdn: zones: attach the dhcp responder on tap plug
  2026-09-02 12:47 [RFC manager/network/proxmox{-ebpf,-perl-rs} 00/12] sdn: implement DHCP for all zones using eBPF Hannes Laimer
                   ` (6 preceding siblings ...)
  2026-09-02 12:47 ` [PATCH pve-network 07/12] sdn: dhcp: add ebpf plugin Hannes Laimer
@ 2026-09-02 12:47 ` Hannes Laimer
  2026-09-02 12:47 ` [PATCH pve-network 09/12] sdn: dhcp: apply mapping edits on the node serving the guest Hannes Laimer
                   ` (4 subsequent siblings)
  12 siblings, 0 replies; 14+ messages in thread
From: Hannes Laimer @ 2026-09-02 12:47 UTC (permalink / raw)
  To: pve-devel

Program attach is per guest interface and the plug is the one moment
every such interface passes through on this node, covering guest
start, hotplug and incoming migration. Attach failures only warn, a
guest start must not depend on the responder.

Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
 src/PVE/Network/SDN/Zones.pm | 4 ++++
 1 file changed, 4 insertions(+)

diff --git a/src/PVE/Network/SDN/Zones.pm b/src/PVE/Network/SDN/Zones.pm
index f668303..2d32f86 100644
--- a/src/PVE/Network/SDN/Zones.pm
+++ b/src/PVE/Network/SDN/Zones.pm
@@ -9,6 +9,7 @@ use PVE::Tools qw(extract_param dir_glob_regex run_command);
 use PVE::Cluster qw(cfs_read_file cfs_write_file cfs_lock_file);
 use PVE::Network;
 
+use PVE::Network::SDN::Dhcp::Ebpf;
 use PVE::Network::SDN::Vnets;
 use PVE::Network::SDN::Zones::VlanPlugin;
 use PVE::Network::SDN::Zones::QinQPlugin;
@@ -344,6 +345,9 @@ sub tap_plug {
 
     my $plugin = PVE::Network::SDN::Zones::Plugin->lookup($plugin_config->{type});
     $plugin->tap_plug($plugin_config, $vnet, $tag, $iface, $bridge, $firewall, $trunks, $rate);
+
+    PVE::Network::SDN::Dhcp::Ebpf::attach_iface($iface)
+        if $plugin_config->{dhcp} && $plugin_config->{dhcp} eq 'ebpf';
 }
 
 sub add_bridge_fdb {
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 14+ messages in thread

* [PATCH pve-network 09/12] sdn: dhcp: apply mapping edits on the node serving the guest
  2026-09-02 12:47 [RFC manager/network/proxmox{-ebpf,-perl-rs} 00/12] sdn: implement DHCP for all zones using eBPF Hannes Laimer
                   ` (7 preceding siblings ...)
  2026-09-02 12:47 ` [PATCH pve-network 08/12] sdn: zones: attach the dhcp responder on tap plug Hannes Laimer
@ 2026-09-02 12:47 ` Hannes Laimer
  2026-09-02 12:47 ` [PATCH pve-network 10/12] sdn: zones: offer dhcp on all zone types, keep dnsmasq simple-only Hannes Laimer
                   ` (3 subsequent siblings)
  12 siblings, 0 replies; 14+ messages in thread
From: Hannes Laimer @ 2026-09-02 12:47 UTC (permalink / raw)
  To: pve-devel

Mapping edits change cluster-wide records, but the ebpf backend
answers from a per-node map, so an edit made through another node's
API left the map of the node running the guest stale until the next
apply or guest start there. Resolve the MAC to its guest and re-apply
the mapping on that node through a node-scoped call, the same
remove-and-add the editing node runs locally. No other node needs the
record, each picks it up at that guest's next start there, so the
cost of an edit stays one call however large the cluster is.

The push runs detached from the edit request, an unreachable node
cannot hold the edit up, it just catches up on its next apply or the
guest's next start.

Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
 src/PVE/API2/Network/SDN/Ips.pm          |  5 +-
 src/PVE/API2/Network/SDN/Nodes/Status.pm | 37 +++++++++++-
 src/PVE/Network/SDN/Dhcp.pm              | 74 ++++++++++++++++++++++++
 3 files changed, 112 insertions(+), 4 deletions(-)

diff --git a/src/PVE/API2/Network/SDN/Ips.pm b/src/PVE/API2/Network/SDN/Ips.pm
index fc76c90..63bfce6 100644
--- a/src/PVE/API2/Network/SDN/Ips.pm
+++ b/src/PVE/API2/Network/SDN/Ips.pm
@@ -17,9 +17,8 @@ my sub update_dhcp_mapping {
     my ($vnet, $mac) = @_;
 
     eval {
-        my ($ip4, $ip6) = PVE::Network::SDN::Vnets::get_ips_from_mac($vnet, $mac);
-        PVE::Network::SDN::Dhcp::remove_mapping($vnet, $mac);
-        PVE::Network::SDN::Dhcp::add_mapping($vnet, $mac, $ip4, $ip6) if $ip4 || $ip6;
+        PVE::Network::SDN::Dhcp::update_mapping($vnet, $mac);
+        PVE::Network::SDN::Dhcp::notify_guest_node($vnet, $mac);
     };
     warn "could not update dhcp mapping for $mac: $@" if $@;
 }
diff --git a/src/PVE/API2/Network/SDN/Nodes/Status.pm b/src/PVE/API2/Network/SDN/Nodes/Status.pm
index 7977e0c..f11fd48 100644
--- a/src/PVE/API2/Network/SDN/Nodes/Status.pm
+++ b/src/PVE/API2/Network/SDN/Nodes/Status.pm
@@ -9,6 +9,8 @@ use PVE::API2::Network::SDN::Nodes::Vnets;
 
 use PVE::JSONSchema qw(get_standard_option);
 
+use PVE::Network::SDN::Dhcp;
+
 use PVE::RESTHandler;
 use base qw(PVE::RESTHandler);
 
@@ -27,6 +29,36 @@ __PACKAGE__->register_method({
     path => 'vnets',
 });
 
+__PACKAGE__->register_method({
+    name => 'dhcp_mapping',
+    path => 'dhcp-mapping',
+    method => 'POST',
+    description =>
+        'Re-apply the DHCP mapping of a MAC address on this node from the current records.',
+    permissions => {
+        check => ['perm', '/sdn/zones/{zone}/{vnet}', ['SDN.Allocate']],
+    },
+    protected => 1,
+    proxyto => 'node',
+    parameters => {
+        additionalProperties => 0,
+        properties => {
+            node => get_standard_option('pve-node'),
+            zone => get_standard_option('pve-sdn-zone-id'),
+            vnet => get_standard_option('pve-sdn-vnet-id'),
+            mac => get_standard_option('mac-addr'),
+        },
+    },
+    returns => { type => 'null' },
+    code => sub {
+        my ($param) = @_;
+
+        PVE::Network::SDN::Dhcp::update_mapping($param->{vnet}, $param->{mac});
+
+        return undef;
+    },
+});
+
 __PACKAGE__->register_method({
     name => 'sdnindex',
     path => '',
@@ -52,7 +84,10 @@ __PACKAGE__->register_method({
         my ($param) = @_;
 
         my $result = [
-            { name => 'fabrics' }, { name => 'vnets' }, { name => 'zones' },
+            { name => 'dhcp-mapping' },
+            { name => 'fabrics' },
+            { name => 'vnets' },
+            { name => 'zones' },
         ];
         return $result;
     },
diff --git a/src/PVE/Network/SDN/Dhcp.pm b/src/PVE/Network/SDN/Dhcp.pm
index f046dfd..6bc9bad 100644
--- a/src/PVE/Network/SDN/Dhcp.pm
+++ b/src/PVE/Network/SDN/Dhcp.pm
@@ -3,6 +3,8 @@ package PVE::Network::SDN::Dhcp;
 use strict;
 use warnings;
 
+use POSIX qw();
+
 use PVE::Cluster;
 
 use PVE::Network::SDN;
@@ -59,6 +61,78 @@ sub remove_mapping {
     $dhcp_plugin->del_ip_mapping($zoneid, $mac);
 }
 
+# re-apply a MAC's mapping on this node from the current records
+sub update_mapping {
+    my ($vnetid, $mac) = @_;
+
+    my ($ip4, $ip6) = PVE::Network::SDN::Vnets::get_ips_from_mac($vnetid, $mac);
+    remove_mapping($vnetid, $mac);
+    add_mapping($vnetid, $mac, $ip4, $ip6) if $ip4 || $ip6;
+}
+
+# the guest config lines come from pmxcfs in one go, no guest config
+# gets parsed for this
+my sub guest_node_by_mac {
+    my ($mac) = @_;
+
+    my $vmlist = PVE::Cluster::get_vmlist();
+    my $nets = PVE::Cluster::get_guest_config_properties([map { "net$_" } 0 .. 31]);
+    for my $vmid (keys %$nets) {
+        for my $net (values %{ $nets->{$vmid} }) {
+            return $vmlist->{ids}->{$vmid}->{node} if $net =~ m/=\Q$mac\E(?:,|$)/i;
+        }
+    }
+
+    return undef;
+}
+
+# The records are cluster-wide, but a node answers from its own map,
+# so an edit has to reach the node running the guest behind the MAC.
+# Every other node picks the record up at that guest's next start
+# there. The push runs detached from the request, an unreachable node
+# must not hold the edit up.
+sub notify_guest_node {
+    my ($vnetid, $mac) = @_;
+
+    my $vnet = PVE::Network::SDN::Vnets::get_vnet($vnetid, 1);
+    return if !$vnet;
+
+    my $zone = PVE::Network::SDN::Zones::get_zone($vnet->{zone}, 1);
+    return if !$zone || !$zone->{dhcp} || $zone->{dhcp} ne 'ebpf';
+
+    my $node = guest_node_by_mac($mac);
+    return if !$node || $node eq PVE::INotify::nodename();
+
+    # double fork, the grandchild is reparented to init so nothing on
+    # the request path ever waits on it
+    my $pid = fork();
+    if (!defined($pid)) {
+        warn "could not fork for the dhcp mapping push to $node: $!\n";
+        return;
+    }
+    if ($pid) {
+        waitpid($pid, 0);
+        return;
+    }
+
+    POSIX::setsid();
+    my $child = fork();
+    POSIX::_exit(1) if !defined($child);
+    POSIX::_exit(0) if $child;
+
+    exec(
+        'pvesh',
+        'create',
+        "/nodes/$node/sdn/dhcp-mapping",
+        '--zone',
+        $vnet->{zone},
+        '--vnet',
+        $vnetid,
+        '--mac',
+        $mac,
+    ) or POSIX::_exit(1);
+}
+
 sub regenerate_config {
     my ($reload) = @_;
 
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 14+ messages in thread

* [PATCH pve-network 10/12] sdn: zones: offer dhcp on all zone types, keep dnsmasq simple-only
  2026-09-02 12:47 [RFC manager/network/proxmox{-ebpf,-perl-rs} 00/12] sdn: implement DHCP for all zones using eBPF Hannes Laimer
                   ` (8 preceding siblings ...)
  2026-09-02 12:47 ` [PATCH pve-network 09/12] sdn: dhcp: apply mapping edits on the node serving the guest Hannes Laimer
@ 2026-09-02 12:47 ` Hannes Laimer
  2026-09-02 12:47 ` [PATCH pve-network 11/12] tests: cover the ebpf dhcp backend and ipam API mapping pushes Hannes Laimer
                   ` (2 subsequent siblings)
  12 siblings, 0 replies; 14+ messages in thread
From: Hannes Laimer @ 2026-09-02 12:47 UTC (permalink / raw)
  To: pve-devel

The dnsmasq backend is confined to simple zones because it is a
node-local process on the vnet bridge, on a zone spanning nodes
every node's instance would answer the shared broadcast domain. The
ebpf backend answers on the guest's own tap and has no such
restriction, so the dhcp property moves from a simple-zone option to
a common one, with dnsmasq rejected on other zone types.

The backends ask the zone for the guest-facing MTU to serve, so the
zone types gaining dhcp implement get_mtu, with vxlan derived zones
accounting for the encapsulation overhead.

Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
 src/PVE/API2/Network/SDN/Zones.pm         | 6 ++++++
 src/PVE/Network/SDN/Zones/EvpnPlugin.pm   | 1 +
 src/PVE/Network/SDN/Zones/FaucetPlugin.pm | 1 +
 src/PVE/Network/SDN/Zones/QinQPlugin.pm   | 7 +++++++
 src/PVE/Network/SDN/Zones/VlanPlugin.pm   | 7 +++++++
 src/PVE/Network/SDN/Zones/VxlanPlugin.pm  | 9 +++++++++
 6 files changed, 31 insertions(+)

diff --git a/src/PVE/API2/Network/SDN/Zones.pm b/src/PVE/API2/Network/SDN/Zones.pm
index ad16bef..b39c0c9 100644
--- a/src/PVE/API2/Network/SDN/Zones.pm
+++ b/src/PVE/API2/Network/SDN/Zones.pm
@@ -447,6 +447,9 @@ __PACKAGE__->register_method({
                 raise_param_exc({ ipam => "$ipam not existing" })
                     if $ipam && !$ipam_cfg->{ids}->{$ipam};
 
+                raise_param_exc({ dhcp => "the dnsmasq backend only supports simple zones" })
+                    if ($opts->{dhcp} // '') eq 'dnsmasq' && $plugin->type() ne 'simple';
+
                 $zone_cfg->{ids}->{$id} = $opts;
                 $plugin->on_update_hook($id, $zone_cfg, $controller_cfg);
 
@@ -543,6 +546,9 @@ __PACKAGE__->register_method({
                 raise_param_exc({ ipam => "$ipam not existing" })
                     if $ipam && !$ipam_cfg->{ids}->{$ipam};
 
+                raise_param_exc({ dhcp => "the dnsmasq backend only supports simple zones" })
+                    if ($opts->{dhcp} // '') eq 'dnsmasq' && $plugin->type() ne 'simple';
+
                 $plugin->on_update_hook($id, $zone_cfg, $controller_cfg);
 
                 PVE::Network::SDN::Zones::write_config($zone_cfg);
diff --git a/src/PVE/Network/SDN/Zones/EvpnPlugin.pm b/src/PVE/Network/SDN/Zones/EvpnPlugin.pm
index 0e79707..7f1fd90 100644
--- a/src/PVE/Network/SDN/Zones/EvpnPlugin.pm
+++ b/src/PVE/Network/SDN/Zones/EvpnPlugin.pm
@@ -125,6 +125,7 @@ sub options {
         reversedns => { optional => 1 },
         dnszone => { optional => 1 },
         ipam => { optional => 1 },
+        dhcp => { optional => 1 },
     };
 }
 
diff --git a/src/PVE/Network/SDN/Zones/FaucetPlugin.pm b/src/PVE/Network/SDN/Zones/FaucetPlugin.pm
index 5f069ae..4e3f5ef 100644
--- a/src/PVE/Network/SDN/Zones/FaucetPlugin.pm
+++ b/src/PVE/Network/SDN/Zones/FaucetPlugin.pm
@@ -30,6 +30,7 @@ sub options {
         reversedns => { optional => 1 },
         dnszone => { optional => 1 },
         ipam => { optional => 1 },
+        dhcp => { optional => 1 },
     };
 }
 
diff --git a/src/PVE/Network/SDN/Zones/QinQPlugin.pm b/src/PVE/Network/SDN/Zones/QinQPlugin.pm
index a75940c..e70912a 100644
--- a/src/PVE/Network/SDN/Zones/QinQPlugin.pm
+++ b/src/PVE/Network/SDN/Zones/QinQPlugin.pm
@@ -36,6 +36,12 @@ sub properties {
     };
 }
 
+sub get_mtu {
+    my ($class, $plugin_config) = @_;
+
+    return $plugin_config->{mtu};
+}
+
 sub options {
     return {
         nodes => { optional => 1 },
@@ -48,6 +54,7 @@ sub options {
         reversedns => { optional => 1 },
         dnszone => { optional => 1 },
         ipam => { optional => 1 },
+        dhcp => { optional => 1 },
     };
 }
 
diff --git a/src/PVE/Network/SDN/Zones/VlanPlugin.pm b/src/PVE/Network/SDN/Zones/VlanPlugin.pm
index 9102b34..4f24b2a 100644
--- a/src/PVE/Network/SDN/Zones/VlanPlugin.pm
+++ b/src/PVE/Network/SDN/Zones/VlanPlugin.pm
@@ -36,6 +36,12 @@ sub properties {
     };
 }
 
+sub get_mtu {
+    my ($class, $plugin_config) = @_;
+
+    return $plugin_config->{mtu};
+}
+
 sub options {
 
     return {
@@ -47,6 +53,7 @@ sub options {
         reversedns => { optional => 1 },
         dnszone => { optional => 1 },
         ipam => { optional => 1 },
+        dhcp => { optional => 1 },
     };
 }
 
diff --git a/src/PVE/Network/SDN/Zones/VxlanPlugin.pm b/src/PVE/Network/SDN/Zones/VxlanPlugin.pm
index a408261..6ae1998 100644
--- a/src/PVE/Network/SDN/Zones/VxlanPlugin.pm
+++ b/src/PVE/Network/SDN/Zones/VxlanPlugin.pm
@@ -47,6 +47,14 @@ sub properties {
     };
 }
 
+# without an explicit zone MTU the vnet bridges default to 1450,
+# leaving room for the vxlan encapsulation
+sub get_mtu {
+    my ($class, $plugin_config) = @_;
+
+    return $plugin_config->{mtu} // 1450;
+}
+
 sub options {
     return {
         nodes => { optional => 1 },
@@ -58,6 +66,7 @@ sub options {
         dnszone => { optional => 1 },
         ipam => { optional => 1 },
         fabric => { optional => 1 },
+        dhcp => { optional => 1 },
     };
 }
 
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 14+ messages in thread

* [PATCH pve-network 11/12] tests: cover the ebpf dhcp backend and ipam API mapping pushes
  2026-09-02 12:47 [RFC manager/network/proxmox{-ebpf,-perl-rs} 00/12] sdn: implement DHCP for all zones using eBPF Hannes Laimer
                   ` (9 preceding siblings ...)
  2026-09-02 12:47 ` [PATCH pve-network 10/12] sdn: zones: offer dhcp on all zone types, keep dnsmasq simple-only Hannes Laimer
@ 2026-09-02 12:47 ` Hannes Laimer
  2026-09-02 12:47 ` [PATCH pve-manager 12/12] ui: sdn: dhcp backend selector on all zones, expose dhcp options Hannes Laimer
  2026-09-02 12:54 ` [RFC manager/network/proxmox{-ebpf,-perl-rs} 00/12] sdn: implement DHCP for all zones using eBPF Hannes Laimer
  12 siblings, 0 replies; 14+ messages in thread
From: Hannes Laimer @ 2026-09-02 12:47 UTC (permalink / raw)
  To: pve-devel

Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
 src/test/run_test_vnets_blackbox.pl | 147 ++++++++++++++++++++++++++++
 1 file changed, 147 insertions(+)

diff --git a/src/test/run_test_vnets_blackbox.pl b/src/test/run_test_vnets_blackbox.pl
index 9f4c424..fd63fd2 100755
--- a/src/test/run_test_vnets_blackbox.pl
+++ b/src/test/run_test_vnets_blackbox.pl
@@ -46,6 +46,7 @@ sub clear_test_state {
         vnets_config => {},
         macdb => {},
         ipamdb => {},
+        ebpf_calls => [],
         ipam_config => {
             'ids' => {
                 'pve' => {
@@ -103,6 +104,13 @@ my $mocked_pve_sdn;
 $mocked_pve_sdn = Test::MockModule->new('PVE::Network::SDN');
 $mocked_pve_sdn->mock(
     cfs_lock_file => $mocked_cfs_lock_file,
+    running_config => sub {
+        return {
+            zones => $test_state->{zones_config},
+            vnets => $test_state->{vnets_config},
+            subnets => $test_state->{subnets_config},
+        };
+    },
 );
 
 my $mocked_pve_tools = Test::MockModule->new('PVE::Tools');
@@ -224,6 +232,21 @@ $mocked_sdn_dhcp_dnsmasq->mock(
     update_lease => sub { },
 );
 
+my $mocked_sdn_dhcp = Test::MockModule->new('PVE::Network::SDN::Dhcp');
+$mocked_sdn_dhcp->mock(
+    notify_guest_node => sub { },
+);
+
+my $mocked_pve_rs_dhcp = Test::MockModule->new('PVE::RS::SDN::Dhcp');
+$mocked_pve_rs_dhcp->mock(
+    map {
+        my $method = $_;
+        $method => sub {
+            push $test_state->{ebpf_calls}->@*, { method => $method, args => [@_] };
+        };
+    } qw(apply attach remove sync update)
+);
+
 my $mocked_api_zones = Test::MockModule->new('PVE::API2::Network::SDN::Zones');
 $mocked_api_zones->mock(
     create_etc_interfaces_sdn_dir => sub { },
@@ -330,6 +353,22 @@ sub create_ip {
     return PVE::API2::Network::SDN::Ips->ipcreate($param);
 }
 
+sub update_ip {
+    my ($param) = @_;
+    return PVE::API2::Network::SDN::Ips->ipupdate($param);
+}
+
+sub delete_ip {
+    my ($param) = @_;
+    return PVE::API2::Network::SDN::Ips->ipdelete($param);
+}
+
+sub take_ebpf_calls {
+    my $calls = $test_state->{ebpf_calls};
+    $test_state->{ebpf_calls} = [];
+    return $calls;
+}
+
 sub run_test {
     my $test = shift;
     clear_test_state();
@@ -963,4 +1002,112 @@ run_test(
     2,
 );
 
+# -------------- ebpf dhcp backend
+sub test_ebpf_backend {
+    my $test_name = (split(/::/, (caller(0))[3]))[-1];
+    my $zoneid = "TESTZONE";
+    my $vnetid = "testvnet";
+    my $mac = "da:65:8f:18:9b:6f";
+
+    create_zone({
+        type => "simple",
+        dhcp => "ebpf",
+        ipam => "pve",
+        zone => $zoneid,
+    });
+
+    create_vnet({
+        type => "vnet",
+        zone => $zoneid,
+        vnet => $vnetid,
+    });
+
+    create_subnet({
+        type => "subnet",
+        vnet => $vnetid,
+        subnet => "10.0.0.0/24",
+        gateway => "10.0.0.1",
+        'dhcp-range' => ["start-address=10.0.0.100,end-address=10.0.0.200"],
+        'dhcp-lease-time' => 300,
+    });
+
+    take_ebpf_calls();
+
+    # guest start allocates from the range and pushes the record
+    eval { nic_start($vnetid, $mac, "999", "testhostname"); };
+    if ($@) {
+        fail("$test_name: nic_start: $@");
+        return;
+    }
+
+    my $calls = take_ebpf_calls();
+    my $record = sub {
+        my ($ip) = @_;
+        return {
+            mac => $mac,
+            ip => $ip,
+            prefixlen => 24,
+            server_id => "10.0.0.1",
+            lease => 300,
+            router => "10.0.0.1",
+            dns => undef,
+            mtu => 1500,
+        };
+    };
+
+    eq_or_diff(
+        $calls,
+        [{ method => 'update', args => [[$record->("10.0.0.100")]] }],
+        "$test_name: guest start pushes the record",
+    );
+
+    # a mapping edit through the API replaces the record
+    update_ip({
+        zone => $zoneid,
+        vnet => $vnetid,
+        mac => $mac,
+        ip => "10.0.0.150",
+    });
+
+    $calls = take_ebpf_calls();
+    eq_or_diff(
+        $calls,
+        [
+            { method => 'remove', args => [$mac] },
+            { method => 'update', args => [[$record->("10.0.0.150")]] },
+        ],
+        "$test_name: mapping edit replaces the record",
+    );
+
+    # a full regenerate refreshes the responder and translates into one sync
+    PVE::Network::SDN::Dhcp::regenerate_config();
+
+    $calls = take_ebpf_calls();
+    eq_or_diff(
+        $calls,
+        [
+            { method => 'apply', args => [] },
+            { method => 'sync', args => [[$record->("10.0.0.150")]] },
+        ],
+        "$test_name: regenerate refreshes the responder and syncs the full record set",
+    );
+
+    # deleting the mapping drops the record
+    delete_ip({
+        zone => $zoneid,
+        vnet => $vnetid,
+        mac => $mac,
+        ip => "10.0.0.150",
+    });
+
+    $calls = take_ebpf_calls();
+    eq_or_diff(
+        $calls,
+        [{ method => 'remove', args => [$mac] }],
+        "$test_name: mapping delete drops the record",
+    );
+}
+
+run_test(\&test_ebpf_backend);
+
 done_testing();
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 14+ messages in thread

* [PATCH pve-manager 12/12] ui: sdn: dhcp backend selector on all zones, expose dhcp options
  2026-09-02 12:47 [RFC manager/network/proxmox{-ebpf,-perl-rs} 00/12] sdn: implement DHCP for all zones using eBPF Hannes Laimer
                   ` (10 preceding siblings ...)
  2026-09-02 12:47 ` [PATCH pve-network 11/12] tests: cover the ebpf dhcp backend and ipam API mapping pushes Hannes Laimer
@ 2026-09-02 12:47 ` Hannes Laimer
  2026-09-02 12:54 ` [RFC manager/network/proxmox{-ebpf,-perl-rs} 00/12] sdn: implement DHCP for all zones using eBPF Hannes Laimer
  12 siblings, 0 replies; 14+ messages in thread
From: Hannes Laimer @ 2026-09-02 12:47 UTC (permalink / raw)
  To: pve-devel

The zone dhcp property is now common to all zone types with two
backends, so the simple-zone automatic-DHCP checkbox hardcoding
dnsmasq becomes a selector on the common zone panel, offering
dnsmasq only where the backend is supported. The subnet gains fields
for the dns-server option, which so far was settable through the
API only and is the only way guests get a resolver with the ebpf
backend, and for the new lease-time knob, which bounds how long an
edited mapping takes to reach a leased guest.

Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
 www/manager6/sdn/SubnetEdit.js       | 24 ++++++++++++++++++++++++
 www/manager6/sdn/zones/Base.js       | 17 +++++++++++++++++
 www/manager6/sdn/zones/SimpleEdit.js | 11 -----------
 3 files changed, 41 insertions(+), 11 deletions(-)

diff --git a/www/manager6/sdn/SubnetEdit.js b/www/manager6/sdn/SubnetEdit.js
index a3608428..f98f65af 100644
--- a/www/manager6/sdn/SubnetEdit.js
+++ b/www/manager6/sdn/SubnetEdit.js
@@ -56,6 +56,30 @@ Ext.define('PVE.sdn.SubnetInputPanel', {
                 deleteEmpty: '{!isCreate}',
             },
         },
+        {
+            xtype: 'proxmoxtextfield',
+            name: 'dhcp-dns-server',
+            vtype: 'IP64Address',
+            fieldLabel: gettext('DHCP DNS Server'),
+            allowBlank: true,
+            skipEmptyText: true,
+            cbind: {
+                deleteEmpty: '{!isCreate}',
+            },
+        },
+        {
+            xtype: 'proxmoxintegerfield',
+            // TRANSLATORS: 's' is the SI abbreviation for seconds, the unit of the value
+            fieldLabel: gettext('DHCP Lease Time') + ' (s)',
+            emptyText: gettext('default'),
+            name: 'dhcp-lease-time',
+            minValue: 60,
+            allowBlank: true,
+            skipEmptyText: true,
+            cbind: {
+                deleteEmpty: '{!isCreate}',
+            },
+        },
     ],
 });
 
diff --git a/www/manager6/sdn/zones/Base.js b/www/manager6/sdn/zones/Base.js
index 66f93de0..3039b99f 100644
--- a/www/manager6/sdn/zones/Base.js
+++ b/www/manager6/sdn/zones/Base.js
@@ -82,6 +82,23 @@ Ext.define('PVE.panel.SDNZoneBase', {
             },
         );
 
+        let dhcpBackends = [
+            ['__default__', Proxmox.Utils.NoneText],
+            ['ebpf', 'eBPF'],
+        ];
+        if (me.type === 'simple') {
+            dhcpBackends.splice(1, 0, ['dnsmasq', 'dnsmasq']);
+        }
+
+        me.advancedItems.push({
+            xtype: 'proxmoxKVComboBox',
+            name: 'dhcp',
+            fieldLabel: gettext('Automatic DHCP'),
+            comboItems: dhcpBackends,
+            value: '__default__',
+            deleteEmpty: !me.isCreate,
+        });
+
         me.callParent();
     },
 });
diff --git a/www/manager6/sdn/zones/SimpleEdit.js b/www/manager6/sdn/zones/SimpleEdit.js
index ba10bb36..aeb076ae 100644
--- a/www/manager6/sdn/zones/SimpleEdit.js
+++ b/www/manager6/sdn/zones/SimpleEdit.js
@@ -19,17 +19,6 @@ Ext.define('PVE.sdn.zones.SimpleInputPanel', {
         var me = this;
 
         me.items = [];
-        me.advancedItems = [
-            {
-                xtype: 'proxmoxcheckbox',
-                name: 'dhcp',
-                inputValue: 'dnsmasq',
-                uncheckedValue: null,
-                checked: false,
-                fieldLabel: gettext('Automatic DHCP'),
-                deleteEmpty: !me.isCreate,
-            },
-        ];
 
         me.callParent();
     },
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 14+ messages in thread

* Re: [RFC manager/network/proxmox{-ebpf,-perl-rs} 00/12] sdn: implement DHCP for all zones using eBPF
  2026-09-02 12:47 [RFC manager/network/proxmox{-ebpf,-perl-rs} 00/12] sdn: implement DHCP for all zones using eBPF Hannes Laimer
                   ` (11 preceding siblings ...)
  2026-09-02 12:47 ` [PATCH pve-manager 12/12] ui: sdn: dhcp backend selector on all zones, expose dhcp options Hannes Laimer
@ 2026-09-02 12:54 ` Hannes Laimer
  12 siblings, 0 replies; 14+ messages in thread
From: Hannes Laimer @ 2026-09-02 12:54 UTC (permalink / raw)
  To: pve-devel

On 2026-09-02 14:48, Hannes Laimer wrote:
> Adds a second DHCP backend, `ebpf`, next to dnsmasq, selectable per
> zone. It aims to replace dnsmasq eventually, for now it is a second
> implementation, which keeps a migration simple. Every zone type can
> enable DHCP through a dropdown selector, `dnsmasq` stays limited to
> simple zones.
> 
> The responder is a subsystem of `proxmox-ebpf` [1], Perl reaches it
> through new pve-rs bindings (PVE::RS::SDN::Dhcp), so the pve-network
> patches need the pve-rs of this series.
> 
> Currently only supports DHCPv4, but adding v6 is very possible once we're happy
> with the overall design.
> 
> # How
> An eBPF program on the ingress of every guest tap parses DHCP requests,
> looks the client MAC up in a mac -> ip+options map and rewrites the
> request into the reply in place, redirected back out of the tap. The
> exchange never reaches the bridge. Everything else, including MACs
> without a map entry, passes untouched, so attaching is a no-op for
> unmanaged MACs.
> 
> IPAM is the source of the assignments, the map is a per-node copy of
> the records, kept current by:
>  - guest start / NIC hotplug / migration: add_dhcp_mapping already
>    fires here and pushes the MAC's record before the interface is
>    plugged, tap_plug then attaches the program.
>  - mapping create/update/delete through the API: the editing node
>    updates its own map and pokes the node running the guest to do the
>    same, detached from the request. Best effort, an unreachable node
>    catches up on its next apply or the guest's next start.
>  - SDN apply: refreshes the programs, drops the link pins of departed
>    guests and rebuilds the map from the current records.
>  - boot: maps start empty, every guest start seeds its own record.
> 
> Changes made directly on an external IPAM service are not detectable
> and the per-MAC answers are cached, so they are not picked up on apply
> either, exactly like with dnsmasq today.
> 
> The pve-network part applies on top of the separately posted patch
> pushing ipam API mapping changes to the dhcp backend. Its first
> patches are preparatory, no negative per-MAC cache entries and a
> locked cache write, a lease time property on subnets, and asserting a
> backend's availability only for zones using it.

oops, did not send that yet, it's [2]

> 
> [1] https://lore.proxmox.com/pve-devel/8d63974f-0a73-480b-9407-c6bdc2d576d7@proxmox.com

[2]
https://lore.proxmox.com/pve-devel/20260902125357.757029-1-h.laimer@proxmox.com/T/#u

> 
> 
> proxmox-ebpf:
> 
> Hannes Laimer (2):
>   dhcp: add per-tap responder BPF program
>   dhcp: add responder subsystem
> 
>  Cargo.toml              |   5 +
>  debian/control          |   6 +-
>  src/dhcp/bpf/dhcp.bpf.c | 324 +++++++++++++++++++
>  src/dhcp/bpf/types.h    |  25 ++
>  src/dhcp/mod.rs         | 288 +++++++++++++++++
>  src/dhcp/types.rs       |  53 ++++
>  src/lib.rs              |   3 +
>  src/subsystem.rs        |  35 +++
>  tests/dhcp.rs           | 668 ++++++++++++++++++++++++++++++++++++++++
>  9 files changed, 1406 insertions(+), 1 deletion(-)
>  create mode 100644 src/dhcp/bpf/dhcp.bpf.c
>  create mode 100644 src/dhcp/bpf/types.h
>  create mode 100644 src/dhcp/mod.rs
>  create mode 100644 src/dhcp/types.rs
>  create mode 100644 tests/dhcp.rs
> 
> 
> proxmox-perl-rs:
> 
> Hannes Laimer (1):
>   pve-rs: sdn: add dhcp responder bindings
> 
>  pve-rs/Cargo.toml               |  2 +
>  pve-rs/Makefile                 |  1 +
>  pve-rs/debian/control           |  2 +
>  pve-rs/src/bindings/sdn/dhcp.rs | 91 +++++++++++++++++++++++++++++++++
>  pve-rs/src/bindings/sdn/mod.rs  |  1 +
>  5 files changed, 97 insertions(+)
>  create mode 100644 pve-rs/src/bindings/sdn/dhcp.rs
> 
> 
> pve-network:
> 
> Hannes Laimer (8):
>   sdn: ipam: do not cache negative per-MAC answers, lock the write
>   sdn: subnets: add dhcp-lease-time property
>   sdn: dhcp: only assert a backend's availability for zones using it
>   sdn: dhcp: add ebpf plugin
>   sdn: zones: attach the dhcp responder on tap plug
>   sdn: dhcp: apply mapping edits on the node serving the guest
>   sdn: zones: offer dhcp on all zone types, keep dnsmasq simple-only
>   tests: cover the ebpf dhcp backend and ipam API mapping pushes
> 
>  src/PVE/API2/Network/SDN/Ips.pm           |   5 +-
>  src/PVE/API2/Network/SDN/Nodes/Status.pm  |  37 ++++-
>  src/PVE/API2/Network/SDN/Zones.pm         |   8 +-
>  src/PVE/Network/SDN/Dhcp.pm               |  87 ++++++++++-
>  src/PVE/Network/SDN/Dhcp/Ebpf.pm          | 173 ++++++++++++++++++++++
>  src/PVE/Network/SDN/Dhcp/Makefile         |   2 +-
>  src/PVE/Network/SDN/Ipams.pm              |  20 ++-
>  src/PVE/Network/SDN/SubnetPlugin.pm       |   7 +
>  src/PVE/Network/SDN/Zones.pm              |   4 +
>  src/PVE/Network/SDN/Zones/EvpnPlugin.pm   |   1 +
>  src/PVE/Network/SDN/Zones/FaucetPlugin.pm |   1 +
>  src/PVE/Network/SDN/Zones/QinQPlugin.pm   |   7 +
>  src/PVE/Network/SDN/Zones/VlanPlugin.pm   |   7 +
>  src/PVE/Network/SDN/Zones/VxlanPlugin.pm  |   9 ++
>  src/test/run_test_vnets_blackbox.pl       | 147 ++++++++++++++++++
>  15 files changed, 502 insertions(+), 13 deletions(-)
>  create mode 100644 src/PVE/Network/SDN/Dhcp/Ebpf.pm
> 
> 
> pve-manager:
> 
> Hannes Laimer (1):
>   ui: sdn: dhcp backend selector on all zones, expose dhcp options
> 
>  www/manager6/sdn/SubnetEdit.js       | 24 ++++++++++++++++++++++++
>  www/manager6/sdn/zones/Base.js       | 17 +++++++++++++++++
>  www/manager6/sdn/zones/SimpleEdit.js | 11 -----------
>  3 files changed, 41 insertions(+), 11 deletions(-)
> 
> 
> Summary over all repositories:
>   32 files changed, 2046 insertions(+), 25 deletions(-)
> 





^ permalink raw reply	[flat|nested] 14+ messages in thread

end of thread, other threads:[~2026-09-02 12:54 UTC | newest]

Thread overview: 14+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
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 proxmox-ebpf 01/12] dhcp: add per-tap responder BPF program Hannes Laimer
2026-09-02 12:47 ` [PATCH proxmox-ebpf 02/12] dhcp: add responder subsystem Hannes Laimer
2026-09-02 12:47 ` [PATCH proxmox-perl-rs 03/12] pve-rs: sdn: add dhcp responder bindings Hannes Laimer
2026-09-02 12:47 ` [PATCH pve-network 04/12] sdn: ipam: do not cache negative per-MAC answers, lock the write Hannes Laimer
2026-09-02 12:47 ` [PATCH pve-network 05/12] sdn: subnets: add dhcp-lease-time property Hannes Laimer
2026-09-02 12:47 ` [PATCH pve-network 06/12] sdn: dhcp: only assert a backend's availability for zones using it Hannes Laimer
2026-09-02 12:47 ` [PATCH pve-network 07/12] sdn: dhcp: add ebpf plugin Hannes Laimer
2026-09-02 12:47 ` [PATCH pve-network 08/12] sdn: zones: attach the dhcp responder on tap plug Hannes Laimer
2026-09-02 12:47 ` [PATCH pve-network 09/12] sdn: dhcp: apply mapping edits on the node serving the guest Hannes Laimer
2026-09-02 12:47 ` [PATCH pve-network 10/12] sdn: zones: offer dhcp on all zone types, keep dnsmasq simple-only Hannes Laimer
2026-09-02 12:47 ` [PATCH pve-network 11/12] tests: cover the ebpf dhcp backend and ipam API mapping pushes Hannes Laimer
2026-09-02 12:47 ` [PATCH pve-manager 12/12] ui: sdn: dhcp backend selector on all zones, expose dhcp options Hannes Laimer
2026-09-02 12:54 ` [RFC manager/network/proxmox{-ebpf,-perl-rs} 00/12] sdn: implement DHCP for all zones using eBPF Hannes Laimer

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