From: Hannes Laimer <h.laimer@proxmox.com>
To: pve-devel@lists.proxmox.com
Subject: [PATCH proxmox-ebpf 02/12] dhcp: add responder subsystem
Date: Fri, 4 Sep 2026 11:38:25 +0200 [thread overview]
Message-ID: <20260904093835.1050030-3-h.laimer@proxmox.com> (raw)
In-Reply-To: <20260904093835.1050030-1-h.laimer@proxmox.com>
The record map is fed entirely by the consumer, so the subsystem needs
no state source of its own. A single full pass is the only writer of
pinned state, it makes the programs current, attaches them to exactly
the interfaces the consumer names and diffs the map against the full
record set, so the empty state a rebuild leaves behind is refilled in
the same pass. A tap plug only attaches, to whatever consistent pair of
program and map the last pass established.
Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
src/dhcp/mod.rs | 247 ++++++++++++++++++++++++++++++++++++++++++++++
src/dhcp/types.rs | 53 ++++++++++
src/lib.rs | 3 +
3 files changed, 303 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..8daf190
--- /dev/null
+++ b/src/dhcp/mod.rs
@@ -0,0 +1,247 @@
+//! 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 the programs.
+
+mod types;
+
+use std::collections::{HashMap, HashSet};
+use std::net::Ipv4Addr;
+
+use anyhow::{Context, bail};
+use aya::include_bytes_aligned;
+
+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,
+ SCHEMA_VERSION,
+ ),
+ }
+ }
+
+ /// The full pass and the only writer of pinned state. Make the programs current, attach them
+ /// to exactly the given interfaces and make the record map hold exactly the given records.
+ /// Both are diffed against the kernel state, so this converges from any starting point, the
+ /// empty state a rebuild on a schema change leaves behind included.
+ pub fn apply(&mut self, ifaces: &[&str], records: &[Record]) -> anyhow::Result<()> {
+ let desired = map_entries(records)?;
+ let lock = self.programs.lock_exclusive()?;
+ lock.ensure_loaded()?;
+
+ let mut map = lock.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());
+ }
+
+ let mut attached = HashSet::new();
+ for iface in ifaces {
+ match nix::net::if_::if_nametoindex(*iface) {
+ Ok(ifindex) => {
+ attached.insert(ifindex);
+ }
+ // gone between the caller's enumeration and here
+ Err(e) => log::info!("dhcp apply: {iface}: {e}, skipping"),
+ }
+ }
+ lock.reconcile(&attached)
+ }
+
+ /// Detach all responder programs and drop pinned/run state, for package removal or the last
+ /// zone leaving the backend.
+ pub fn clear(&self) -> anyhow::Result<()> {
+ self.programs.clear()
+ }
+
+ /// Attach the responder to one guest interface (a tap plug). A link operation on the pinned
+ /// programs only, it never loads anything. Whatever a full pass pinned is a program and a map
+ /// consistent with each other, so at worst a stale pair serves until the next pass.
+ 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.programs.lock_shared()?;
+ lock.attach_iface(ifindex)
+ .with_context(|| format!("dhcp responder for {iface}"))
+ }
+}
+
+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;
--
2.47.3
next prev parent reply other threads:[~2026-09-04 9:38 UTC|newest]
Thread overview: 14+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-09-04 9:38 [PATCH manager/network/proxmox{-ebpf,-perl-rs} 00/12] sdn: implement DHCP for all zones using eBPF Hannes Laimer
2026-09-04 9:38 ` [PATCH proxmox-ebpf 01/12] dhcp: add per-tap responder BPF program Hannes Laimer
2026-09-04 9:38 ` Hannes Laimer [this message]
2026-09-04 9:38 ` [PATCH proxmox-perl-rs 03/12] pve-rs: sdn: add dhcp responder bindings Hannes Laimer
2026-09-04 9:38 ` [PATCH pve-network 04/12] sdn: ipam: do not cache negative per-MAC answers, lock the write Hannes Laimer
2026-09-04 9:38 ` [PATCH pve-network 05/12] sdn: subnets: add dhcp-lease-time property Hannes Laimer
2026-09-04 9:38 ` [PATCH pve-network 06/12] sdn: dhcp: only assert a backend's availability for zones using it Hannes Laimer
2026-09-04 9:38 ` [PATCH pve-network 07/12] sdn: dhcp: add ebpf plugin Hannes Laimer
2026-09-04 9:38 ` [PATCH pve-network 08/12] sdn: zones: attach the dhcp responder on tap plug Hannes Laimer
2026-09-04 9:38 ` [PATCH pve-network 09/12] sdn: dhcp: apply mapping edits on the node serving the guest Hannes Laimer
2026-09-04 9:38 ` [PATCH pve-network 10/12] sdn: zones: offer dhcp on all zone types, keep dnsmasq simple-only Hannes Laimer
2026-09-04 9:38 ` [PATCH pve-network 11/12] tests: cover the ebpf dhcp backend and ipam API mapping pushes Hannes Laimer
2026-09-04 9:38 ` [PATCH pve-manager 12/12] ui: sdn: dhcp backend selector on all zones, expose dhcp options Hannes Laimer
-- strict thread matches above, loose matches on Subject: below --
2026-09-02 12:47 [RFC manager/network/proxmox{-ebpf,-perl-rs} 00/12] sdn: implement DHCP for all zones using eBPF Hannes Laimer
2026-09-02 12:47 ` [PATCH proxmox-ebpf 02/12] dhcp: add responder subsystem Hannes Laimer
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=20260904093835.1050030-3-h.laimer@proxmox.com \
--to=h.laimer@proxmox.com \
--cc=pve-devel@lists.proxmox.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox