From: Hannes Laimer <h.laimer@proxmox.com>
To: pve-devel@lists.proxmox.com
Subject: [PATCH proxmox-ebpf v3 02/17] dhcp: add the subsystem that loads the responder and keeps its records
Date: Tue, 20 Oct 2026 09:59:50 +0200 [thread overview]
Message-ID: <20261020080005.470757-3-h.laimer@proxmox.com> (raw)
In-Reply-To: <20261020080005.470757-1-h.laimer@proxmox.com>
The subsystem loads the program, pins a map with one entry per guest
interface and attaches the program to the interfaces it serves.
Callers run a full pass over every guest interface, plug or unplug one
interface, or change the records of a guest's interfaces.
A load can leave the map empty, and only a full pass is handed every
guest interface. So the full pass is the only step that loads. A plug
or a record change that finds nothing loaded reports that a full pass
has to run first.
A hotplug moves a tap before the guest config names the new bridge,
and no lock closes that gap. So every entry names the vnet its writer
saw the interface on. A pass or the record change of a mapping edit
leaves an entry naming another vnet alone. A plug writes outright, so
plugs share the lock. The edit's record change reads the entry before
it writes, so it takes the lock exclusively.
Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
src/dhcp/mod.rs | 1031 +++++++++++++++++++++++++++++++++++++++++++++
src/dhcp/types.rs | 64 +++
src/lib.rs | 3 +
src/subsystem.rs | 2 +-
4 files changed, 1099 insertions(+), 1 deletion(-)
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..d0c4148
--- /dev/null
+++ b/src/dhcp/mod.rs
@@ -0,0 +1,1031 @@
+//! The dhcp subsystem, a per-tap DHCPv4 responder answering from a pinned map of one entry per
+//! interface.
+//!
+//! The map holds nothing of its own. The SDN side hands in every guest interface with the answer
+//! it gets, if any. A full pass makes the kernel match that for the interfaces it names. A record
+//! change or a plug writes its own interfaces only. A linked interface the pass does not name
+//! keeps its link and its entry. A plug runs under the shared lock and writes its entry whatever
+//! is there. A pass runs under the exclusive lock, and so does the record change an edit pushes,
+//! since it reads the entry's vnet before it writes. A guest's own start holds the guest lock, so
+//! nothing else plugs its interfaces meanwhile and it writes its records under the shared lock
+//! without a read.
+//!
+//! A hotplug plugs the tap before the guest config names the new bridge, and no lock orders that.
+//! So every entry names the vnet its writer saw the interface on. A pass or an edit's record change
+//! leaves an entry alone that names another vnet than the configs do, and the link follows such an
+//! entry. A plug replaces it. Plugs of one interface are serialized by the guest lock, so the plug
+//! is the latest word on where its interface sits. An unplug drops the interface's entry. An
+//! interface that went away without a link keeps its entry until the next pass. One plugged onto a
+//! bridge this subsystem does not serve trades its entry for a marker naming that place.
+mod types;
+
+use std::collections::{HashMap, HashSet};
+use std::io::ErrorKind;
+use std::net::Ipv4Addr;
+
+use anyhow::{Context, bail};
+use aya::maps::MapError;
+
+use self::types::*;
+use crate::subsystem::{ApplyLock, Exclusive, NotLoaded, Shared, TcPrograms};
+use crate::tc::{Direction, LinkPin};
+use crate::tc_programs;
+
+/// One guest interface as the configs know it. `serve` says whether a pass links it. A plug
+/// links its interface regardless. `record` is the answer it gets there, and without one the
+/// interface gets no answer. `vnet` is the vnet it sits on, none for a plain bridge.
+pub struct Iface {
+ pub name: String,
+ pub vnet: Option<String>,
+ pub serve: bool,
+ pub record: Option<Record>,
+}
+
+/// One interface's answer, the address and every option the reply carries.
+pub struct Record {
+ 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>,
+ pub mac: [u8; 6],
+}
+
+/// What became of an unplugged interface.
+pub enum Unplugged {
+ /// Gone, or going with the unplug.
+ Gone,
+ /// Plugged onto a bridge this subsystem does not serve, the vnet it sits on now, none for a
+ /// plain bridge.
+ MovedTo(Option<String>),
+}
+
+/// What a single-entry step did.
+#[derive(Debug, PartialEq, Eq)]
+pub enum Outcome {
+ /// Nothing is left to do. The entry is in place, or its interface is not here.
+ Done,
+ /// Nothing is loaded to write into. Only a full pass loads, so the caller runs one and retries.
+ NeedsFullPass,
+}
+
+const RECORDS_MAP: &str = "dhcp_iface_records";
+const MAPS: [&str; 1] = [RECORDS_MAP];
+
+// bump with any change to the key or value layout of a map in dhcp.bpf.c
+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 PROGRAM_NAMES: [(Direction, &str); 1] = [(Direction::Ingress, "tc_dhcp_ingress")];
+
+type RecordsMap = aya::maps::HashMap<aya::maps::MapData, u32, DhcpRecord>;
+
+static PROGRAMS: TcPrograms = tc_programs! {
+ name: "dhcp",
+ programs: &PROGRAM_NAMES,
+ maps: &MAPS,
+ schema_version: SCHEMA_VERSION,
+};
+
+pub struct DhcpSubsystem {
+ programs: &'static TcPrograms,
+}
+
+impl Default for DhcpSubsystem {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl DhcpSubsystem {
+ pub fn new() -> Self {
+ Self {
+ programs: &PROGRAMS,
+ }
+ }
+
+ /// The lock a plug runs under, held until [`attach`](SharedLock::attach) returns. Plugs run
+ /// next to each other and wait only behind an exclusive holder.
+ pub fn lock_shared(&self) -> anyhow::Result<SharedLock> {
+ Ok(SharedLock {
+ lock: self.programs.lock_shared()?,
+ })
+ }
+
+ /// The lock a full pass or a record change runs under, held until the change is written.
+ pub fn lock_exclusive(&self) -> anyhow::Result<ExclusiveLock> {
+ Ok(ExclusiveLock {
+ lock: self.programs.lock_exclusive()?,
+ })
+ }
+
+ /// A tap unplug. The interface may be gone by then, so its link is found by the name the pin
+ /// carries. [`Unplugged`] decides what becomes of the entry.
+ pub fn detach(&self, iface: &str, unplugged: Unplugged) -> anyhow::Result<()> {
+ // resolved before the lock wait, an interface recreated under the name meanwhile is not
+ // this unplug's and keeps its link
+ let now = ifindex_of(iface)?;
+ // every guest unplug on the host lands here. Most never had a link and must not wait
+ // behind a pass. One without a link whose interface is gone leaves its entry to the
+ // next pass
+ if !self.programs.link_pinned(iface) && now.is_none() {
+ return Ok(());
+ }
+ let lock = self.programs.lock_shared()?;
+
+ if !lock.is_current() {
+ log::debug!("dhcp detach: nothing current, {iface} is left to the next full pass");
+ return Ok(());
+ }
+ // the map is opened and the marker built before the links go, so a failure there
+ // leaves them attached
+ let mut map: RecordsMap = lock.hash_map(RECORDS_MAP)?;
+ let marker = match &unplugged {
+ Unplugged::Gone => None,
+ Unplugged::MovedTo(vnet) => Some(DhcpRecord::removed(place(vnet.as_deref())?)),
+ };
+ // an interface without a link has an entry as well, a marker where it is not served
+ let mut ifindexes = lock.detach_named(iface, |pin| {
+ Some(pin.ifindex) != now && pin_alive(pin).unwrap_or(false)
+ })?;
+ if let Some(now) = now.filter(|now| !ifindexes.contains(now)) {
+ ifindexes.push(now);
+ }
+ // the marker is the moved interface's. Older incarnations under the name are gone
+ unplug_entries(&mut map, &ifindexes, now.zip(marker))
+ }
+}
+
+/// The shared lock of the responder, what a plug holds, and the record change of a guest's own
+/// start.
+pub struct SharedLock {
+ lock: ApplyLock<'static, Shared>,
+}
+
+impl SharedLock {
+ /// A tap plug, the entry goes in and the program on. The plug loads nothing, so with nothing
+ /// pinned the caller runs a full pass first.
+ pub fn attach(&self, iface: &Iface) -> anyhow::Result<Outcome> {
+ if !self.lock.is_current() {
+ return Ok(Outcome::NeedsFullPass);
+ }
+ let Some(ifindex) = ifindex_of(iface.name.as_str())? else {
+ log::info!("dhcp attach: {} is gone, nothing to do", iface.name);
+ return Ok(Outcome::Done);
+ };
+ // the entry goes in before the program runs, a marker when there is no record or one
+ // the program cannot carry. A guest start must not fail on a bad record
+ let mut map: RecordsMap = self.lock.hash_map(RECORDS_MAP)?;
+ let entry = match iface.entry() {
+ Ok(entry) => entry,
+ Err(e) => {
+ log::warn!(
+ "dhcp: {} attaches with nothing to answer: {e:#}",
+ iface.name
+ );
+ DhcpRecord::removed(iface.place()?)
+ }
+ };
+ write_entry(&mut map, ifindex, &entry, Elsewhere::Override)?;
+ match self.lock.attach_iface(ifindex) {
+ Ok(()) => Ok(Outcome::Done),
+ Err(e) if e.downcast_ref::<NotLoaded>().is_some() => Ok(Outcome::NeedsFullPass),
+ Err(e) => Err(e).with_context(|| format!("dhcp responder for {}", iface.name)),
+ }
+ }
+
+ /// A record change for a caller that holds the guest lock. Nothing else plugs the guest's
+ /// interfaces under that lock, so each entry is written outright, one atomic write, without
+ /// reading what it holds.
+ pub fn update(&self, ifaces: &[Iface]) -> anyhow::Result<Outcome> {
+ update_entries(&self.lock, ifaces, Elsewhere::Override)
+ }
+}
+
+/// The exclusive lock of the responder, what a full pass and a record change hold.
+pub struct ExclusiveLock {
+ lock: ApplyLock<'static, Exclusive>,
+}
+
+impl ExclusiveLock {
+ /// The full pass. Afterward every interface the configs name that exists here has its entry,
+ /// a record where it is served with one and a marker otherwise, and a link is on every
+ /// served interface and on every interface whose entry names another vnet. Entries of
+ /// interfaces that are gone are dropped, as are entries of live interfaces the configs do
+ /// not name and that have no link. Bad input leaves its interface as it is, with a warning.
+ pub fn apply(&self, ifaces: &[Iface]) -> anyhow::Result<()> {
+ self.lock.ensure_loaded()?;
+ let present = present_ifindexes(ifaces)?;
+ // a link the configs do not name stays while its interface lives under the pin's
+ // name. A reused index is not that interface
+ let mut unknown_alive = HashSet::new();
+ for pin in self.lock.attached()? {
+ if !present.contains_key(&pin.ifindex) && pin_alive(&pin)? {
+ unknown_alive.insert(pin.ifindex);
+ }
+ }
+ let mut map: RecordsMap = self.lock.hash_map(RECORDS_MAP)?;
+ let (elsewhere, failed) = sync_records(&mut map, &present, &unknown_alive)?;
+ // a failed write or removal leaves its entry as it is. The links are still put right
+ // and every failure is reported once the rest is done
+ let links = self
+ .lock
+ .reconcile(&wanted_links(&present, &elsewhere, &unknown_alive));
+ match (failed, links) {
+ (0, links) => links,
+ (failed, Ok(())) => bail!("{failed} entries could not be changed"),
+ (failed, Err(e)) => {
+ Err(e).with_context(|| format!("{failed} entries could not be changed"))
+ }
+ }
+ }
+
+ /// A record change. Interfaces not found here are skipped. The rest are written whether they
+ /// have a link yet or not. A record the reply cannot carry leaves its interface as it is.
+ /// With nothing loaded the caller runs a full pass first, like a plug.
+ pub fn update(&self, ifaces: &[Iface]) -> anyhow::Result<Outcome> {
+ update_entries(&self.lock, ifaces, Elsewhere::Skip)
+ }
+
+ /// Detach everywhere and drop the pinned state, for the last zone leaving the backend.
+ pub fn clear(&self) -> anyhow::Result<()> {
+ self.lock.clear()
+ }
+}
+
+fn update_entries<M>(
+ lock: &ApplyLock<'static, M>,
+ ifaces: &[Iface],
+ elsewhere: Elsewhere,
+) -> anyhow::Result<Outcome> {
+ if !lock.is_current() {
+ return Ok(Outcome::NeedsFullPass);
+ }
+ let mut map: RecordsMap = lock.hash_map(RECORDS_MAP)?;
+ let mut failed = 0usize;
+ for iface in ifaces {
+ let ifindex = match ifindex_of(iface.name.as_str()) {
+ Ok(Some(ifindex)) => ifindex,
+ Ok(None) => continue,
+ Err(e) => {
+ log::warn!("dhcp: {e:#}");
+ failed += 1;
+ continue;
+ }
+ };
+ let entry = match iface.entry() {
+ Ok(entry) => entry,
+ Err(e) => {
+ log::warn!("dhcp: {} is left as it is: {e:#}", iface.name);
+ continue;
+ }
+ };
+ if let Err(e) = write_entry(&mut map, ifindex, &entry, elsewhere) {
+ log::warn!("dhcp: writing the entry of {}: {e:#}", iface.name);
+ failed += 1;
+ }
+ }
+ if failed > 0 {
+ bail!("{failed} interfaces could not be updated");
+ }
+ Ok(Outcome::Done)
+}
+
+const NO_VNET: [u8; 8] = [0; 8];
+
+// the map update flag that refuses a key the map does not hold, from the kernel's uapi bpf.h
+const BPF_EXIST: u64 = 2;
+
+fn unplug_entries(
+ map: &mut impl RecordStore,
+ ifindexes: &[u32],
+ moved: Option<(u32, DhcpRecord)>,
+) -> anyhow::Result<()> {
+ for &ifindex in ifindexes {
+ match moved {
+ // only an existing entry becomes a marker, so an interface without one gets none
+ Some((current, marker)) if current == ifindex => map.mark(ifindex, marker)?,
+ _ => map.remove(ifindex)?,
+ }
+ }
+ Ok(())
+}
+
+// the vnet id padded with zeros, all zero for none
+fn place(vnet: Option<&str>) -> anyhow::Result<[u8; 8]> {
+ let Some(vnet) = vnet else {
+ return Ok(NO_VNET);
+ };
+ let bytes = vnet.as_bytes();
+ if bytes.is_empty() || bytes.len() > NO_VNET.len() {
+ bail!("vnet id {vnet:?} does not fit a record");
+ }
+ let mut place = NO_VNET;
+ place[..bytes.len()].copy_from_slice(bytes);
+ Ok(place)
+}
+
+// the record map as the writers see it, so their decisions run against a plain map in tests
+trait RecordStore {
+ fn get(&self, ifindex: u32) -> anyhow::Result<Option<DhcpRecord>>;
+ fn insert(&mut self, ifindex: u32, rec: DhcpRecord) -> anyhow::Result<()>;
+ // replace an entry that exists and do nothing for one that does not, in one step, so it is
+ // safe under the shared lock
+ fn mark(&mut self, ifindex: u32, rec: DhcpRecord) -> anyhow::Result<()>;
+ // a missing entry is removed already
+ fn remove(&mut self, ifindex: u32) -> anyhow::Result<()>;
+ fn entries(&self) -> anyhow::Result<Vec<(u32, DhcpRecord)>>;
+}
+
+impl RecordStore for RecordsMap {
+ fn get(&self, ifindex: u32) -> anyhow::Result<Option<DhcpRecord>> {
+ match aya::maps::HashMap::get(self, &ifindex, 0) {
+ Ok(live) => Ok(Some(live)),
+ Err(MapError::KeyNotFound) => Ok(None),
+ Err(e) => Err(e.into()),
+ }
+ }
+
+ fn insert(&mut self, ifindex: u32, rec: DhcpRecord) -> anyhow::Result<()> {
+ aya::maps::HashMap::insert(self, ifindex, rec, 0)?;
+ Ok(())
+ }
+
+ fn mark(&mut self, ifindex: u32, rec: DhcpRecord) -> anyhow::Result<()> {
+ match aya::maps::HashMap::insert(self, ifindex, rec, BPF_EXIST) {
+ Ok(()) => Ok(()),
+ // the kernel answers a missing key with ENOENT, and aya passes that through as is
+ Err(MapError::SyscallError(e)) if e.io_error.kind() == ErrorKind::NotFound => Ok(()),
+ Err(e) => Err(e.into()),
+ }
+ }
+
+ fn remove(&mut self, ifindex: u32) -> anyhow::Result<()> {
+ match aya::maps::HashMap::remove(self, &ifindex) {
+ Ok(()) => Ok(()),
+ // the kernel answers a missing key with ENOENT, and aya passes that through as is
+ Err(MapError::SyscallError(e)) if e.io_error.kind() == ErrorKind::NotFound => Ok(()),
+ Err(e) => Err(e.into()),
+ }
+ }
+
+ // a record the listing cannot read is one the pass cannot compare against, so the pass
+ // fails rather than writes past it
+ fn entries(&self) -> anyhow::Result<Vec<(u32, DhcpRecord)>> {
+ self.iter()
+ .collect::<Result<Vec<_>, _>>()
+ .context("listing the records")
+ }
+}
+
+// what a write does with an entry naming another vnet than its own
+#[derive(Clone, Copy)]
+enum Elsewhere {
+ // a record change, its configs are stale for the interface
+ Skip,
+ // a plug, or a record change under the guest lock, both see where the interface sits now
+ Override,
+}
+
+fn write_entry(
+ map: &mut impl RecordStore,
+ ifindex: u32,
+ rec: &DhcpRecord,
+ elsewhere: Elsewhere,
+) -> anyhow::Result<()> {
+ if matches!(elsewhere, Elsewhere::Skip)
+ && let Some(live) = map.get(ifindex)?
+ && live.vnet != rec.vnet
+ {
+ log::debug!("dhcp: ifindex {ifindex} sits elsewhere than the configs say, leaving it");
+ return Ok(());
+ }
+ map.insert(ifindex, *rec)
+}
+
+// entries of live links the pass does not know stay, since their plug wrote them. The other
+// unknown ones go with their interface. Returns the interfaces whose entry names another vnet
+// than the configs, and how many writes failed
+fn sync_records(
+ map: &mut impl RecordStore,
+ present: &HashMap<u32, &Iface>,
+ unknown_alive: &HashSet<u32>,
+) -> anyhow::Result<(HashSet<u32>, usize)> {
+ let live: HashMap<u32, DhcpRecord> = map.entries()?.into_iter().collect();
+ let dead: Vec<u32> = live
+ .keys()
+ .filter(|ifindex| !present.contains_key(ifindex) && !unknown_alive.contains(ifindex))
+ .copied()
+ .collect();
+ let mut failed = 0usize;
+ for &ifindex in &dead {
+ if let Err(e) = map.remove(ifindex) {
+ log::warn!("dhcp: removing the record of ifindex {ifindex}: {e:#}");
+ failed += 1;
+ }
+ }
+ let mut written = 0usize;
+ let mut elsewhere = HashSet::new();
+
+ for (ifindex, iface) in present {
+ // bad input leaves its interface as it is and the pass goes on
+ let desired = match iface.entry() {
+ Ok(desired) => desired,
+ Err(e) => {
+ log::warn!("dhcp: {} is left as it is: {e:#}", iface.name);
+ continue;
+ }
+ };
+ match live.get(ifindex) {
+ Some(entry) if entry.vnet != desired.vnet => {
+ elsewhere.insert(*ifindex);
+ }
+
+ Some(entry) if *entry == desired => {}
+ _ => match map.insert(*ifindex, desired) {
+ Ok(()) => written += 1,
+ Err(e) => {
+ log::warn!("dhcp: writing the entry of {}: {e:#}", iface.name);
+ failed += 1;
+ }
+ },
+ }
+ }
+
+ if written + dead.len() + elsewhere.len() > 0 {
+ log::info!(
+ "dhcp: {written} entries written, {} removed, {} left where their plug put them",
+ dead.len(),
+ elsewhere.len()
+ );
+ } else {
+ log::debug!("dhcp: {} records, no changes", live.len());
+ }
+ Ok((elsewhere, failed))
+}
+
+// the interface's index here, none when it does not exist. Any other failure says nothing about
+// the interface, and a step taking it for gone would detach what it could not resolve
+fn ifindex_of(name: &str) -> anyhow::Result<Option<u32>> {
+ match nix::net::if_::if_nametoindex(name) {
+ Ok(ifindex) => Ok(Some(ifindex)),
+ Err(nix::errno::Errno::ENODEV) => Ok(None),
+ Err(e) => Err(e).with_context(|| format!("resolve interface {name}")),
+ }
+}
+
+// the named interfaces that exist here by index, the names come from configs cluster-wide and
+// most belong to other nodes
+fn present_ifindexes(ifaces: &[Iface]) -> anyhow::Result<HashMap<u32, &Iface>> {
+ let mut present = HashMap::new();
+ for iface in ifaces {
+ if let Some(ifindex) = ifindex_of(iface.name.as_str())? {
+ present.insert(ifindex, iface);
+ }
+ }
+ Ok(present)
+}
+
+// the link follows the configs where they agree with the entry. An entry naming another vnet
+// keeps its link, and a served interface without a mapping gets one. So a record change filling
+// a marker attaches nothing itself. A live link the configs do not know stays
+fn wanted_links(
+ present: &HashMap<u32, &Iface>,
+ elsewhere: &HashSet<u32>,
+ unknown_alive: &HashSet<u32>,
+) -> HashSet<u32> {
+ let mut wanted: HashSet<u32> = present
+ .iter()
+ .filter(|(ifindex, iface)| elsewhere.contains(ifindex) || iface.serve)
+ .map(|(ifindex, _)| *ifindex)
+ .collect();
+
+ wanted.extend(unknown_alive);
+ wanted
+}
+
+// whether the link's interface still exists under the name and index its pin carries
+fn pin_alive(pin: &LinkPin) -> anyhow::Result<bool> {
+ Ok(ifindex_of(pin.name.as_str())? == Some(pin.ifindex))
+}
+
+impl Iface {
+ // a marker where nothing is served
+ fn entry(&self) -> anyhow::Result<DhcpRecord> {
+ let vnet = self.place()?;
+ match &self.record {
+ Some(record) if self.serve => record
+ .entry(vnet)
+ .with_context(|| format!("record for {}", self.name)),
+ _ => Ok(DhcpRecord::removed(vnet)),
+ }
+ }
+
+ fn place(&self) -> anyhow::Result<[u8; 8]> {
+ place(self.vnet.as_deref()).with_context(|| format!("interface {}", self.name))
+ }
+}
+
+impl Record {
+ // refused here is what the reply cannot carry. Zero stands for absent in the entry, so an
+ // address or an MTU of zero cannot be told from none
+ fn entry(&self, vnet: [u8; 8]) -> anyhow::Result<DhcpRecord> {
+ if self.prefixlen == 0 || self.prefixlen > 32 {
+ bail!("prefixlen {} out of range", self.prefixlen);
+ }
+ if self.ip.is_unspecified() {
+ bail!("no address");
+ }
+ if self.server_id.is_unspecified() {
+ bail!("no server address");
+ }
+ if self.lease == 0 {
+ bail!("lease of zero seconds");
+ }
+ if self.mtu.is_some_and(|mtu| mtu < 68) {
+ bail!("mtu below the IPv4 minimum of 68");
+ }
+ if self.mac == [0; 6] {
+ bail!("no MAC");
+ }
+ let netmask = u32::MAX << (32 - self.prefixlen);
+ Ok(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(),
+ vnet,
+ mac: self.mac,
+ })
+ }
+}
+
+#[cfg(test)]
+mod test {
+ use std::collections::BTreeMap;
+
+ use super::*;
+
+ fn record() -> Record {
+ Record {
+ 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),
+ mac: MAC,
+ }
+ }
+
+ const MAC: [u8; 6] = [0xda, 0x65, 0x8f, 0x18, 0x9b, 0x6f];
+
+ const VNET: [u8; 8] = *b"vnet1\0\0\0";
+
+ #[test]
+ fn converts_full_record() {
+ let rec = record().entry(VNET).unwrap();
+ 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);
+ assert_eq!(rec.vnet, VNET);
+ assert_eq!(rec.mac, MAC);
+
+ let rec = Record {
+ router: None,
+ dns: None,
+ mtu: None,
+ ..record()
+ }
+ .entry(VNET)
+ .unwrap();
+ assert_eq!(rec.router, 0);
+ assert_eq!(rec.dns, 0);
+ assert_eq!(rec.mtu, 0);
+ }
+
+ #[test]
+ fn entries_name_the_vnet_of_the_interface() {
+ let iface = Iface {
+ name: "tap100i0".into(),
+ vnet: Some("testvnet".into()),
+ serve: true,
+ record: Some(record()),
+ };
+ assert_eq!(&iface.entry().unwrap().vnet, b"testvnet");
+ let iface = Iface {
+ name: "tap100i0".into(),
+ vnet: None,
+ serve: false,
+ record: None,
+ };
+ assert_eq!(iface.entry().unwrap().vnet, NO_VNET);
+ let iface = Iface {
+ name: "tap100i0".into(),
+ vnet: Some("ninechars".into()),
+ serve: true,
+ record: Some(record()),
+ };
+ assert!(iface.entry().is_err());
+ let iface = Iface {
+ name: "tap100i0".into(),
+ vnet: Some(String::new()),
+ serve: true,
+ record: Some(record()),
+ };
+ assert!(iface.entry().is_err());
+ }
+
+ #[test]
+ fn unserved_interfaces_get_a_marker() {
+ let iface = Iface {
+ name: "tap100i0".into(),
+ vnet: Some("vnet1".into()),
+ serve: false,
+ record: Some(record()),
+ };
+ assert_eq!(iface.entry().unwrap().ip, 0);
+ assert_eq!(iface.entry().unwrap().vnet, VNET);
+ let iface = Iface {
+ name: "tap100i0".into(),
+ vnet: Some("vnet1".into()),
+ serve: true,
+ record: None,
+ };
+ assert_eq!(iface.entry().unwrap().ip, 0);
+ let iface = Iface {
+ name: "tap100i0".into(),
+ vnet: Some("vnet1".into()),
+ serve: true,
+ record: Some(record()),
+ };
+ assert_ne!(iface.entry().unwrap().ip, 0);
+ }
+
+ #[test]
+ fn refuses_what_the_reply_cannot_carry() {
+ for rec in [
+ Record {
+ prefixlen: 33,
+ ..record()
+ },
+ Record {
+ prefixlen: 0,
+ ..record()
+ },
+ Record {
+ server_id: Ipv4Addr::UNSPECIFIED,
+ ..record()
+ },
+ Record {
+ lease: 0,
+ ..record()
+ },
+ Record {
+ ip: Ipv4Addr::UNSPECIFIED,
+ ..record()
+ },
+ Record {
+ mtu: Some(67),
+ ..record()
+ },
+ Record {
+ mac: [0; 6],
+ ..record()
+ },
+ ] {
+ assert!(rec.entry(VNET).is_err());
+ }
+ let rec = Record {
+ mtu: Some(68),
+ ..record()
+ };
+ assert_eq!(u16::from_be(rec.entry(VNET).unwrap().mtu), 68);
+
+ let rec = Record {
+ prefixlen: 32,
+ ..record()
+ };
+ assert_eq!(u32::from_be(rec.entry(VNET).unwrap().netmask), u32::MAX);
+ let rec = Record {
+ prefixlen: 1,
+ ..rec
+ };
+ assert_eq!(u32::from_be(rec.entry(VNET).unwrap().netmask), 1 << 31);
+ }
+
+ impl RecordStore for BTreeMap<u32, DhcpRecord> {
+ fn get(&self, ifindex: u32) -> anyhow::Result<Option<DhcpRecord>> {
+ Ok(BTreeMap::get(self, &ifindex).copied())
+ }
+
+ fn insert(&mut self, ifindex: u32, rec: DhcpRecord) -> anyhow::Result<()> {
+ BTreeMap::insert(self, ifindex, rec);
+ Ok(())
+ }
+
+ fn mark(&mut self, ifindex: u32, rec: DhcpRecord) -> anyhow::Result<()> {
+ if let Some(entry) = BTreeMap::get_mut(self, &ifindex) {
+ *entry = rec;
+ }
+ Ok(())
+ }
+
+ fn remove(&mut self, ifindex: u32) -> anyhow::Result<()> {
+ BTreeMap::remove(self, &ifindex);
+ Ok(())
+ }
+
+ fn entries(&self) -> anyhow::Result<Vec<(u32, DhcpRecord)>> {
+ Ok(self.iter().map(|(k, v)| (*k, *v)).collect())
+ }
+ }
+
+ const OTHER: [u8; 8] = *b"vnet2\0\0\0";
+
+ fn entry(vnet: [u8; 8]) -> DhcpRecord {
+ record().entry(vnet).unwrap()
+ }
+
+ fn other_answer(vnet: [u8; 8]) -> DhcpRecord {
+ Record {
+ ip: Ipv4Addr::new(10, 0, 0, 200),
+ ..record()
+ }
+ .entry(vnet)
+ .unwrap()
+ }
+
+ #[test]
+ fn a_record_change_writes_on_its_own_vnet() {
+ let mut map = BTreeMap::new();
+ write_entry(&mut map, 1, &entry(VNET), Elsewhere::Skip).unwrap();
+ assert_eq!(map[&1], entry(VNET));
+ write_entry(&mut map, 1, &other_answer(VNET), Elsewhere::Skip).unwrap();
+ assert_eq!(map[&1], other_answer(VNET));
+ // an entry naming another vnet was placed by a plug and stays
+ map.insert(1, entry(OTHER));
+ write_entry(&mut map, 1, &other_answer(VNET), Elsewhere::Skip).unwrap();
+ assert_eq!(map[&1], entry(OTHER));
+ }
+
+ #[test]
+ fn a_plug_writes_whatever_is_there() {
+ let mut map = BTreeMap::new();
+ write_entry(&mut map, 1, &entry(VNET), Elsewhere::Override).unwrap();
+ assert_eq!(map[&1], entry(VNET));
+ map.insert(1, entry(OTHER));
+ write_entry(&mut map, 1, &other_answer(VNET), Elsewhere::Override).unwrap();
+ assert_eq!(map[&1], other_answer(VNET));
+ }
+
+ #[test]
+ fn an_unplug_removes_or_marks_the_entries() {
+ let mut map = BTreeMap::new();
+ map.insert(1, entry(VNET));
+ map.insert(2, entry(VNET));
+ map.insert(3, entry(VNET));
+ unplug_entries(&mut map, &[1, 2], None).unwrap();
+ assert_eq!(map.keys().copied().collect::<Vec<_>>(), [3]);
+ // a missing entry is removed already
+ unplug_entries(&mut map, &[1], None).unwrap();
+ // a move marks the interface that moved, and an older incarnation under its name is gone
+ map.insert(4, entry(VNET));
+ let marker = DhcpRecord::removed(NO_VNET);
+ unplug_entries(&mut map, &[3, 4], Some((4, marker))).unwrap();
+ assert_eq!(map.keys().copied().collect::<Vec<_>>(), [4]);
+ assert_eq!(map[&4].ip, 0);
+ assert_eq!(map[&4].vnet, NO_VNET);
+ // an interface without an entry gets no marker
+ unplug_entries(&mut map, &[5], Some((5, marker))).unwrap();
+ assert!(!map.contains_key(&5));
+ }
+
+ fn iface(name: &str, vnet: Option<&str>, serve: bool, record: Option<Record>) -> Iface {
+ Iface {
+ name: name.into(),
+ vnet: vnet.map(String::from),
+ serve,
+ record,
+ }
+ }
+
+ #[test]
+ fn the_pass_syncs_the_records_it_knows() {
+ let mut map = BTreeMap::new();
+ map.insert(1, entry(VNET)); // same answer as the config
+ map.insert(2, other_answer(VNET)); // a changed answer
+ map.insert(4, entry(VNET)); // not served anymore
+ map.insert(6, entry(VNET)); // its interface is gone
+ map.insert(7, entry(VNET)); // a live link the configs do not name
+ map.insert(8, entry(VNET)); // its mapping is gone
+ let ifaces = [
+ iface("a", Some("vnet1"), true, Some(record())),
+ iface("b", Some("vnet1"), true, Some(record())),
+ iface("d", Some("vnet1"), false, None),
+ iface("e", Some("vnet1"), true, Some(record())), // no entry yet
+ iface("f", Some("vnet1"), true, None), // no entry, served, no mapping
+ iface("g", Some("vnet1"), true, None),
+ ];
+ let present: HashMap<u32, &Iface> =
+ [1, 2, 4, 5, 9, 8].into_iter().zip(ifaces.iter()).collect();
+ let unknown_alive = HashSet::from([7]);
+ let (elsewhere, failed) = sync_records(&mut map, &present, &unknown_alive).unwrap();
+
+ assert!(elsewhere.is_empty());
+ assert_eq!(failed, 0);
+ assert_eq!(map[&1], entry(VNET));
+ assert_eq!(map[&2], entry(VNET), "a changed answer is rewritten");
+ assert_eq!(
+ map[&4].ip, 0,
+ "an interface not served anymore keeps a marker"
+ );
+ assert_eq!(map[&5], entry(VNET), "a new interface gets its entry");
+ assert!(!map.contains_key(&6), "a gone interface loses its entry");
+ assert_eq!(
+ map[&9].ip, 0,
+ "a served interface without a mapping gets a marker"
+ );
+ assert_eq!(map[&8].ip, 0, "a mapping that is gone leaves a marker");
+ assert!(
+ map.contains_key(&7),
+ "a live link the configs do not name keeps its entry"
+ );
+ }
+
+ #[test]
+ fn the_links_follow_the_configs_or_the_entry() {
+ let ifaces = [
+ iface("a", Some("vnet1"), true, Some(record())), // served, entry agrees
+ iface("b", Some("vnet1"), false, None), // not served
+ iface("c", Some("vnet1"), false, None), // config stale, entry has a record
+ iface("d", Some("vnet1"), true, Some(record())), // config stale, entry is a marker
+ ];
+ let present: HashMap<u32, &Iface> = (1..=4).zip(ifaces.iter()).collect();
+ let elsewhere = HashSet::from([3, 4]);
+ let unknown_alive = HashSet::from([9]);
+ assert_eq!(
+ wanted_links(&present, &elsewhere, &unknown_alive),
+ HashSet::from([1, 3, 4, 9])
+ );
+ }
+
+ // a store that refuses one interface's writes
+ struct Flaky(BTreeMap<u32, DhcpRecord>, u32);
+
+ impl RecordStore for Flaky {
+ fn get(&self, ifindex: u32) -> anyhow::Result<Option<DhcpRecord>> {
+ RecordStore::get(&self.0, ifindex)
+ }
+
+ fn insert(&mut self, ifindex: u32, rec: DhcpRecord) -> anyhow::Result<()> {
+ if ifindex == self.1 {
+ bail!("map full");
+ }
+ RecordStore::insert(&mut self.0, ifindex, rec)
+ }
+
+ fn mark(&mut self, ifindex: u32, rec: DhcpRecord) -> anyhow::Result<()> {
+ RecordStore::mark(&mut self.0, ifindex, rec)
+ }
+
+ fn remove(&mut self, ifindex: u32) -> anyhow::Result<()> {
+ if ifindex == self.1 {
+ bail!("map busy");
+ }
+ RecordStore::remove(&mut self.0, ifindex)
+ }
+
+ fn entries(&self) -> anyhow::Result<Vec<(u32, DhcpRecord)>> {
+ RecordStore::entries(&self.0)
+ }
+ }
+
+ #[test]
+ fn bad_input_leaves_its_interface_alone() {
+ let mut map = BTreeMap::new();
+ map.insert(1, entry(VNET));
+ let bad = Record {
+ lease: 0,
+ ..record()
+ };
+ let ifaces = [
+ iface("a", Some("vnet1"), true, Some(bad)),
+ iface("b", Some("vnet1"), true, Some(record())),
+ ];
+ let present: HashMap<u32, &Iface> = (1..=2).zip(ifaces.iter()).collect();
+ let (elsewhere, failed) = sync_records(&mut map, &present, &HashSet::new()).unwrap();
+ assert!(elsewhere.is_empty());
+ assert_eq!(failed, 0);
+ assert_eq!(map[&1], entry(VNET), "the old entry stays");
+ assert_eq!(map[&2], entry(VNET), "the rest is written");
+ }
+
+ #[test]
+ fn a_failed_write_does_not_stop_the_pass() {
+ let mut map = Flaky(BTreeMap::new(), 2);
+ let ifaces = [
+ iface("a", Some("vnet1"), true, Some(record())),
+ iface("b", Some("vnet1"), true, Some(record())),
+ iface("c", Some("vnet1"), true, Some(record())),
+ ];
+ let present: HashMap<u32, &Iface> = (1..=3).zip(ifaces.iter()).collect();
+ let (elsewhere, failed) = sync_records(&mut map, &present, &HashSet::new()).unwrap();
+ assert!(elsewhere.is_empty());
+ assert_eq!(failed, 1);
+ assert!(map.0.contains_key(&1));
+ assert!(!map.0.contains_key(&2));
+ assert!(map.0.contains_key(&3));
+ }
+
+ // a store whose listing fails
+ struct Unlistable(BTreeMap<u32, DhcpRecord>);
+
+ impl RecordStore for Unlistable {
+ fn get(&self, ifindex: u32) -> anyhow::Result<Option<DhcpRecord>> {
+ RecordStore::get(&self.0, ifindex)
+ }
+
+ fn insert(&mut self, ifindex: u32, rec: DhcpRecord) -> anyhow::Result<()> {
+ RecordStore::insert(&mut self.0, ifindex, rec)
+ }
+
+ fn mark(&mut self, ifindex: u32, rec: DhcpRecord) -> anyhow::Result<()> {
+ RecordStore::mark(&mut self.0, ifindex, rec)
+ }
+
+ fn remove(&mut self, ifindex: u32) -> anyhow::Result<()> {
+ RecordStore::remove(&mut self.0, ifindex)
+ }
+
+ fn entries(&self) -> anyhow::Result<Vec<(u32, DhcpRecord)>> {
+ bail!("map gone")
+ }
+ }
+
+ #[test]
+ fn a_failed_listing_fails_the_pass_before_it_writes() {
+ let mut map = Unlistable(BTreeMap::new());
+ map.0.insert(1, entry(OTHER));
+ let ifaces = [iface("a", Some("vnet1"), true, Some(record()))];
+ let present: HashMap<u32, &Iface> = [(1, &ifaces[0])].into_iter().collect();
+ assert!(sync_records(&mut map, &present, &HashSet::new()).is_err());
+ assert_eq!(map.0[&1].vnet, OTHER);
+ }
+
+ #[test]
+ fn a_failed_removal_counts_like_a_failed_write() {
+ let mut map = Flaky(BTreeMap::new(), 2);
+ map.0.insert(2, entry(VNET));
+ let ifaces = [iface("a", Some("vnet1"), true, Some(record()))];
+ let present: HashMap<u32, &Iface> = [(1, &ifaces[0])].into_iter().collect();
+ let (_, failed) = sync_records(&mut map, &present, &HashSet::new()).unwrap();
+ assert_eq!(failed, 1);
+ assert!(map.0.contains_key(&1));
+ assert!(map.0.contains_key(&2));
+ }
+
+ #[test]
+ fn the_pass_leaves_entries_of_another_vnet_to_their_link() {
+ let mut map = BTreeMap::new();
+ map.insert(1, entry(OTHER)); // a plug the configs trail, with a record
+ map.insert(2, DhcpRecord::removed(NO_VNET)); // moved to a plain bridge
+ map.insert(3, DhcpRecord::removed(NO_VNET)); // the configs caught up with that
+ let ifaces = [
+ iface("a", Some("vnet1"), true, Some(record())),
+ iface("b", Some("vnet1"), true, Some(record())),
+ iface("c", None, false, None),
+ ];
+ let present: HashMap<u32, &Iface> = (1..=3).zip(ifaces.iter()).collect();
+ let (elsewhere, _) = sync_records(&mut map, &present, &HashSet::new()).unwrap();
+
+ assert_eq!(elsewhere, HashSet::from([1, 2]));
+
+ assert_eq!(map[&1].vnet, OTHER);
+ assert_eq!(map[&2].vnet, NO_VNET);
+ assert_eq!(
+ map[&3].ip, 0,
+ "a marker the configs agree with stays as it is"
+ );
+ }
+}
diff --git a/src/dhcp/types.rs b/src/dhcp/types.rs
new file mode 100644
index 0000000..71a1b94
--- /dev/null
+++ b/src/dhcp/types.rs
@@ -0,0 +1,64 @@
+//! Keep in sync with 'bpf/types.h'.
+
+/// 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, Debug, PartialEq, Eq)]
+pub struct DhcpRecord {
+ /// 0 = removed, the entry is a marker naming its vnet
+ 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,
+ /// The vnet the writer saw the interface on, the id padded with zeros, all zero for none.
+ /// Never read by the program.
+ pub vnet: [u8; 8],
+ /// The NIC's MAC, a request carrying another is passed on. Zero in a marker.
+ pub mac: [u8; 6],
+}
+
+impl DhcpRecord {
+ /// The marker a removal leaves behind, on the vnet the writer saw the interface on.
+ pub fn removed(vnet: [u8; 8]) -> Self {
+ Self {
+ ip: 0,
+ netmask: 0,
+ router: 0,
+ dns: 0,
+ server_id: 0,
+ lease: 0,
+ mtu: 0,
+ vnet,
+ mac: [0; 6],
+ }
+ }
+}
+
+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::<DhcpRecord>(), 40);
+ 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, vnet), 26);
+ assert_eq!(offset_of!(DhcpRecord, mac), 34);
+ }
+}
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 3bf1634..6014dbe 100644
--- a/src/subsystem.rs
+++ b/src/subsystem.rs
@@ -42,7 +42,7 @@ const RUN_ROOT: &str = "/run/proxmox-ebpf";
/// The subsystems in the order their programs run on an interface, the first runs first. A name
/// missing from the list fails the build.
-pub const RUN_ORDER: &[&str] = &[];
+pub const RUN_ORDER: &[&str] = &["dhcp"];
const fn rank_of(name: &str) -> usize {
let mut i = 0;
--
2.47.3
next prev parent reply other threads:[~2026-10-20 8:02 UTC|newest]
Thread overview: 43+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-10-20 7:59 [PATCH container/docs/manager/network/proxmox{-ebpf,-perl-rs}/qemu-server v3 00/17] sdn: implement DHCP for all zones using eBPF Hannes Laimer
2026-10-20 7:59 ` [PATCH proxmox-ebpf v3 01/17] dhcp: add the per-tap DHCPv4 responder program Hannes Laimer
2026-10-20 7:59 ` Hannes Laimer [this message]
2026-10-20 13:09 ` [PATCH proxmox-ebpf v3 02/17] dhcp: add the subsystem that loads the responder and keeps its records Stefan Hanreich
2026-10-21 7:09 ` Hannes Laimer
2026-10-20 7:59 ` [PATCH proxmox-perl-rs v3 03/17] pve-rs: sdn: add bindings for the eBPF DHCP responder Hannes Laimer
2026-10-20 13:10 ` Stefan Hanreich
2026-10-21 7:32 ` Hannes Laimer
2026-10-20 7:59 ` [PATCH pve-network v3 04/17] sdn: dhcp: replace a stale dnsmasq reservation instead of dropping it Hannes Laimer
2026-10-20 13:15 ` Stefan Hanreich
2026-10-20 7:59 ` [PATCH pve-network v3 05/17] sdn: dhcp: update reservations when ip mappings change Hannes Laimer
2026-10-20 13:42 ` Stefan Hanreich
2026-10-21 7:21 ` Hannes Laimer
2026-10-20 7:59 ` [PATCH pve-network v3 06/17] sdn: ipam: key the MAC cache by lower-case MAC, stop caching misses Hannes Laimer
2026-10-20 13:10 ` Stefan Hanreich
2026-10-21 6:17 ` Hannes Laimer
2026-10-20 7:59 ` [PATCH pve-network v3 07/17] sdn: dhcp: make the lease time configurable per subnet Hannes Laimer
2026-10-20 13:17 ` Stefan Hanreich
2026-10-20 7:59 ` [PATCH pve-network v3 08/17] sdn: dhcp: require a backend only if a zone on this node uses it Hannes Laimer
2026-10-20 13:17 ` Stefan Hanreich
2026-10-20 7:59 ` [PATCH pve-network v3 09/17] sdn: dhcp: add an eBPF backend answering on each guest interface Hannes Laimer
2026-10-20 13:10 ` Stefan Hanreich
2026-10-21 6:16 ` Hannes Laimer
2026-10-20 7:59 ` [PATCH pve-network v3 10/17] sdn: zones: report interface plugs and unplugs to the DHCP backends Hannes Laimer
2026-10-20 13:17 ` Stefan Hanreich
2026-10-20 7:59 ` [PATCH pve-network v3 11/17] sdn: dhcp: push IP mapping edits to the guest's node Hannes Laimer
2026-10-20 13:09 ` Stefan Hanreich
2026-10-21 6:44 ` Hannes Laimer
2026-10-20 8:00 ` [PATCH pve-network v3 12/17] sdn: zones: allow the ebpf DHCP backend on every zone type Hannes Laimer
2026-10-20 8:00 ` [PATCH qemu-server v3 13/17] network: sdn: keep the DHCP backends current on guest NICs Hannes Laimer
2026-10-20 13:38 ` Stefan Hanreich
2026-10-20 8:00 ` [PATCH pve-container v3 14/17] net: report NIC plugs and removals to the SDN DHCP backends Hannes Laimer
2026-10-20 13:36 ` Stefan Hanreich
2026-10-21 7:29 ` Hannes Laimer
2026-10-20 8:00 ` [PATCH pve-manager v3 15/17] ui: sdn: add zone DHCP backend selector and subnet DHCP options Hannes Laimer
2026-10-20 13:19 ` Stefan Hanreich
2026-10-20 8:00 ` [PATCH pve-manager v3 16/17] sdn: bring up the DHCP backends at boot before the guests start Hannes Laimer
2026-10-20 13:21 ` Stefan Hanreich
2026-10-21 7:17 ` Hannes Laimer
2026-10-20 8:00 ` [PATCH pve-docs v3 17/17] sdn: dhcp: add eBPF backend section Hannes Laimer
2026-10-20 13:11 ` [PATCH container/docs/manager/network/proxmox{-ebpf,-perl-rs}/qemu-server v3 00/17] sdn: implement DHCP for all zones using eBPF Stefan Hanreich
2026-10-21 7:35 ` Hannes Laimer
2026-10-21 8:46 ` superseded: " 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=20261020080005.470757-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