* [PATCH proxmox-ebpf v2 0/3] add proxmox-ebpf library
@ 2026-09-04 9:04 Hannes Laimer
2026-09-04 9:04 ` [PATCH proxmox-ebpf v2 1/3] add the shared tc subsystem code Hannes Laimer
` (2 more replies)
0 siblings, 3 replies; 4+ messages in thread
From: Hannes Laimer @ 2026-09-04 9:04 UTC (permalink / raw)
To: pve-devel
proxmox-ebpf holds the eBPF programs and the code to load and drive
them, grouped into subsystems. It is a library only, whoever needs a
subsystem pulls in just that one through a cargo feature and calls it
from their side. This series is the shared part, the actual
subsystems come as their own series on top.
The core is the shared subsystem code. It takes care of the whole
lifecycle, from loading and attaching per interface to pinning in
bpffs and tearing down again, so nothing has to keep running and each
call picks up where the last one left off. Updates are handled too,
the compiled object is hashed at build time and that hash plus a
schema version are recorded per subsystem, so a call can tell what
changed. A changed program is swapped onto the existing links without
interrupting traffic, a changed map schema gets a teardown and
rebuild. The BPF C code is also built natively against a small shim,
so the parsing and packet building logic can be tested without a
kernel. Packaging is debcargo like the other rust crates.
The series applies on top of the scaffolding commit of the repo, which
is not on the list since it carries the vendored vmlinux.h at ~167k
lines. It is on my staff repo along with these three commits.
v2:
- attach through tcx instead of a clsact qdisc, that qdisc shared its
handle with the one the rate limiter installs
- fix races between concurrent callers in verify, attach and clear
- the pinned state carries schema and fingerprint in its paths, any
other schema pinned is rebuilt, a newer one is refused
- load every program before pinning any and move existing links onto
the new programs after every load
- the apply lock is a typed guard, loading and the full pass exist
only on the exclusive one
proxmox-ebpf:
Hannes Laimer (3):
add the shared tc subsystem code
tests: add a native harness for the BPF C programs
debian: package the crate as a rust library
.gitignore | 1 +
Cargo.toml | 7 +
Makefile | 48 ++++
build.rs | 67 +++++
debian/changelog | 5 +
debian/control | 50 ++++
debian/copyright | 18 ++
debian/debcargo.toml | 13 +
debian/source/format | 1 +
src/bpf-shim/bpf/bpf_endian.h | 12 +
src/bpf-shim/bpf/bpf_helpers.h | 32 +++
src/bpf-shim/bpf_debug.h | 10 +
src/bpf-shim/vmlinux.h | 70 +++++
src/lib.rs | 3 +
src/subsystem.rs | 476 +++++++++++++++++++++++++++++++++
src/tc.rs | 187 +++++++++++++
tests/common/mod.rs | 191 +++++++++++++
17 files changed, 1191 insertions(+)
create mode 100644 Makefile
create mode 100644 debian/changelog
create mode 100644 debian/control
create mode 100644 debian/copyright
create mode 100644 debian/debcargo.toml
create mode 100644 debian/source/format
create mode 100644 src/bpf-shim/bpf/bpf_endian.h
create mode 100644 src/bpf-shim/bpf/bpf_helpers.h
create mode 100644 src/bpf-shim/bpf_debug.h
create mode 100644 src/bpf-shim/vmlinux.h
create mode 100644 src/subsystem.rs
create mode 100644 src/tc.rs
create mode 100644 tests/common/mod.rs
Summary over all repositories:
17 files changed, 1191 insertions(+), 0 deletions(-)
--
Generated by murpp 0.12.0
^ permalink raw reply [flat|nested] 4+ messages in thread
* [PATCH proxmox-ebpf v2 1/3] add the shared tc subsystem code
2026-09-04 9:04 [PATCH proxmox-ebpf v2 0/3] add proxmox-ebpf library Hannes Laimer
@ 2026-09-04 9:04 ` Hannes Laimer
2026-09-04 9:04 ` [PATCH proxmox-ebpf v2 2/3] tests: add a native harness for the BPF C programs Hannes Laimer
2026-09-04 9:04 ` [PATCH proxmox-ebpf v2 3/3] debian: package the crate as a rust library Hannes Laimer
2 siblings, 0 replies; 4+ messages in thread
From: Hannes Laimer @ 2026-09-04 9:04 UTC (permalink / raw)
To: pve-devel
A subsystem owns tc classifier programs and their maps, attached per
interface and pinned in bpffs together with an object fingerprint and
a schema version, so state survives between the one-shot calls from
the consumer driving it and re-running never interrupts traffic.
Apply paths coordinate through a shared flock, loading takes it
exclusively.
Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
src/lib.rs | 3 +
src/subsystem.rs | 476 +++++++++++++++++++++++++++++++++++++++++++++++
src/tc.rs | 187 +++++++++++++++++++
3 files changed, 666 insertions(+)
create mode 100644 src/subsystem.rs
create mode 100644 src/tc.rs
diff --git a/src/lib.rs b/src/lib.rs
index 244f44c..572d861 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,2 +1,5 @@
//! 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.
+
+pub mod subsystem;
+pub mod tc;
diff --git a/src/subsystem.rs b/src/subsystem.rs
new file mode 100644
index 0000000..853e17b
--- /dev/null
+++ b/src/subsystem.rs
@@ -0,0 +1,476 @@
+//! The shared subsystem code. A subsystem's tc programs, one per direction it declares, with
+//! their maps and links, pinned under `/sys/fs/bpf/proxmox-ebpf/<name>/<schema>/`, the programs
+//! by the fingerprint of their object, so the pinned state itself says which build it belongs to.
+//! The loaded BPF stays in the kernel between invocations, so [`ApplyLock::ensure_loaded`] loads
+//! and verifies only on the first run and on a version change. Everything else attaches links and
+//! syncs maps against what is already there. Each subsystem owns one [`TcPrograms`].
+
+use std::{collections::HashSet, fs::File, io::ErrorKind, marker::PhantomData, path::PathBuf};
+
+use anyhow::Context;
+use aya::{EbpfLoader, programs::SchedClassifier};
+use nix::fcntl::{Flock, FlockArg};
+
+use crate::tc::{self, Direction};
+
+// throwaway pin roots for the verify-at-load step, one per subsystem
+pub(crate) const VERIFY_ROOT: &str = "/sys/fs/bpf/proxmox-ebpf-test";
+
+const PIN_ROOT: &str = "/sys/fs/bpf/proxmox-ebpf";
+const RUN_ROOT: &str = "/run/proxmox-ebpf";
+
+/// The pinned tc programs for one subsystem.
+///
+/// Holds only the immutable description. The loaded BPF lives in the kernel, pinned, and is
+/// reached back through those pins, so normal operation runs no verifier.
+pub struct TcPrograms {
+ name: &'static str,
+ obj: &'static [u8],
+ fingerprint: u64,
+ prog_name: fn(Direction) -> &'static str,
+ directions: &'static [Direction],
+ schema_version: u32,
+}
+
+impl TcPrograms {
+ pub fn new(
+ name: &'static str,
+ obj: &'static [u8],
+ fingerprint: u64,
+ prog_name: fn(Direction) -> &'static str,
+ directions: &'static [Direction],
+ schema_version: u32,
+ ) -> Self {
+ Self {
+ name,
+ obj,
+ fingerprint,
+ prog_name,
+ directions,
+ schema_version,
+ }
+ }
+
+ fn pin_root(&self) -> PathBuf {
+ PathBuf::from(PIN_ROOT).join(self.name)
+ }
+ fn schema_root(&self) -> PathBuf {
+ self.pin_root().join(self.schema_version.to_string())
+ }
+ fn links_dir(&self) -> PathBuf {
+ self.schema_root().join("links")
+ }
+ fn link_pin_path(&self, ifindex: u32, dir: Direction) -> PathBuf {
+ self.links_dir().join(tc::pin_filename(ifindex, dir))
+ }
+ fn progs_dir(&self) -> PathBuf {
+ self.schema_root().join("prog")
+ }
+ fn fingerprint_dir_name(&self) -> String {
+ format!("{:016x}", self.fingerprint)
+ }
+ fn prog_dir(&self) -> PathBuf {
+ self.progs_dir().join(self.fingerprint_dir_name())
+ }
+ fn prog_pin_path(&self, dir: Direction) -> PathBuf {
+ self.prog_dir().join(dir.as_str())
+ }
+
+ fn hash_map<K: aya::Pod, V: aya::Pod>(
+ &self,
+ name: &str,
+ ) -> anyhow::Result<aya::maps::HashMap<aya::maps::MapData, K, V>> {
+ let map = aya::maps::MapData::from_pin(self.schema_root().join(name))
+ .with_context(|| format!("{}: open pinned map {name}", self.name))?;
+ Ok(aya::maps::HashMap::try_from(aya::maps::Map::HashMap(map))?)
+ }
+
+ /// Open one direction's pinned program. A plain `BPF_OBJ_GET`, no verifier, since the program
+ /// was checked once when [`ensure_loaded`](ApplyLock::ensure_loaded) installed it.
+ fn program(&self, dir: Direction) -> anyhow::Result<SchedClassifier> {
+ SchedClassifier::from_pin(self.prog_pin_path(dir))
+ .with_context(|| format!("{}: open pinned program {}", self.name, dir.as_str()))
+ }
+
+ /// FNV-1a over the embedded object. A `const fn`, so each subsystem folds it into a `const` at
+ /// compile time and the per-invocation path never rehashes a constant.
+ pub const fn obj_fingerprint(obj: &[u8]) -> u64 {
+ let mut hash = 0xcbf29ce484222325u64;
+ let mut i = 0;
+ while i < obj.len() {
+ hash ^= obj[i] as u64;
+ hash = hash.wrapping_mul(0x100000001b3);
+ i += 1;
+ }
+ hash
+ }
+
+ /// The per-subsystem apply lock under `/run`, one file taken in two modes. A full apply (and
+ /// any install/teardown) takes it [exclusively](Self::lock_exclusive) so its enumerate, detach
+ /// and attach run as one unit. An additive single-interface apply takes it
+ /// [shared](Self::lock_shared) so guest plugs run concurrently and only block while a full
+ /// apply holds it. The kernel drops the lock if the process dies. Everything that touches the
+ /// pinned state is a method of the returned guard, so the mode a step needs is checked at
+ /// compile time.
+ fn lock<M>(&self, arg: FlockArg) -> anyhow::Result<ApplyLock<'_, M>> {
+ let dir = PathBuf::from(RUN_ROOT).join(self.name);
+ std::fs::create_dir_all(&dir)?;
+ let file = File::create(dir.join("lock"))?;
+ let lock = Flock::lock(file, arg).map_err(|(_, e)| anyhow::Error::new(e))?;
+ Ok(ApplyLock {
+ programs: self,
+ _lock: lock,
+ _mode: PhantomData,
+ })
+ }
+
+ /// Take the apply lock in shared mode, for an additive single-interface apply.
+ pub fn lock_shared(&self) -> anyhow::Result<ApplyLock<'_, Shared>> {
+ self.lock(FlockArg::LockShared)
+ }
+
+ /// Take the apply lock exclusively, for a full apply or an install/teardown.
+ pub fn lock_exclusive(&self) -> anyhow::Result<ApplyLock<'_, Exclusive>> {
+ self.lock(FlockArg::LockExclusive)
+ }
+
+ fn ensure_loaded(&self) -> anyhow::Result<bool> {
+ if self.is_current() {
+ return Ok(false);
+ }
+ let empty = !self.any_programs_pinned();
+
+ // any other schema pinned here is old state whose map layout this build cannot bind to,
+ // so it goes once the new code verified. A newer one means a newer build already runs
+ // here and this process is the stale one, it must not tear that down
+ let others = self.other_schemas()?;
+ if let Some(newer) = others
+ .iter()
+ .flatten()
+ .copied()
+ .find(|&s| s > self.schema_version)
+ {
+ anyhow::bail!(
+ "{}: pinned state is of schema {newer}, newer than this build's {}, clear it to \
+ downgrade",
+ self.name,
+ self.schema_version
+ );
+ }
+ let schema_changed = !others.is_empty();
+ if schema_changed {
+ log::warn!("{}: pinned state of another schema, rebuilding", self.name);
+ // verify the new code loads against throw-away state before tearing the old down, so a
+ // verifier rejection can't leave us with the old state wiped and nothing to replace it
+ self.verify().with_context(|| {
+ format!(
+ "{}: new BPF code does not load against fresh state",
+ self.name
+ )
+ })?;
+ self.tear_down().context("tear_down")?;
+ }
+
+ let reclaimed = self.install().context("install")?;
+ Ok(empty || schema_changed || reclaimed > 0)
+ }
+
+ /// True when every direction's program is pinned under this build's schema and fingerprint. A
+ /// lock-free hint for picking the lock mode. A stale answer is harmless, the exclusive path
+ /// checks again and the shared guard cannot install, its attach fails on the missing pins.
+ pub fn is_current(&self) -> bool {
+ self.directions
+ .iter()
+ .all(|&dir| self.prog_pin_path(dir).exists())
+ }
+
+ fn any_programs_pinned(&self) -> bool {
+ std::fs::read_dir(self.progs_dir())
+ .map(|mut entries| entries.next().is_some())
+ .unwrap_or(false)
+ }
+
+ // the schema versions pinned here other than this build's, a directory that is no version at
+ // all is foreign state and counts as unknown
+ fn other_schemas(&self) -> anyhow::Result<Vec<Option<u32>>> {
+ let entries = match std::fs::read_dir(self.pin_root()) {
+ Ok(entries) => entries,
+ Err(e) if e.kind() == ErrorKind::NotFound => return Ok(Vec::new()),
+ Err(e) => return Err(e.into()),
+ };
+ let mut others = Vec::new();
+ for entry in entries {
+ let name = entry?.file_name();
+ let schema = name.to_str().and_then(|s| s.parse::<u32>().ok());
+ if schema != Some(self.schema_version) {
+ others.push(schema);
+ }
+ }
+ Ok(others)
+ }
+
+ fn verify(&self) -> anyhow::Result<()> {
+ let names: Vec<&str> = self
+ .directions
+ .iter()
+ .map(|&d| (self.prog_name)(d))
+ .collect();
+ tc::verify(self.name, &[self.obj], &names)
+ }
+
+ fn tear_down(&self) -> anyhow::Result<()> {
+ match std::fs::remove_dir_all(self.pin_root()) {
+ Ok(()) => Ok(()),
+ Err(e) if e.kind() == ErrorKind::NotFound => Ok(()),
+ Err(e) => Err(e.into()),
+ }
+ }
+
+ /// Load and verify the object, move the pinned links onto it, then pin its programs and drop
+ /// those of other builds. The verifier runs here and in the throwaway [`verify`](Self::verify),
+ /// nowhere else. The programs are pinned last since their pins are what marks this build as
+ /// current, so a failure before that point leaves the previous build in charge and the next
+ /// run retries. A link the update cannot reach has lost its netdev, so it is unpinned and the
+ /// next reconcile attaches the interface fresh if it is still wanted. Returns how many links
+ /// went that way. The `bpf` handle is dropped at the end, the pinned programs and maps stay
+ /// resident in the kernel.
+ fn install(&self) -> anyhow::Result<usize> {
+ std::fs::create_dir_all(self.links_dir())?;
+ let mut bpf = EbpfLoader::new()
+ .map_pin_path(self.schema_root())
+ .load(self.obj)?;
+ // load everything before touching any link, so a verifier rejection of one direction
+ // leaves the old state whole
+ for &dir in self.directions {
+ let name = (self.prog_name)(dir);
+ let prog: &mut SchedClassifier = bpf
+ .program_mut(name)
+ .with_context(|| format!("{}: program {name} not found in object", self.name))?
+ .try_into()?;
+ prog.load()?;
+ }
+
+ let live = self.live_links()?;
+ let mut reclaimed = 0usize;
+ for &dir in self.directions {
+ let prog = self.loaded(&mut bpf, dir)?;
+ for &(ifindex, _) in live.iter().filter(|&&(_, d)| d == dir) {
+ let path = self.link_pin_path(ifindex, dir);
+ if let Err(e) = tc::swap_pinned_link(prog, &path) {
+ log::warn!(
+ "{}: {ifindex}-{} swap failed ({e:#}), reclaiming",
+ self.name,
+ dir.as_str()
+ );
+ self.reclaim_link(ifindex, dir);
+ reclaimed += 1;
+ }
+ }
+ }
+
+ // a run that died after pinning some of the directions left a partial set behind
+ match std::fs::remove_dir_all(self.prog_dir()) {
+ Ok(()) => {}
+ Err(e) if e.kind() == ErrorKind::NotFound => {}
+ Err(e) => return Err(e).context("remove partial program pins"),
+ }
+ std::fs::create_dir_all(self.prog_dir())?;
+ for &dir in self.directions {
+ self.loaded(&mut bpf, dir)?.pin(self.prog_pin_path(dir))?;
+ }
+
+ // the links are on this build now, the other builds' programs go with their pins
+ let mine = self.fingerprint_dir_name();
+ for entry in std::fs::read_dir(self.progs_dir())? {
+ let entry = entry?;
+ if entry.file_name().to_string_lossy() != mine.as_str()
+ && let Err(e) = std::fs::remove_dir_all(entry.path())
+ {
+ log::warn!(
+ "{}: remove stale program pins {}: {e:#}",
+ self.name,
+ entry.path().display()
+ );
+ }
+ }
+ Ok(reclaimed)
+ }
+
+ // one direction's program out of the loaded object, resolved once by the load loop
+ fn loaded<'b>(
+ &self,
+ bpf: &'b mut aya::Ebpf,
+ dir: Direction,
+ ) -> anyhow::Result<&'b mut SchedClassifier> {
+ let prog = bpf
+ .program_mut((self.prog_name)(dir))
+ .expect("every program was resolved by the load loop")
+ .try_into()?;
+ Ok(prog)
+ }
+
+ fn live_links(&self) -> anyhow::Result<Vec<(u32, Direction)>> {
+ tc::read_pinned_links(&self.links_dir())
+ }
+
+ /// Unpin a defunct link. The pin file goes even when the kernel object behind it cannot be
+ /// opened any more.
+ fn reclaim_link(&self, ifindex: u32, dir: Direction) {
+ let path = self.link_pin_path(ifindex, dir);
+ if let Err(e) = tc::detach_pinned_link(&path) {
+ log::warn!(
+ "{}: reclaim {ifindex}-{}: unpin stale link: {e:#}",
+ self.name,
+ dir.as_str()
+ );
+ let _ = std::fs::remove_file(&path);
+ }
+ }
+
+ fn reconcile(&self, desired: &HashSet<u32>) -> anyhow::Result<()> {
+ let live: HashSet<(u32, Direction)> = self.live_links()?.into_iter().collect();
+
+ for &(ifidx, dir) in &live {
+ if desired.contains(&ifidx) {
+ continue;
+ }
+ log::debug!("{}: detach {ifidx}-{}", self.name, dir.as_str());
+ if let Err(e) = tc::detach_pinned_link(&self.link_pin_path(ifidx, dir)) {
+ log::error!("{}: detach {ifidx}-{}: {e:#}", self.name, dir.as_str());
+ } else {
+ log::info!("{}: detached {ifidx}-{}", self.name, dir.as_str());
+ }
+ }
+
+ let mut failed = 0usize;
+ for &dir in self.directions {
+ let mut prog = self.program(dir)?;
+ for &ifidx in desired {
+ if live.contains(&(ifidx, dir)) {
+ continue;
+ }
+ let path = self.link_pin_path(ifidx, dir);
+ if let Err(e) = tc::attach_and_pin(&mut prog, ifidx, dir, &path) {
+ log::error!("{}: attach {ifidx}-{}: {e:#}", self.name, dir.as_str());
+ failed += 1;
+ }
+ }
+ }
+ if failed > 0 {
+ anyhow::bail!("{}: {failed} interface(s) failed to attach", self.name);
+ }
+ Ok(())
+ }
+
+ fn attach_iface(&self, ifindex: u32) -> anyhow::Result<()> {
+ for &dir in self.directions {
+ // opened ahead of the pin check so a missing program fails here and never counts as a
+ // dead link
+ let mut prog = self.program(dir)?;
+ let path = self.link_pin_path(ifindex, dir);
+ if path.exists() {
+ match tc::swap_pinned_link(&mut prog, &path) {
+ Ok(()) => continue,
+ Err(e) => {
+ log::warn!(
+ "{}: {ifindex}-{} swap failed ({e:#}), rebuilding",
+ self.name,
+ dir.as_str()
+ );
+ self.reclaim_link(ifindex, dir);
+ }
+ }
+ }
+ match tc::attach_and_pin(&mut prog, ifindex, dir, &path) {
+ Ok(()) => {}
+ Err(e) if e.downcast_ref::<tc::PinExists>().is_some() => {
+ log::debug!(
+ "{}: {ifindex}-{} attached concurrently",
+ self.name,
+ dir.as_str()
+ );
+ }
+ Err(e) => {
+ return Err(e).with_context(|| {
+ format!("{}: attach {ifindex}-{}", self.name, dir.as_str())
+ });
+ }
+ }
+ }
+ Ok(())
+ }
+
+ /// Drop the pinned state of this subsystem, links included, under the exclusive lock, which
+ /// stays in place for whoever waits on it. For package removal, leaving links attached would
+ /// keep the programs running with nothing left to update them.
+ pub fn clear(&self) -> anyhow::Result<()> {
+ let _lock = self.lock_exclusive()?;
+ // the lock file under /run stays, unlinking it under a waiter would let that waiter run
+ // unserialized
+ self.tear_down()
+ }
+}
+
+/// Marker for a guard holding the apply lock shared.
+pub struct Shared;
+
+/// Marker for a guard holding the apply lock exclusively.
+pub struct Exclusive;
+
+/// The apply lock of one subsystem, held for as long as the guard lives. The steps that rewrite
+/// pinned state, loading and the full pass, exist only on the [`Exclusive`] guard, so nothing can
+/// run them next to shared holders. There is no upgrade, a caller that took the shared lock and
+/// finds an install needed drops it and takes the exclusive one.
+pub struct ApplyLock<'a, M> {
+ programs: &'a TcPrograms,
+ _lock: Flock<File>,
+ _mode: PhantomData<M>,
+}
+
+impl<M> ApplyLock<'_, M> {
+ /// Open a pinned BPF hash map by name, for the owning subsystem to sync. Valid once
+ /// [`ensure_loaded`](ApplyLock::ensure_loaded) has run, which is every caller's first step.
+ pub fn hash_map<K: aya::Pod, V: aya::Pod>(
+ &self,
+ name: &str,
+ ) -> anyhow::Result<aya::maps::HashMap<aya::maps::MapData, K, V>> {
+ self.programs.hash_map(name)
+ }
+
+ /// Attach the programs to a single interface, additively, without touching others.
+ ///
+ /// If a pin already exists, swap the program in place with no traffic gap. The swap only
+ /// succeeds while the link is still on a live netdev, a defunct pin is reclaimed and the
+ /// interface attached fresh. Two concurrent attaches of the same interface run under the
+ /// shared lock, the one that loses the pin race finds the other's link in place and is done.
+ /// A failed attach propagates so the caller can decide what an interface without the program
+ /// means for it.
+ pub fn attach_iface(&self, ifindex: u32) -> anyhow::Result<()> {
+ self.programs.attach_iface(ifindex)
+ }
+}
+
+impl ApplyLock<'_, Exclusive> {
+ /// Make sure the programs are loaded and pinned. The load runs only when there is no current
+ /// pin, when the schema version changed (a rebuild), or when the object changed (a refresh).
+ /// Otherwise the programs and maps pinned by an earlier run are reused untouched. Returns
+ /// whether the pinned state was found incomplete, so the caller knows its links and maps have
+ /// to be re-established. A clean refresh keeps both and reports false. A subsystem without
+ /// maps never bumps its schema and so never tears down.
+ pub fn ensure_loaded(&self) -> anyhow::Result<bool> {
+ self.programs.ensure_loaded()
+ }
+
+ /// Make the attached set match `desired`. Detach interfaces no longer wanted, attach the ones
+ /// missing. Refreshing existing links onto new code is done by
+ /// [`ensure_loaded`](Self::ensure_loaded). The live set it samples cannot change under it,
+ /// every other writer waits on the exclusive lock.
+ ///
+ /// Returns an error if any interface failed to attach (after attempting all of them), an
+ /// interface left without its program is the subsystem not doing its job there, so that
+ /// surfaces as a failed apply. A failed detach only leaves a program running longer than
+ /// wanted and is logged.
+ pub fn reconcile(&self, desired: &HashSet<u32>) -> anyhow::Result<()> {
+ self.programs.reconcile(desired)
+ }
+}
diff --git a/src/tc.rs b/src/tc.rs
new file mode 100644
index 0000000..a01dcaa
--- /dev/null
+++ b/src/tc.rs
@@ -0,0 +1,187 @@
+//! TC link plumbing shared by all subsystems. A `Direction` enum, attach/swap/detach free
+//! functions over tcx links (kernel 6.6 and newer), and a uniform pin-filename layout
+//! `{ifindex}-{direction}`.
+
+use std::{
+ io::ErrorKind,
+ path::{Path, PathBuf},
+ str::FromStr,
+};
+
+use anyhow::Context;
+use aya::{
+ EbpfLoader,
+ pin::PinError,
+ programs::{
+ SchedClassifier, TcAttachType,
+ links::{FdLink, LinkOrder, PinnedLink},
+ tc::TcAttachOptions,
+ },
+};
+
+use crate::subsystem::VERIFY_ROOT;
+
+/// An attach that found its pin already present, so a caller racing another attach of the same
+/// interface can tell that outcome from a real failure.
+#[derive(Debug)]
+pub struct PinExists;
+
+impl std::fmt::Display for PinExists {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.write_str("pin path already exists")
+ }
+}
+
+impl std::error::Error for PinExists {}
+
+#[derive(Copy, Clone, Hash, PartialEq, Eq, Debug)]
+pub enum Direction {
+ Ingress,
+ Egress,
+}
+
+pub const DIRECTIONS: [Direction; 2] = [Direction::Ingress, Direction::Egress];
+
+impl Direction {
+ pub fn as_str(self) -> &'static str {
+ match self {
+ Self::Ingress => "ingress",
+ Self::Egress => "egress",
+ }
+ }
+ pub fn aya_type(self) -> TcAttachType {
+ match self {
+ Self::Ingress => TcAttachType::Ingress,
+ Self::Egress => TcAttachType::Egress,
+ }
+ }
+}
+
+impl FromStr for Direction {
+ type Err = ();
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ match s {
+ "ingress" => Ok(Self::Ingress),
+ "egress" => Ok(Self::Egress),
+ _ => Err(()),
+ }
+ }
+}
+
+/// RAII handle for a subsystem's verify root, so the throwaway state is gone even when the verify
+/// body panics or returns Err.
+struct VerifyRoot(PathBuf);
+
+impl VerifyRoot {
+ fn new(subsystem: &str) -> anyhow::Result<Self> {
+ let path = Path::new(VERIFY_ROOT).join(subsystem);
+ let _ = std::fs::remove_dir_all(&path);
+ std::fs::create_dir_all(&path)
+ .with_context(|| format!("create verify root {}", path.display()))?;
+ Ok(Self(path))
+ }
+ fn path(&self) -> &Path {
+ &self.0
+ }
+}
+
+impl Drop for VerifyRoot {
+ fn drop(&mut self) {
+ if let Err(e) = std::fs::remove_dir_all(&self.0) {
+ log::warn!("failed to clean up verify root {}: {e:#}", self.0.display());
+ }
+ }
+}
+
+/// Loads every named program in each object against a throwaway pin root of the subsystem,
+/// catching verifier regressions before any real state is touched.
+pub fn verify(subsystem: &str, objects: &[&[u8]], program_names: &[&str]) -> anyhow::Result<()> {
+ let root = VerifyRoot::new(subsystem)?;
+ for &obj in objects {
+ let mut bpf = EbpfLoader::new().map_pin_path(root.path()).load(obj)?;
+ for &name in program_names {
+ let p: &mut SchedClassifier = bpf
+ .program_mut(name)
+ .with_context(|| format!("program {name} not found in object"))?
+ .try_into()?;
+ p.load()?;
+ }
+ }
+ Ok(())
+}
+
+pub fn pin_filename(ifindex: u32, dir: Direction) -> String {
+ format!("{ifindex}-{}", dir.as_str())
+}
+
+/// Attach `prog` in `dir` as a tcx link and pin it. tcx (kernel 6.6 and newer) hangs the
+/// program on the netdev hook itself, no qdisc is involved, so nothing here competes with the
+/// `ingress` qdisc the rate limiter installs on the same interface. Programs of several
+/// subsystems stack in attach order, each new link goes last.
+pub fn attach_and_pin(
+ prog: &mut SchedClassifier,
+ ifindex: u32,
+ dir: Direction,
+ pin_path: &Path,
+) -> anyhow::Result<()> {
+ let name = nix::net::if_::if_indextoname(ifindex)?;
+ let name = name.to_str()?;
+ let link_id = prog.attach_with_options(
+ name,
+ dir.aya_type(),
+ TcAttachOptions::TcxOrder(LinkOrder::last()),
+ )?;
+ let link = prog.take_link(link_id)?;
+ let fd_link: FdLink = link.try_into()?;
+ // a lost race against another attach of the same interface shows up here, the unpinned link
+ // detaches again when its fd drops
+ match fd_link.pin(pin_path) {
+ Ok(_) => Ok(()),
+ Err(PinError::SyscallError(e)) if e.io_error.kind() == ErrorKind::AlreadyExists => {
+ Err(PinExists.into())
+ }
+ Err(e) => Err(e.into()),
+ }
+}
+
+/// Rebind a pinned link to `prog` via LINK_UPDATE. Atomic, traffic sees no detach/reattach gap.
+pub fn swap_pinned_link(prog: &mut SchedClassifier, pin_path: &Path) -> anyhow::Result<()> {
+ let pinned = PinnedLink::from_pin(pin_path)?;
+ let fd_link: FdLink = pinned.into();
+ let link = fd_link.try_into()?;
+ let new_id = prog.attach_to_link(link)?;
+ // take the handle out of aya's internal tracking, we have the pin
+ let _ = prog.take_link(new_id)?;
+ Ok(())
+}
+
+pub fn detach_pinned_link(pin_path: &Path) -> anyhow::Result<()> {
+ let pinned = PinnedLink::from_pin(pin_path)?;
+ let _fd_link = pinned.unpin()?;
+ Ok(())
+}
+
+/// Read and parse every pin file in `links_dir` as `{ifindex}-{direction}`. Unrecognized names are
+/// skipped with a warning.
+pub fn read_pinned_links(links_dir: &Path) -> anyhow::Result<Vec<(u32, Direction)>> {
+ let mut out = Vec::new();
+ let dir = match std::fs::read_dir(links_dir) {
+ Ok(d) => d,
+ Err(e) if e.kind() == ErrorKind::NotFound => return Ok(out),
+ Err(e) => return Err(e.into()),
+ };
+ for entry in dir {
+ let entry = entry?;
+ let name = entry.file_name();
+ let name = name.to_string_lossy();
+ let Some((ifidx_str, dir_str)) = name.split_once('-') else {
+ log::warn!("unrecognized pin file in links dir: {name}");
+ continue;
+ };
+ match (ifidx_str.parse::<u32>(), dir_str.parse::<Direction>()) {
+ (Ok(ifidx), Ok(d)) => out.push((ifidx, d)),
+ _ => log::warn!("unrecognized pin file in links dir: {name}"),
+ }
+ }
+ Ok(out)
+}
--
2.47.3
^ permalink raw reply related [flat|nested] 4+ messages in thread
* [PATCH proxmox-ebpf v2 2/3] tests: add a native harness for the BPF C programs
2026-09-04 9:04 [PATCH proxmox-ebpf v2 0/3] add proxmox-ebpf library Hannes Laimer
2026-09-04 9:04 ` [PATCH proxmox-ebpf v2 1/3] add the shared tc subsystem code Hannes Laimer
@ 2026-09-04 9:04 ` Hannes Laimer
2026-09-04 9:04 ` [PATCH proxmox-ebpf v2 3/3] debian: package the crate as a rust library Hannes Laimer
2 siblings, 0 replies; 4+ messages in thread
From: Hannes Laimer @ 2026-09-04 9:04 UTC (permalink / raw)
To: pve-devel
The programs' parse and build logic is plain C whose behavior does
not depend on the compilation target, so build.rs compiles each one
a second time, natively, against shim headers that turn the helpers
into plain extern functions, and archives the results for the test
build. The harness provides those helpers as bounds-checked
functions over an owned buffer plus a per-thread map registry, so a
test hands a program a crafted frame and asserts on the rewritten
bytes.
Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
build.rs | 62 +++++++++++
src/bpf-shim/bpf/bpf_endian.h | 12 +++
src/bpf-shim/bpf/bpf_helpers.h | 32 ++++++
src/bpf-shim/bpf_debug.h | 10 ++
src/bpf-shim/vmlinux.h | 70 ++++++++++++
tests/common/mod.rs | 191 +++++++++++++++++++++++++++++++++
6 files changed, 377 insertions(+)
create mode 100644 src/bpf-shim/bpf/bpf_endian.h
create mode 100644 src/bpf-shim/bpf/bpf_helpers.h
create mode 100644 src/bpf-shim/bpf_debug.h
create mode 100644 src/bpf-shim/vmlinux.h
create mode 100644 tests/common/mod.rs
diff --git a/build.rs b/build.rs
index 270a97f..81c2ce4 100644
--- a/build.rs
+++ b/build.rs
@@ -15,6 +15,9 @@ fn main() {
println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-env-changed=CARGO_FEATURE_BPF_DEBUG");
+ let shim_include = src_dir.join("bpf-shim");
+ let mut native_objs = Vec::new();
+
for sub_entry in std::fs::read_dir(&src_dir).expect("read src/") {
let sub_path = sub_entry.expect("read src/ entry").path();
let bpf_dir = sub_path.join("bpf");
@@ -43,8 +46,67 @@ fn main() {
continue;
}
compile_bpf(&path, &bpf_dir, &global_include, &out_dir, bpf_debug);
+ native_objs.push(compile_native(
+ &path,
+ &bpf_dir,
+ &global_include,
+ &shim_include,
+ &out_dir,
+ ));
}
}
+
+ // archived so a member is only linked when a test references its symbols, the regular
+ // build neither grows nor needs the mocked helpers
+ let archive = out_dir.join("libbpf_native.a");
+ let _ = std::fs::remove_file(&archive);
+ let status = Command::new("ar")
+ .arg("rcs")
+ .arg(&archive)
+ .args(&native_objs)
+ .status()
+ .expect("failed to invoke ar");
+ assert!(
+ status.success(),
+ "ar failed to create {}",
+ archive.display()
+ );
+ println!("cargo:rustc-link-search=native={}", out_dir.display());
+ println!("cargo:rustc-link-lib=static=bpf_native");
+}
+
+// native build against the shim headers, so tests call the program as a plain function
+fn compile_native(
+ src: &Path,
+ local_include: &Path,
+ global_include: &Path,
+ shim_include: &Path,
+ out_dir: &Path,
+) -> PathBuf {
+ let stem = src.file_stem().unwrap().to_str().unwrap();
+ let obj_path = out_dir.join(format!("{stem}.native.o"));
+
+ let status = Command::new("clang")
+ .args(["-O2", "-g", "-Wall", "-fPIC"])
+ // shim first, it shadows vmlinux.h and the bpf headers
+ .arg("-I")
+ .arg(shim_include)
+ .arg("-I")
+ .arg(global_include)
+ .arg("-I")
+ .arg(local_include)
+ .arg("-c")
+ .arg(src)
+ .arg("-o")
+ .arg(&obj_path)
+ .status()
+ .expect("failed to invoke clang, is it installed?");
+ assert!(
+ status.success(),
+ "clang failed to compile {} natively",
+ src.display()
+ );
+ obj_path
}
fn compile_bpf(
diff --git a/src/bpf-shim/bpf/bpf_endian.h b/src/bpf-shim/bpf/bpf_endian.h
new file mode 100644
index 0000000..ebcbceb
--- /dev/null
+++ b/src/bpf-shim/bpf/bpf_endian.h
@@ -0,0 +1,12 @@
+#ifndef PROXMOX_EBPF_SHIM_BPF_ENDIAN_H
+#define PROXMOX_EBPF_SHIM_BPF_ENDIAN_H
+
+// Native stand-in for <bpf/bpf_endian.h>, little-endian hosts only (the
+// shim vmlinux.h enforces that).
+
+#define bpf_htons(x) __builtin_bswap16(x)
+#define bpf_ntohs(x) __builtin_bswap16(x)
+#define bpf_htonl(x) __builtin_bswap32(x)
+#define bpf_ntohl(x) __builtin_bswap32(x)
+
+#endif
diff --git a/src/bpf-shim/bpf/bpf_helpers.h b/src/bpf-shim/bpf/bpf_helpers.h
new file mode 100644
index 0000000..599a031
--- /dev/null
+++ b/src/bpf-shim/bpf/bpf_helpers.h
@@ -0,0 +1,32 @@
+#ifndef PROXMOX_EBPF_SHIM_BPF_HELPERS_H
+#define PROXMOX_EBPF_SHIM_BPF_HELPERS_H
+
+// Native stand-in for <bpf/bpf_helpers.h>. The helpers become plain extern
+// functions the test harness provides, sections reduce to weak linkage so
+// the program objects can be linked into one test binary, and the map
+// definition macros keep their libbpf shapes, which are plain C already.
+
+#define SEC(name) __attribute__((weak))
+#define __always_inline inline __attribute__((always_inline))
+#define __uint(name, val) int(*name)[val]
+#define __type(name, val) typeof(val) *name
+
+#ifndef barrier_var
+#define barrier_var(var) asm volatile("" : "+r"(var))
+#endif
+
+// map-definition constants, the values are irrelevant natively
+enum {
+ BPF_MAP_TYPE_HASH = 1,
+};
+#define BPF_F_NO_PREALLOC 1
+#define LIBBPF_PIN_BY_NAME 1
+
+extern void *bpf_map_lookup_elem(void *map, const void *key);
+extern long bpf_skb_load_bytes(const void *skb, __u32 offset, void *to, __u32 len);
+extern long bpf_skb_store_bytes(void *skb, __u32 offset, const void *from, __u32 len, __u64 flags);
+extern long bpf_skb_pull_data(void *skb, __u32 len);
+extern long bpf_skb_change_tail(void *skb, __u32 new_len, __u64 flags);
+extern long bpf_redirect(__u32 ifindex, __u64 flags);
+
+#endif
diff --git a/src/bpf-shim/bpf_debug.h b/src/bpf-shim/bpf_debug.h
new file mode 100644
index 0000000..a7e5eac
--- /dev/null
+++ b/src/bpf-shim/bpf_debug.h
@@ -0,0 +1,10 @@
+#ifndef PROXMOX_EBPF_SHIM_BPF_DEBUG_H
+#define PROXMOX_EBPF_SHIM_BPF_DEBUG_H
+
+// Native stand-in for bpf_debug.h, debug printing is a no-op in tests.
+
+#define DBG(...) \
+ do { \
+ } while (0)
+
+#endif
diff --git a/src/bpf-shim/vmlinux.h b/src/bpf-shim/vmlinux.h
new file mode 100644
index 0000000..52954f2
--- /dev/null
+++ b/src/bpf-shim/vmlinux.h
@@ -0,0 +1,70 @@
+#ifndef PROXMOX_EBPF_SHIM_VMLINUX_H
+#define PROXMOX_EBPF_SHIM_VMLINUX_H
+
+// Native stand-in for vmlinux.h, only the types the programs touch. The
+// header layouts match the little-endian kernel wire layouts, data and
+// data_end are wide enough to hold real pointers.
+
+#if __BYTE_ORDER__ != __ORDER_LITTLE_ENDIAN__
+#error "the native BPF test build assumes a little-endian host"
+#endif
+
+typedef unsigned char __u8;
+typedef unsigned short __u16;
+typedef unsigned int __u32;
+typedef unsigned long long __u64;
+typedef __u16 __be16;
+typedef __u32 __be32;
+
+// keep in sync with SkBuff in tests/common/mod.rs
+struct __sk_buff {
+ unsigned long data;
+ unsigned long data_end;
+ __u32 len;
+ __u32 ifindex;
+ __u32 mark;
+};
+
+struct ethhdr {
+ __u8 h_dest[6];
+ __u8 h_source[6];
+ __be16 h_proto;
+} __attribute__((packed));
+
+struct iphdr {
+ __u8 ihl : 4;
+ __u8 version : 4;
+ __u8 tos;
+ __be16 tot_len;
+ __be16 id;
+ __be16 frag_off;
+ __u8 ttl;
+ __u8 protocol;
+ __u16 check;
+ __be32 saddr;
+ __be32 daddr;
+};
+
+struct udphdr {
+ __be16 source;
+ __be16 dest;
+ __be16 len;
+ __u16 check;
+};
+
+struct in6_addr {
+ __u8 s6_addr[16];
+};
+
+struct ipv6hdr {
+ __u8 priority : 4;
+ __u8 version : 4;
+ __u8 flow_lbl[3];
+ __be16 payload_len;
+ __u8 nexthdr;
+ __u8 hop_limit;
+ struct in6_addr saddr;
+ struct in6_addr daddr;
+};
+
+#endif
diff --git a/tests/common/mod.rs b/tests/common/mod.rs
new file mode 100644
index 0000000..b6d3df6
--- /dev/null
+++ b/tests/common/mod.rs
@@ -0,0 +1,191 @@
+//! Native harness for the subsystems' BPF C programs. A fake skb over an owned buffer, the
+//! helpers as plain bounds-checked functions over it, and a per-thread map registry. build.rs
+//! compiles every program a second time against the shim headers in src/bpf-shim and archives
+//! them, tests declare the program symbols and call them like plain functions.
+
+// the archive is linked through the lib, a test crate that never uses the lib
+// would drop it from the link together with the program symbols
+use proxmox_ebpf as _;
+
+use std::cell::RefCell;
+use std::collections::HashMap;
+use std::ffi::{c_int, c_long, c_void};
+
+pub const TC_ACT_OK: c_int = 0;
+pub const TC_ACT_REDIRECT: c_int = 7;
+
+pub const PKT_CAP: usize = 2048;
+
+/// Keep in sync with struct __sk_buff in src/bpf-shim/vmlinux.h.
+#[repr(C)]
+pub struct SkBuff {
+ pub data: usize,
+ pub data_end: usize,
+ pub len: u32,
+ pub ifindex: u32,
+ pub mark: u32,
+}
+
+/// The skb handed to a program plus the buffer behind it. The helpers get the skb pointer and
+/// cast back to this, so the skb must stay the first field.
+#[repr(C)]
+pub struct TestSkb {
+ skb: SkBuff,
+ buf: Box<[u8; PKT_CAP]>,
+}
+
+impl TestSkb {
+ pub fn new(packet: &[u8], ifindex: u32) -> Self {
+ assert!(packet.len() <= PKT_CAP);
+ let mut buf = Box::new([0u8; PKT_CAP]);
+ buf[..packet.len()].copy_from_slice(packet);
+ let mut t = TestSkb {
+ skb: SkBuff {
+ data: 0,
+ data_end: 0,
+ len: packet.len() as u32,
+ ifindex,
+ mark: 0,
+ },
+ buf,
+ };
+ t.sync();
+ t
+ }
+
+ fn sync(&mut self) {
+ let base = self.buf.as_ptr() as usize;
+ self.skb.data = base;
+ self.skb.data_end = base + self.skb.len as usize;
+ }
+
+ pub fn packet(&self) -> &[u8] {
+ &self.buf[..self.skb.len as usize]
+ }
+
+ pub fn run(&mut self, prog: unsafe extern "C" fn(*mut SkBuff) -> c_int) -> c_int {
+ REDIRECTED.with(|r| *r.borrow_mut() = None);
+ // derived from the whole struct since the helpers widen it back to one
+ unsafe { prog((self as *mut TestSkb).cast()) }
+ }
+}
+
+struct MockMap {
+ key_size: usize,
+ entries: HashMap<Vec<u8>, Box<[u8]>>,
+}
+
+thread_local! {
+ static MAPS: RefCell<HashMap<usize, MockMap>> = RefCell::new(HashMap::new());
+ static REDIRECTED: RefCell<Option<u32>> = const { RefCell::new(None) };
+}
+
+pub fn register_map(map: *const c_void, key_size: usize) {
+ MAPS.with(|maps| {
+ maps.borrow_mut().insert(
+ map as usize,
+ MockMap {
+ key_size,
+ entries: HashMap::new(),
+ },
+ )
+ });
+}
+
+pub fn map_insert(map: *const c_void, key: &[u8], value: &[u8]) {
+ MAPS.with(|maps| {
+ let mut maps = maps.borrow_mut();
+ let m = maps.get_mut(&(map as usize)).expect("map not registered");
+ assert_eq!(key.len(), m.key_size);
+ m.entries.insert(key.to_vec(), value.into());
+ });
+}
+
+/// The ifindex of the redirect the last run issued, if any.
+pub fn redirected() -> Option<u32> {
+ REDIRECTED.with(|r| *r.borrow())
+}
+
+unsafe fn testskb<'a>(skb: *mut c_void) -> &'a mut TestSkb {
+ unsafe { &mut *(skb as *mut TestSkb) }
+}
+
+#[unsafe(no_mangle)]
+pub extern "C" fn bpf_map_lookup_elem(map: *mut c_void, key: *const c_void) -> *mut c_void {
+ MAPS.with(|maps| {
+ let maps = maps.borrow();
+ let Some(m) = maps.get(&(map as usize)) else {
+ panic!("lookup on unregistered map");
+ };
+ let key = unsafe { std::slice::from_raw_parts(key as *const u8, m.key_size) };
+ match m.entries.get(key) {
+ // the box's heap allocation stays put while the registry holds it
+ Some(v) => v.as_ptr() as *mut c_void,
+ None => std::ptr::null_mut(),
+ }
+ })
+}
+
+#[unsafe(no_mangle)]
+pub extern "C" fn bpf_skb_load_bytes(
+ skb: *mut c_void,
+ offset: u32,
+ to: *mut c_void,
+ len: u32,
+) -> c_long {
+ let t = unsafe { testskb(skb) };
+ let (offset, len) = (offset as usize, len as usize);
+ if offset + len > t.skb.len as usize {
+ return -1;
+ }
+ unsafe { std::ptr::copy_nonoverlapping(t.buf.as_ptr().add(offset), to as *mut u8, len) };
+ 0
+}
+
+#[unsafe(no_mangle)]
+pub extern "C" fn bpf_skb_store_bytes(
+ skb: *mut c_void,
+ offset: u32,
+ from: *const c_void,
+ len: u32,
+ _flags: u64,
+) -> c_long {
+ let t = unsafe { testskb(skb) };
+ let (offset, len) = (offset as usize, len as usize);
+ if offset + len > t.skb.len as usize {
+ return -1;
+ }
+ unsafe {
+ std::ptr::copy_nonoverlapping(from as *const u8, t.buf.as_mut_ptr().add(offset), len)
+ };
+ 0
+}
+
+#[unsafe(no_mangle)]
+pub extern "C" fn bpf_skb_pull_data(skb: *mut c_void, len: u32) -> c_long {
+ // the buffer is always linear, pulling within it is a no-op
+ let t = unsafe { testskb(skb) };
+ if len > t.skb.len { -1 } else { 0 }
+}
+
+#[unsafe(no_mangle)]
+pub extern "C" fn bpf_skb_change_tail(skb: *mut c_void, new_len: u32, _flags: u64) -> c_long {
+ let t = unsafe { testskb(skb) };
+ if new_len as usize > PKT_CAP {
+ return -1;
+ }
+ // like the kernel, grown room reads as zeros
+ let old = t.skb.len as usize;
+ if new_len as usize > old {
+ t.buf[old..new_len as usize].fill(0);
+ }
+ t.skb.len = new_len;
+ t.sync();
+ 0
+}
+
+#[unsafe(no_mangle)]
+pub extern "C" fn bpf_redirect(ifindex: u32, _flags: u64) -> c_long {
+ REDIRECTED.with(|r| *r.borrow_mut() = Some(ifindex));
+ TC_ACT_REDIRECT as c_long
+}
--
2.47.3
^ permalink raw reply related [flat|nested] 4+ messages in thread
* [PATCH proxmox-ebpf v2 3/3] debian: package the crate as a rust library
2026-09-04 9:04 [PATCH proxmox-ebpf v2 0/3] add proxmox-ebpf library Hannes Laimer
2026-09-04 9:04 ` [PATCH proxmox-ebpf v2 1/3] add the shared tc subsystem code Hannes Laimer
2026-09-04 9:04 ` [PATCH proxmox-ebpf v2 2/3] tests: add a native harness for the BPF C programs Hannes Laimer
@ 2026-09-04 9:04 ` Hannes Laimer
2 siblings, 0 replies; 4+ messages in thread
From: Hannes Laimer @ 2026-09-04 9:04 UTC (permalink / raw)
To: pve-devel
Through debcargo with the overlay in debian/, like we do for the other
rust crates. The BPF programs are compiled by the build script, so
building against the crate pulls in clang and the libbpf headers as
well.
Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
.gitignore | 1 +
Cargo.toml | 7 +++++++
Makefile | 48 ++++++++++++++++++++++++++++++++++++++++++
build.rs | 5 +++++
debian/changelog | 5 +++++
debian/control | 50 ++++++++++++++++++++++++++++++++++++++++++++
debian/copyright | 18 ++++++++++++++++
debian/debcargo.toml | 13 ++++++++++++
debian/source/format | 1 +
9 files changed, 148 insertions(+)
create mode 100644 Makefile
create mode 100644 debian/changelog
create mode 100644 debian/control
create mode 100644 debian/copyright
create mode 100644 debian/debcargo.toml
create mode 100644 debian/source/format
diff --git a/.gitignore b/.gitignore
index 3cb8cae..574dcbd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,3 +8,4 @@ Cargo.lock
*.changes
*.tar.?z
/proxmox-ebpf-[0-9]*/
+/build
diff --git a/Cargo.toml b/Cargo.toml
index 27010c4..1e8703d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,7 +1,14 @@
[package]
name = "proxmox-ebpf"
version = "0.1.0"
+description = "Proxmox VE eBPF"
+authors = ["Proxmox Support Team <support@proxmox.com>"]
edition = "2024"
+rust-version = "1.88"
+license = "AGPL-3"
+homepage = "https://proxmox.com"
+
+exclude = [".cargo", ".gitignore", "Makefile", "build", "debian"]
[features]
# When enabled, BPF programs compile with -DBPF_DEBUG so their DBG() macros
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..3650a9d
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,48 @@
+include /usr/share/dpkg/pkg-info.mk
+
+CRATE := proxmox-ebpf
+BUILDDIR ?= build
+CARGO ?= cargo
+
+DSC := $(BUILDDIR)/rust-$(CRATE)_$(DEB_VERSION).dsc
+
+all: cargo-build
+
+.PHONY: cargo-build
+cargo-build:
+ $(CARGO) build --all-features
+
+.PHONY: check test
+check test:
+ $(CARGO) test --all-features
+
+# the generated control is copied back so the checked-in one stays current
+$(BUILDDIR)/$(CRATE): Cargo.toml build.rs src include tests debian
+ rm -rf $@
+ mkdir -p $(BUILDDIR)
+ echo system > $(BUILDDIR)/rust-toolchain
+ rm -f debian/control
+ debcargo package \
+ --config $(CURDIR)/debian/debcargo.toml \
+ --changelog-ready \
+ --no-overlay-write-back \
+ --directory $(CURDIR)/$@ \
+ $(CRATE) \
+ $(DEB_VERSION_UPSTREAM)
+ rm -f $@/debian/source/format.debcargo.hint
+ cp $@/debian/control debian/control
+
+.PHONY: deb
+deb: $(BUILDDIR)/$(CRATE)
+ cd $(BUILDDIR)/$(CRATE); dpkg-buildpackage -b -uc -us
+ lintian $(BUILDDIR)/*.deb
+
+.PHONY: dsc
+dsc: $(BUILDDIR)/$(CRATE)
+ cd $(BUILDDIR)/$(CRATE); dpkg-buildpackage -S -us -uc -d
+ lintian $(DSC)
+
+.PHONY: clean
+clean:
+ $(CARGO) clean
+ rm -rf $(BUILDDIR)
diff --git a/build.rs b/build.rs
index 81c2ce4..188f810 100644
--- a/build.rs
+++ b/build.rs
@@ -73,6 +73,11 @@ fn main() {
);
println!("cargo:rustc-link-search=native={}", out_dir.display());
println!("cargo:rustc-link-lib=static=bpf_native");
+ // the debian package should include the BPF C sources
+ println!(
+ "dh-cargo:deb-built-using=bpf_native=1={}",
+ manifest_dir.display()
+ );
}
// native build against the shim headers, so tests call the program as a plain function
diff --git a/debian/changelog b/debian/changelog
new file mode 100644
index 0000000..71e3394
--- /dev/null
+++ b/debian/changelog
@@ -0,0 +1,5 @@
+rust-proxmox-ebpf (0.1.0) trixie; urgency=medium
+
+ * Initial release.
+
+ -- Proxmox Support Team <support@proxmox.com> Wed, 02 Sep 2026 10:00:00 +0200
diff --git a/debian/control b/debian/control
new file mode 100644
index 0000000..c415c8d
--- /dev/null
+++ b/debian/control
@@ -0,0 +1,50 @@
+Source: rust-proxmox-ebpf
+Section: rust
+Priority: optional
+Build-Depends: debhelper-compat (= 13),
+ dh-sequence-cargo
+Build-Depends-Arch: cargo:native <!nocheck>,
+ rustc:native (>= 1.88) <!nocheck>,
+ libstd-rust-dev <!nocheck>,
+ librust-anyhow-1+default-dev <!nocheck>,
+ librust-aya-0.13+default-dev <!nocheck>,
+ librust-log-0.4+default-dev <!nocheck>,
+ librust-nix-0.29+default-dev <!nocheck>,
+ librust-nix-0.29+fs-dev <!nocheck>,
+ librust-nix-0.29+net-dev <!nocheck>,
+ clang <!nocheck>,
+ libbpf-dev <!nocheck>
+Maintainer: Proxmox Support Team <support@proxmox.com>
+Standards-Version: 4.7.2
+Vcs-Git: https://salsa.debian.org/rust-team/debcargo-conf.git [src/proxmox-ebpf]
+Vcs-Browser: https://salsa.debian.org/rust-team/debcargo-conf/tree/master/src/proxmox-ebpf
+Homepage: https://proxmox.com
+X-Cargo-Crate: proxmox-ebpf
+
+Package: librust-proxmox-ebpf-dev
+Architecture: any
+Multi-Arch: same
+Depends:
+ ${misc:Depends},
+ librust-anyhow-1+default-dev,
+ librust-aya-0.13+default-dev,
+ librust-log-0.4+default-dev,
+ librust-nix-0.29+default-dev,
+ librust-nix-0.29+fs-dev,
+ librust-nix-0.29+net-dev,
+ clang,
+ libbpf-dev
+Provides:
+ librust-proxmox-ebpf+bpf-debug-dev (= ${binary:Version}),
+ librust-proxmox-ebpf+default-dev (= ${binary:Version}),
+ librust-proxmox-ebpf-0-dev (= ${binary:Version}),
+ librust-proxmox-ebpf-0+bpf-debug-dev (= ${binary:Version}),
+ librust-proxmox-ebpf-0+default-dev (= ${binary:Version}),
+ librust-proxmox-ebpf-0.1-dev (= ${binary:Version}),
+ librust-proxmox-ebpf-0.1+bpf-debug-dev (= ${binary:Version}),
+ librust-proxmox-ebpf-0.1+default-dev (= ${binary:Version}),
+ librust-proxmox-ebpf-0.1.0-dev (= ${binary:Version}),
+ librust-proxmox-ebpf-0.1.0+bpf-debug-dev (= ${binary:Version}),
+ librust-proxmox-ebpf-0.1.0+default-dev (= ${binary:Version})
+Description: Proxmox VE eBPF - Rust source code
+ Source code for Debianized Rust crate "proxmox-ebpf"
diff --git a/debian/copyright b/debian/copyright
new file mode 100644
index 0000000..01138fa
--- /dev/null
+++ b/debian/copyright
@@ -0,0 +1,18 @@
+Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
+
+Files:
+ *
+Copyright: 2026 Proxmox Server Solutions GmbH <support@proxmox.com>
+License: AGPL-3.0-or-later
+ This program is free software: you can redistribute it and/or modify it under
+ the terms of the GNU Affero General Public License as published by the Free
+ Software Foundation, either version 3 of the License, or (at your option) any
+ later version.
+ .
+ This program is distributed in the hope that it will be useful, but WITHOUT
+ ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+ FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+ details.
+ .
+ You should have received a copy of the GNU Affero General Public License along
+ with this program. If not, see <https://www.gnu.org/licenses/>.
diff --git a/debian/debcargo.toml b/debian/debcargo.toml
new file mode 100644
index 0000000..18361e7
--- /dev/null
+++ b/debian/debcargo.toml
@@ -0,0 +1,13 @@
+overlay = "."
+crate_src_path = ".."
+maintainer = "Proxmox Support Team <support@proxmox.com>"
+
+# the BPF programs are C sources of this crate, not vendored code
+whitelist = ["src/*/bpf/*.c"]
+
+[source]
+#vcs_git = "git://git.proxmox.com/git/proxmox-ebpf.git"
+#vcs_browser = "https://git.proxmox.com/?p=proxmox-ebpf.git"
+
+[packages.lib]
+depends = ["clang", "libbpf-dev"]
diff --git a/debian/source/format b/debian/source/format
new file mode 100644
index 0000000..89ae9db
--- /dev/null
+++ b/debian/source/format
@@ -0,0 +1 @@
+3.0 (native)
--
2.47.3
^ permalink raw reply related [flat|nested] 4+ messages in thread
end of thread, other threads:[~2026-09-04 9:05 UTC | newest]
Thread overview: 4+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
2026-09-04 9:04 [PATCH proxmox-ebpf v2 0/3] add proxmox-ebpf library Hannes Laimer
2026-09-04 9:04 ` [PATCH proxmox-ebpf v2 1/3] add the shared tc subsystem code Hannes Laimer
2026-09-04 9:04 ` [PATCH proxmox-ebpf v2 2/3] tests: add a native harness for the BPF C programs Hannes Laimer
2026-09-04 9:04 ` [PATCH proxmox-ebpf v2 3/3] debian: package the crate as a rust library Hannes Laimer
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.