From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from gate001.proxmox.com (gate001.proxmox.com [45.144.208.40]) by lore.proxmox.com (Postfix) with ESMTPS id 781931FF0A7 for ; Wed, 02 Sep 2026 14:47:51 +0200 (CEST) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 1963A214F1; Wed, 02 Sep 2026 14:47:47 +0200 (CEST) From: Hannes Laimer 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 Message-ID: <20260902124739.750853-3-h.laimer@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260902124739.750853-1-h.laimer@proxmox.com> References: <20260902124739.750853-1-h.laimer@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1788353258493 X-SPAM-LEVEL: Spam detection results: 0 AWL -0.812 Adjusted score from AWL reputation of From: address DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment (newer systems) RCVD_IN_DNSWL_MED -2.3 Sender listed at https://www.dnswl.org/, medium trust SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record URIBL_BLACK 3 Contains an URL listed in the URIBL blacklist [types.rs] Message-ID-Hash: SFLGCNMPOFDEAUENMHWBMAK34XCD3DSC X-Message-ID-Hash: SFLGCNMPOFDEAUENMHWBMAK34XCD3DSC X-MailFrom: h.laimer@proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox VE development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: 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 --- 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, + pub dns: Option, + pub mtu: Option, +} + +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::(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::(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::(RECORDS_MAP)?; + let live: HashMap = 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> { + 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> { + 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::(), 8); + assert_eq!(offset_of!(DhcpMacKey, addr), 0); + assert_eq!(offset_of!(DhcpMacKey, _pad), 6); + + assert_eq!(size_of::(), 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