From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from gate001.proxmox.com (gate001.proxmox.com [45.144.208.40]) by lore.proxmox.com (Postfix) with ESMTPS id 1025E1FF0AA for ; Tue, 20 Oct 2026 15:10:30 +0200 (CEST) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 358C0216A5; Tue, 20 Oct 2026 15:10:27 +0200 (CEST) Message-ID: <83ea64ef-6097-4f74-a824-bd0cfbdf8860@proxmox.com> Date: Tue, 20 Oct 2026 15:10:15 +0200 MIME-Version: 1.0 User-Agent: Mozilla Thunderbird From: Stefan Hanreich Subject: Re: [PATCH proxmox-perl-rs v3 03/17] pve-rs: sdn: add bindings for the eBPF DHCP responder To: pve-devel@lists.proxmox.com References: <20261020080005.470757-1-h.laimer@proxmox.com> <20261020080005.470757-4-h.laimer@proxmox.com> Content-Language: en-US In-Reply-To: <20261020080005.470757-4-h.laimer@proxmox.com> Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 7bit X-SPAM-LEVEL: Spam detection results: 0 AWL 0.396 Adjusted score from AWL reputation of From: address DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment (newer systems) RCVD_IN_DNSWL_MED -2.3 Sender listed at https://www.dnswl.org/, medium trust SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Message-ID-Hash: MNBTMI5WRKKQMZEKFNBR76WYOOAIUXHE X-Message-ID-Hash: MNBTMI5WRKKQMZEKFNBR76WYOOAIUXHE X-MailFrom: s.hanreich@proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox VE development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: On 10/20/26 10:00 AM, Hannes Laimer wrote: > The responder answers with the records the caller hands in. The > caller has to build them under the responder's lock, or a full pass > could write older records over those a plug or record change built > later. So the lock is an object the caller takes before it builds the > records, and every call that hands in records is a method of that > lock. > > Signed-off-by: Hannes Laimer > --- > pve-rs/Cargo.toml | 2 + > pve-rs/Makefile | 3 + > pve-rs/debian/control | 2 + > pve-rs/src/bindings/sdn/dhcp.rs | 229 ++++++++++++++++++++++++++++++++ > pve-rs/src/bindings/sdn/mod.rs | 1 + > 5 files changed, 237 insertions(+) > create mode 100644 pve-rs/src/bindings/sdn/dhcp.rs > > diff --git a/pve-rs/Cargo.toml b/pve-rs/Cargo.toml > index 5ae9082..0adf00b 100644 > --- a/pve-rs/Cargo.toml > +++ b/pve-rs/Cargo.toml > @@ -34,6 +34,7 @@ proxmox-apt = { version = "1.0.1", features = ["cache"] } > proxmox-apt-api-types = "3" > proxmox-base64 = "1" > proxmox-config-digest = "1" > +proxmox-ebpf = { version = "0.1", features = ["dhcp"] } > proxmox-frr = { version = "0.5.1" } > proxmox-http = { version = "1.0.2", features = ["client-sync", "client-trait"] } > proxmox-http-error = "1" > @@ -70,6 +71,7 @@ proxmox-wireguard = { version = "0.1.2" } > # proxmox-config-digest = { path = "../../proxmox/proxmox-config-digest" } > # proxmox-daemon = { path = "../../proxmox/proxmox-daemon" } > # proxmox-dns-api = { path = "../../proxmox/proxmox-dns-api" } > +# proxmox-ebpf = { path = "../../proxmox-ebpf" } > # proxmox-http-error = { path = "../../proxmox/proxmox-http-error" } > # proxmox-http = { path = "../../proxmox/proxmox-http" } > # proxmox-human-byte = { path = "../../proxmox/proxmox-human-byte" } > diff --git a/pve-rs/Makefile b/pve-rs/Makefile > index bb1cd2d..ebe8bd3 100644 > --- a/pve-rs/Makefile > +++ b/pve-rs/Makefile > @@ -32,6 +32,9 @@ PERLMOD_PACKAGES := \ > PVE::RS::OpenId \ > PVE::RS::ResourceScheduling::Static \ > PVE::RS::ResourceScheduling::Dynamic \ > + PVE::RS::SDN::Dhcp \ > + PVE::RS::SDN::Dhcp::ExclusiveLock \ > + PVE::RS::SDN::Dhcp::SharedLock \ > PVE::RS::SDN::Fabrics \ > PVE::RS::SDN::PrefixLists \ > PVE::RS::SDN::RouteMaps \ > diff --git a/pve-rs/debian/control b/pve-rs/debian/control > index 70367dc..521d966 100644 > --- a/pve-rs/debian/control > +++ b/pve-rs/debian/control > @@ -20,6 +20,8 @@ Build-Depends: debhelper-compat (= 13), > librust-proxmox-apt-api-types-3+default-dev (>= 3.0.0-~~), > librust-proxmox-base64-1+default-dev, > librust-proxmox-config-digest-1+default-dev, > + librust-proxmox-ebpf-0.1+default-dev, > + librust-proxmox-ebpf-0.1+dhcp-dev, > librust-proxmox-frr-0.5+default-dev (>= 0.5.1-~~), > librust-proxmox-http-1+client-sync-dev (>= 1.0.2-~~), > librust-proxmox-http-1+client-trait-dev (>= 1.0.2-~~), > diff --git a/pve-rs/src/bindings/sdn/dhcp.rs b/pve-rs/src/bindings/sdn/dhcp.rs > new file mode 100644 > index 0000000..a6cdc95 > --- /dev/null > +++ b/pve-rs/src/bindings/sdn/dhcp.rs > @@ -0,0 +1,229 @@ > +#[perlmod::package(name = "PVE::RS::SDN::Dhcp", lib = "pve_rs")] > +pub mod pve_rs_sdn_dhcp { > + //! The `PVE::RS::SDN::Dhcp` package. > + //! > + //! Bindings for the per-tap eBPF DHCP responder. Interfaces and the records they answer with > + //! are handed in here and served directly from the kernel. The calls that change the responder > + //! are methods of a lock object, so nothing writes to it without holding the lock in the mode > + //! the change needs. > + > + use std::net::Ipv4Addr; > + > + use anyhow::Error; > + use serde::{Deserialize, Deserializer}; > + > + use proxmox_ebpf::dhcp::{DhcpSubsystem, Iface, Record, Unplugged}; > + > + /// One interface's DHCP answer, the address and every option the reply carries. > + #[derive(Deserialize)] > + pub struct DhcpRecord { > + ip: Ipv4Addr, > + prefixlen: u8, > + server_id: Ipv4Addr, > + lease: u32, > + #[serde(default)] > + router: Option, > + #[serde(default)] > + dns: Option, > + #[serde(default)] > + mtu: Option, > + #[serde(deserialize_with = "mac_from_str")] > + mac: [u8; 6], proxmox-network-types has a MAC address type that would fit here if we add an as_slice() method > + } > + > + // a MAC as six hex pairs > + fn mac_from_str<'de, D: Deserializer<'de>>(d: D) -> Result<[u8; 6], D::Error> { > + let text = String::deserialize(d)?; > + let mut mac = [0u8; 6]; > + let mut parts = text.split(':'); > + for byte in &mut mac { > + let part = parts > + .next() > + .ok_or_else(|| serde::de::Error::custom(format!("MAC {text} is too short")))?; > + if part.len() != 2 || !part.bytes().all(|b| b.is_ascii_hexdigit()) { > + return Err(serde::de::Error::custom(format!("MAC {text} is malformed"))); > + } > + *byte = u8::from_str_radix(part, 16) > + .map_err(|_| serde::de::Error::custom(format!("MAC {text} is malformed")))?; > + } > + if parts.next().is_some() { > + return Err(serde::de::Error::custom(format!("MAC {text} is too long"))); > + } > + Ok(mac) > + } would become obsolete if using the MacAddr type > + /// One guest interface, the vnet it sits on if any, whether the responder runs on it and what > + /// it answers there. > + #[derive(Deserialize)] > + pub struct DhcpIface { > + name: String, > + #[serde(default)] > + vnet: Option, > + serve: bool, > + #[serde(default)] > + record: Option, > + } > + > + /// Where an unplugged interface went, the vnet it sits on now, none for a plain bridge. > + #[derive(Deserialize)] > + pub struct DhcpTarget { > + #[serde(default)] > + vnet: Option, > + } > + > + impl From for Record { > + fn from(r: DhcpRecord) -> Self { > + Record { > + ip: r.ip, > + prefixlen: r.prefixlen, > + server_id: r.server_id, > + lease: r.lease, > + router: r.router, > + dns: r.dns, > + mtu: r.mtu, > + mac: r.mac, > + } > + } > + } > + > + impl From for Iface { > + fn from(i: DhcpIface) -> Self { > + Iface { > + name: i.name, > + vnet: i.vnet, > + serve: i.serve, > + record: i.record.map(Record::from), > + } > + } > + } > + > + pub(super) fn to_ifaces(ifaces: Vec) -> Vec { > + ifaces.into_iter().map(Iface::from).collect() > + } > + > + /// A tap unplug. Without a target the interface is gone or going and takes its record with > + /// it. With one it was plugged onto a bridge the responder does not serve and keeps a marker > + /// naming that place. > + #[export] > + pub fn detach(iface: &str, target: Option) -> Result<(), Error> { > + let unplugged = match target { > + None => Unplugged::Gone, > + Some(target) => Unplugged::MovedTo(target.vnet), > + }; > + DhcpSubsystem::new().detach(iface, unplugged) > + } > +} > + > +#[perlmod::package(name = "PVE::RS::SDN::Dhcp::SharedLock", lib = "pve_rs")] > +pub mod pve_rs_sdn_dhcp_shared_lock { > + //! The `PVE::RS::SDN::Dhcp::SharedLock` package. > + //! > + //! The responder's lock taken shared, what a tap plug and a guest's own record change hold. > + //! Released when the object goes away. > + > + use anyhow::Error; > + use perlmod::Value; > + > + use proxmox_ebpf::dhcp::{DhcpSubsystem, Iface, Outcome, Record, SharedLock}; > + > + use super::pve_rs_sdn_dhcp::{DhcpIface, DhcpRecord, to_ifaces}; > + > + /// The lock as Perl holds it. > + pub struct PerlSharedLock { > + lock: SharedLock, > + } > + > + perlmod::declare_magic!(Box : &PerlSharedLock as "PVE::RS::SDN::Dhcp::SharedLock"); > + > + /// Class method: take the lock. Plugs hold it next to each other and wait only behind a full > + /// pass or a record change. > + #[export] > + pub fn new(#[raw] class: Value) -> Result { > + let lock = DhcpSubsystem::new().lock_shared()?; > + Ok(perlmod::instantiate_magic!(&class, MAGIC => Box::new(PerlSharedLock { lock }))) > + } > + > + /// Method: a tap plug onto a vnet with the record it answers. Without a record the interface > + /// gets no answer. Returns false when nothing is loaded yet. An interface gone already counts > + /// as done. > + #[export] > + pub fn attach( > + #[try_from_ref] this: &PerlSharedLock, > + iface: String, > + vnet: String, > + record: Option, > + ) -> Result { > + let iface = Iface { > + name: iface, > + vnet: Some(vnet), > + serve: true, > + record: record.map(Record::from), > + }; > + Ok(this.lock.attach(&iface)? == Outcome::Done) > + } > + > + /// Method: a record change for the listed interfaces by a caller holding the guest lock. > + /// Returns false when nothing is loaded to write into, so a full pass is due. > + #[export] > + pub fn update( > + #[try_from_ref] this: &PerlSharedLock, > + ifaces: Vec, > + ) -> Result { > + Ok(this.lock.update(&to_ifaces(ifaces))? == Outcome::Done) > + } > +} > + > +#[perlmod::package(name = "PVE::RS::SDN::Dhcp::ExclusiveLock", lib = "pve_rs")] > +pub mod pve_rs_sdn_dhcp_exclusive_lock { > + //! The `PVE::RS::SDN::Dhcp::ExclusiveLock` package. > + //! > + //! The responder's lock taken exclusively, what a full pass and a record change hold. Released > + //! when the object goes away. > + > + use anyhow::Error; > + use perlmod::Value; > + > + use proxmox_ebpf::dhcp::{DhcpSubsystem, ExclusiveLock, Outcome}; > + > + use super::pve_rs_sdn_dhcp::{DhcpIface, to_ifaces}; > + > + /// The lock as Perl holds it. > + pub struct PerlExclusiveLock { > + lock: ExclusiveLock, > + } > + > + perlmod::declare_magic!(Box : &PerlExclusiveLock as "PVE::RS::SDN::Dhcp::ExclusiveLock"); > + > + /// Class method: take the lock, waiting out every plug in flight. > + #[export] > + pub fn new(#[raw] class: Value) -> Result { > + let lock = DhcpSubsystem::new().lock_exclusive()?; > + Ok(perlmod::instantiate_magic!(&class, MAGIC => Box::new(PerlExclusiveLock { lock }))) > + } > + > + /// Method: the full pass over every guest interface of the cluster. Those found here get > + /// their entry and a link where served. > + #[export] > + pub fn apply( > + #[try_from_ref] this: &PerlExclusiveLock, > + ifaces: Vec, > + ) -> Result<(), Error> { > + this.lock.apply(&to_ifaces(ifaces)) > + } > + > + /// Method: a record change for the listed interfaces. Those not found here are skipped. > + /// Returns false when nothing is loaded to write into, so a full pass is due. > + #[export] > + pub fn update( > + #[try_from_ref] this: &PerlExclusiveLock, > + ifaces: Vec, > + ) -> Result { > + Ok(this.lock.update(&to_ifaces(ifaces))? == Outcome::Done) > + } > + > + /// Method: detach the responder everywhere and drop its pinned state. > + #[export] > + pub fn clear(#[try_from_ref] this: &PerlExclusiveLock) -> Result<(), Error> { > + this.lock.clear() > + } > +} > diff --git a/pve-rs/src/bindings/sdn/mod.rs b/pve-rs/src/bindings/sdn/mod.rs > index dcae046..4b99b8f 100644 > --- a/pve-rs/src/bindings/sdn/mod.rs > +++ b/pve-rs/src/bindings/sdn/mod.rs > @@ -1,3 +1,4 @@ > +pub(crate) mod dhcp; > pub(crate) mod fabrics; > pub(crate) mod prefix_lists; > pub(crate) mod route_maps;