public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: Hannes Laimer <h.laimer@proxmox.com>
To: pve-devel@lists.proxmox.com
Subject: [PATCH proxmox-ebpf v2 1/3] add the shared tc subsystem code
Date: Fri,  4 Sep 2026 11:04:56 +0200	[thread overview]
Message-ID: <20260904090458.990888-2-h.laimer@proxmox.com> (raw)
In-Reply-To: <20260904090458.990888-1-h.laimer@proxmox.com>

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





  reply	other threads:[~2026-09-04  9:05 UTC|newest]

Thread overview: 4+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-09-04  9:04 [PATCH proxmox-ebpf v2 0/3] add proxmox-ebpf library Hannes Laimer
2026-09-04  9:04 ` Hannes Laimer [this message]
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

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=20260904090458.990888-2-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
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal