public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
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





  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
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal