From: Stefan Hanreich <s.hanreich@proxmox.com>
To: pve-devel@lists.proxmox.com
Subject: Re: [PATCH proxmox-ebpf v3 02/17] dhcp: add the subsystem that loads the responder and keeps its records
Date: Tue, 20 Oct 2026 15:09:26 +0200 [thread overview]
Message-ID: <bc334f0b-b57c-427c-9199-c87102f8b121@proxmox.com> (raw)
In-Reply-To: <20261020080005.470757-3-h.laimer@proxmox.com>
On 10/20/26 10:03 AM, Hannes Laimer wrote:
> 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],
proxmox-network-types has a MAC address type that would fit here and in other structs
> +}
> +
> +/// 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| {
the name confused me a bit here, maybe detach_named_if or something would be more fitting?
> + 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:#}");
error message is missing an error or some other prefix?
> + 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)
> +}
Could we use if_nameindex here instead of querying if_nametoindex for every interface?
> +// 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;
next prev parent reply other threads:[~2026-10-20 13:09 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 ` [PATCH proxmox-ebpf v3 02/17] dhcp: add the subsystem that loads the responder and keeps its records Hannes Laimer
2026-10-20 13:09 ` Stefan Hanreich [this message]
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=bc334f0b-b57c-427c-9199-c87102f8b121@proxmox.com \
--to=s.hanreich@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