From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from gate001.proxmox.com (gate001.proxmox.com [IPv6:2a0f:8001:1:32::40]) by lore.proxmox.com (Postfix) with ESMTPS id BF03B1FF0AB for ; Wed, 21 Oct 2026 09:33:03 +0200 (CEST) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 4E93A2147A; Wed, 21 Oct 2026 09:33:01 +0200 (CEST) Message-ID: <8279a4bf-8c35-418f-b1e6-66a9522d17e0@proxmox.com> Date: Wed, 21 Oct 2026 09:32:55 +0200 MIME-Version: 1.0 User-Agent: Mozilla Thunderbird Subject: Re: [PATCH proxmox-perl-rs v3 03/17] pve-rs: sdn: add bindings for the eBPF DHCP responder To: Stefan Hanreich , pve-devel@lists.proxmox.com References: <20261020080005.470757-1-h.laimer@proxmox.com> <20261020080005.470757-4-h.laimer@proxmox.com> <83ea64ef-6097-4f74-a824-bd0cfbdf8860@proxmox.com> From: Hannes Laimer Content-Language: en-US In-Reply-To: <83ea64ef-6097-4f74-a824-bd0cfbdf8860@proxmox.com> Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 7bit X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1792567976036 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.384 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: JWZS672MANGUNTFPFZPV5YRIU62HOWYH X-Message-ID-Hash: JWZS672MANGUNTFPFZPV5YRIU62HOWYH X-MailFrom: h.laimer@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 2026-10-20 15:10, Stefan Hanreich wrote: > 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 grrr, patch ordering in thunderbird.. but yes, like in 02/17 i'd rather not include another repo in this series, id do a small follow up with the mac type change included > >> + } >> + >> + // 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; > > > > >