From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from gate001.proxmox.com (gate001.proxmox.com [IPv6:2a0f:8001:1:32::40]) by lore.proxmox.com (Postfix) with ESMTPS id C47DB1FF0A5 for ; Fri, 04 Sep 2026 11:38:53 +0200 (CEST) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 1128D215C6; Fri, 04 Sep 2026 11:38:46 +0200 (CEST) From: Hannes Laimer 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 Message-ID: <20260904093835.1050030-3-h.laimer@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260904093835.1050030-1-h.laimer@proxmox.com> References: <20260904093835.1050030-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: 1788514716251 X-SPAM-LEVEL: Spam detection results: 0 AWL -0.867 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: ILUQWE2IKM3EHDU5AZSU6RXSH6CFNGKF X-Message-ID-Hash: ILUQWE2IKM3EHDU5AZSU6RXSH6CFNGKF 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. 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 --- 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, + 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, + 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::(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()); + } + + 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> { + 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; -- 2.47.3