From: Hannes Laimer <h.laimer@proxmox.com>
To: pve-devel@lists.proxmox.com
Subject: [PATCH proxmox-perl-rs v6 04/17] pve-rs: sdn: add bindings for the eBPF DHCP responder
Date: Thu, 5 Nov 2026 11:03:03 +0100 [thread overview]
Message-ID: <20261105100316.449163-5-h.laimer@proxmox.com> (raw)
In-Reply-To: <20261105100316.449163-1-h.laimer@proxmox.com>
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 <h.laimer@proxmox.com>
---
Notes:
v6:
* pass on whether an interface sits on the bridge its config names
v5:
* take the MAC with the interface and as an argument of the plug, not
in the record
pve-rs/Cargo.toml | 2 +
pve-rs/Makefile | 3 +
pve-rs/debian/control | 2 +
pve-rs/src/bindings/sdn/dhcp.rs | 217 ++++++++++++++++++++++++++++++++
pve-rs/src/bindings/sdn/mod.rs | 1 +
5 files changed, 225 insertions(+)
create mode 100644 pve-rs/src/bindings/sdn/dhcp.rs
diff --git a/pve-rs/Cargo.toml b/pve-rs/Cargo.toml
index ecedb1ebe747..f7d6abf4efd7 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.6.0" }
proxmox-http = { version = "1.0.2", features = ["client-sync", "client-trait"] }
proxmox-http-error = "1"
@@ -71,6 +72,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/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 60f68c7a21be..c597401f789b 100644
--- a/pve-rs/Makefile
+++ b/pve-rs/Makefile
@@ -33,6 +33,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 ad8611eb1b69..2972f98a91d1 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.6+default-dev (>= 0.6.0-~~),
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 000000000000..b9ea41e6998d
--- /dev/null
+++ b/pve-rs/src/bindings/sdn/dhcp.rs
@@ -0,0 +1,217 @@
+#[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;
+
+ use proxmox_ebpf::dhcp::{DhcpSubsystem, Iface, Record, Unplugged};
+ use proxmox_network_types::mac_address::MacAddress;
+
+ /// 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<Ipv4Addr>,
+ #[serde(default)]
+ dns: Option<Ipv4Addr>,
+ #[serde(default)]
+ mtu: Option<u16>,
+ }
+
+ /// One guest interface, the vnet it sits on if any, its MAC, whether the responder runs on it
+ /// and what it answers there, and whether it already sits on the bridge the configs name.
+ #[derive(Deserialize)]
+ pub struct DhcpIface {
+ name: String,
+ #[serde(default)]
+ vnet: Option<String>,
+ #[serde(default)]
+ mac: Option<MacAddress>,
+ serve: bool,
+ #[serde(default)]
+ record: Option<DhcpRecord>,
+ #[serde(default)]
+ on_config_bridge: bool,
+ }
+
+ impl From<DhcpRecord> 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,
+ }
+ }
+ }
+
+ impl From<DhcpIface> for Iface {
+ fn from(i: DhcpIface) -> Self {
+ Iface {
+ name: i.name,
+ vnet: i.vnet,
+ mac: i.mac.map(|mac| mac.octets()),
+ serve: i.serve,
+ record: i.record.map(Record::from),
+ on_config_bridge: i.on_config_bridge,
+ }
+ }
+ }
+
+ pub(super) fn to_ifaces(ifaces: Vec<DhcpIface>) -> Vec<Iface> {
+ ifaces.into_iter().map(Iface::from).collect()
+ }
+
+ /// A tap unplug. The interface is gone or going and takes its record with it.
+ #[export]
+ pub fn detach(iface: &str) -> Result<(), Error> {
+ DhcpSubsystem::new().detach(iface, Unplugged::Gone)
+ }
+}
+
+#[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, Unplugged};
+ use proxmox_network_types::mac_address::MacAddress;
+
+ 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> : &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<Value, Error> {
+ 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 NIC's MAC and 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,
+ mac: Option<MacAddress>,
+ record: Option<DhcpRecord>,
+ ) -> Result<bool, Error> {
+ let iface = Iface {
+ name: iface,
+ vnet: Some(vnet),
+ mac: mac.map(|mac| mac.octets()),
+ serve: true,
+ record: record.map(Record::from),
+ on_config_bridge: false,
+ };
+ Ok(this.lock.attach(&iface)? == Outcome::Done)
+ }
+
+ /// Method: a plug onto a bridge the responder does not serve, the vnet it sits on now, none
+ /// for a plain bridge. The interface keeps a marker naming that place.
+ #[export]
+ pub fn detach(
+ #[try_from_ref] this: &PerlSharedLock,
+ iface: String,
+ vnet: Option<String>,
+ ) -> Result<(), Error> {
+ this.lock.detach(&iface, Unplugged::MovedTo(vnet))
+ }
+
+ /// 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<DhcpIface>,
+ ) -> Result<bool, Error> {
+ 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 std::sync::Mutex;
+
+ 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. A full pass and a clear take it mutably, and Perl hands out
+ /// shared references only.
+ pub struct PerlExclusiveLock {
+ lock: Mutex<ExclusiveLock>,
+ }
+
+ perlmod::declare_magic!(Box<PerlExclusiveLock> : &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<Value, Error> {
+ let lock = Mutex::new(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<DhcpIface>,
+ ) -> Result<(), Error> {
+ this.lock.lock().unwrap().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<DhcpIface>,
+ ) -> Result<bool, Error> {
+ Ok(this.lock.lock().unwrap().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.lock().unwrap().clear()
+ }
+}
diff --git a/pve-rs/src/bindings/sdn/mod.rs b/pve-rs/src/bindings/sdn/mod.rs
index dcae046a7e46..4b99b8fa10e5 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;
--
2.47.3
next prev parent reply other threads:[~2026-11-05 10:04 UTC|newest]
Thread overview: 18+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-11-05 10:02 [PATCH container/docs/manager/network/proxmox{,-perl-rs,-ve-rs}/qemu-server v6 00/17] sdn: implement DHCP for all zones using eBPF Hannes Laimer
2026-11-05 10:03 ` [PATCH proxmox v6 01/17] network-types: mac address: add an accessor for the bytes Hannes Laimer
2026-11-05 10:03 ` [PATCH proxmox v6 02/17] ebpf: dhcp: add the per-tap DHCPv4 responder program Hannes Laimer
2026-11-05 10:03 ` [PATCH proxmox v6 03/17] ebpf: dhcp: add the subsystem that loads the responder and keeps its records Hannes Laimer
2026-11-05 10:03 ` Hannes Laimer [this message]
2026-11-05 10:03 ` [PATCH pve-network v6 05/17] sdn: zones: take the VXLAN and EVPN MTU from the underlay interface Hannes Laimer
2026-11-05 10:03 ` [PATCH pve-network v6 06/17] sdn: dhcp: make the lease time configurable per subnet Hannes Laimer
2026-11-05 10:03 ` [PATCH pve-network v6 07/17] sdn: dhcp: require a backend only if a zone on this node uses it Hannes Laimer
2026-11-05 10:03 ` [PATCH pve-network v6 08/17] sdn: dhcp: add an eBPF backend answering on each guest interface Hannes Laimer
2026-11-05 10:03 ` [PATCH pve-network v6 09/17] sdn: zones: report interface plugs and unplugs to the DHCP backends Hannes Laimer
2026-11-05 10:03 ` [PATCH pve-network v6 10/17] sdn: zones: allow the ebpf DHCP backend on every zone type Hannes Laimer
2026-11-05 10:03 ` [PATCH qemu-server v6 11/17] network: sdn: keep the DHCP backends current on guest NICs Hannes Laimer
2026-11-05 10:03 ` [PATCH pve-container v6 12/17] net: report NIC plugs and removals to the SDN DHCP backends Hannes Laimer
2026-11-05 10:03 ` [PATCH pve-manager v6 13/17] ui: sdn: add zone DHCP backend selector and subnet DHCP options Hannes Laimer
2026-11-05 10:03 ` [PATCH pve-manager v6 14/17] sdn: bring up the DHCP backends at boot before the guests start Hannes Laimer
2026-11-05 10:03 ` [PATCH proxmox-ve-rs v6 15/17] ve-config: sdn: add the ebpf dhcp backend Hannes Laimer
2026-11-05 10:03 ` [PATCH pve-docs v6 16/17] sdn: zones: document the MTU taken from the underlay interface Hannes Laimer
2026-11-05 10:03 ` [PATCH pve-docs v6 17/17] sdn: dhcp: add eBPF backend section 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=20261105100316.449163-5-h.laimer@proxmox.com \
--to=h.laimer@proxmox.com \
--cc=pve-devel@lists.proxmox.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox