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: 14+ 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
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 a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox