From: Hannes Laimer <h.laimer@proxmox.com>
To: pve-devel@lists.proxmox.com
Subject: [PATCH proxmox-ebpf 02/12] dhcp: add responder subsystem
Date: Wed, 2 Sep 2026 14:47:29 +0200 [thread overview]
Message-ID: <20260902124739.750853-3-h.laimer@proxmox.com> (raw)
In-Reply-To: <20260902124739.750853-1-h.laimer@proxmox.com>
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
next prev parent reply other threads:[~2026-09-02 12:47 UTC|newest]
Thread overview: 15+ messages / expand[flat|nested] mbox.gz Atom feed top
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 [this message]
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
2026-09-03 4:26 ` 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=20260902124739.750853-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 an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.