From: Hannes Laimer <h.laimer@proxmox.com>
To: Stefan Hanreich <s.hanreich@proxmox.com>, 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: Wed, 21 Oct 2026 09:09:55 +0200 [thread overview]
Message-ID: <627ccb59-63f0-44b7-8d33-9b76697ffd17@proxmox.com> (raw)
In-Reply-To: <bc334f0b-b57c-427c-9199-c87102f8b121@proxmox.com>
On 2026-10-20 15:09, Stefan Hanreich wrote:
> 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
>
not in its current shape, we need to access the bytes, with the current
type can't. but generally yes, we'd just also need to expose them on the
type. id rather have this as small follow-up that includes the change on
the type
>> +}
>> +
>> +/// 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?
>
hmm, sure, but this is from the lib.. the fn doc mentions it, but i do
see you point. id address this in the lib series, maybe as a follow-up?
>> + 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?
>
yes, ill reword for v4
>> + 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?
>
yes, will do for v4
>> +// 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-21 7:10 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
2026-10-21 7:09 ` Hannes Laimer [this message]
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=627ccb59-63f0-44b7-8d33-9b76697ffd17@proxmox.com \
--to=h.laimer@proxmox.com \
--cc=pve-devel@lists.proxmox.com \
--cc=s.hanreich@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 an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.