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 v4 1/5] tc: add the link plumbing
Date: Mon, 19 Oct 2026 16:24:08 +0200	[thread overview]
Message-ID: <20261019142413.2255644-2-h.laimer@proxmox.com> (raw)
In-Reply-To: <20261019142413.2255644-1-h.laimer@proxmox.com>

Attach, swap and detach of tcx links on an interface, and the naming
of their pins in bpffs. A swap rebinds the pinned link to a new
program in place, so an update never interrupts traffic.

What a failed step means for the interface is the caller's decision.
So a lost race against another attach, a vanished interface and a pin
already gone each come back as their own error, read from the
kernel's answer.

Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
 src/lib.rs |   2 +
 src/tc.rs  | 303 +++++++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 305 insertions(+)
 create mode 100644 src/tc.rs

diff --git a/src/lib.rs b/src/lib.rs
index 244f44c..8d4420a 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,2 +1,4 @@
 //! 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 tc;
diff --git a/src/tc.rs b/src/tc.rs
new file mode 100644
index 0000000..05d823e
--- /dev/null
+++ b/src/tc.rs
@@ -0,0 +1,303 @@
+//! TC link plumbing shared by all subsystems. How a subsystem's programs are attached as tcx
+//! links (kernel 6.6 and newer), swapped and detached, and how their pins are named in bpffs,
+//! see [`LinkPin`].
+
+use std::{io::ErrorKind, path::Path, str::FromStr};
+
+use anyhow::Context;
+use aya::{
+    pin::PinError,
+    programs::{
+        ProgramError, SchedClassifier, TcAttachType,
+        links::{FdLink, LinkError, LinkOrder, PinnedLink},
+        tc::TcAttachOptions,
+    },
+};
+
+/// Why a link step did not go through, with the kernel's own answer where it gives one, so a
+/// caller can tell a lost race or a vanished interface from a real failure.
+#[derive(Debug)]
+pub enum Error {
+    /// A link of the program is in place already, another attach of the interface won the race.
+    AlreadyAttached,
+    /// The interface is gone, its index names no netdev anymore.
+    IfaceGone,
+    /// The pin was gone by the time it was opened or removed, another attach or unplug took it.
+    PinGone,
+    Other(anyhow::Error),
+}
+
+impl std::fmt::Display for Error {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            Self::AlreadyAttached => f.write_str("link already in place"),
+            Self::IfaceGone => f.write_str("interface is gone"),
+            Self::PinGone => f.write_str("pin is gone"),
+            Self::Other(e) if f.alternate() => write!(f, "{e:#}"),
+            Self::Other(e) => write!(f, "{e}"),
+        }
+    }
+}
+
+impl std::error::Error for Error {
+    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+        match self {
+            Self::Other(e) => e.chain().nth(1),
+            _ => None,
+        }
+    }
+}
+
+impl From<anyhow::Error> for Error {
+    fn from(e: anyhow::Error) -> Self {
+        Self::Other(e)
+    }
+}
+
+// EEXIST on the link create is the kernel refusing a second link of the program in the chain
+impl From<ProgramError> for Error {
+    fn from(e: ProgramError) -> Self {
+        match e {
+            ProgramError::SyscallError(s) if s.io_error.kind() == ErrorKind::AlreadyExists => {
+                Self::AlreadyAttached
+            }
+            e => Self::Other(e.into()),
+        }
+    }
+}
+
+// EEXIST on the pin is the path taken by another attach, the link is there either way
+impl From<PinError> for Error {
+    fn from(e: PinError) -> Self {
+        match e {
+            PinError::SyscallError(s) if s.io_error.kind() == ErrorKind::AlreadyExists => {
+                Self::AlreadyAttached
+            }
+            e => Self::Other(e.into()),
+        }
+    }
+}
+
+impl From<LinkError> for Error {
+    fn from(e: LinkError) -> Self {
+        match e {
+            LinkError::SyscallError(s) if s.io_error.kind() == ErrorKind::NotFound => Self::PinGone,
+            e => Self::Other(e.into()),
+        }
+    }
+}
+
+impl From<std::io::Error> for Error {
+    fn from(e: std::io::Error) -> Self {
+        if e.kind() == ErrorKind::NotFound {
+            Self::PinGone
+        } else {
+            Self::Other(e.into())
+        }
+    }
+}
+
+#[derive(Copy, Clone, Hash, PartialEq, Eq, Debug)]
+pub enum Direction {
+    Ingress,
+    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(()),
+        }
+    }
+}
+
+/// One pinned link as its pin file names it. The interface name is part of the file name, so an
+/// unplug reported after the interface is gone still finds the pin and a reused index does not
+/// pass for the old link. The fields are separated by a colon, which the kernel forbids in
+/// interface names. A dot inside the name is written as a colon as well, since bpffs refuses a
+/// dot in a file name.
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+pub struct LinkPin {
+    pub ifindex: u32,
+    pub name: String,
+    pub dir: Direction,
+}
+
+impl LinkPin {
+    pub fn filename(&self) -> String {
+        format!(
+            "{}:{}:{}",
+            self.ifindex,
+            self.dir.as_str(),
+            self.name.replace('.', ":")
+        )
+    }
+
+    /// The pin a file name stands for, none when it is not one of ours.
+    pub fn parse(filename: &str) -> Option<Self> {
+        let mut parts = filename.splitn(3, ':');
+        let ifindex = parts.next()?.parse().ok()?;
+        let dir = parts.next()?.parse().ok()?;
+        let name = parts.next()?.replace(':', ".");
+        let pin = LinkPin { ifindex, name, dir };
+        // a file name that does not round-trip is not one of ours, and neither is one the kernel
+        // would refuse as an interface name
+        let valid_name = !pin.name.is_empty()
+            && pin.name.len() < 16
+            && pin.name != "."
+            && pin.name != ".."
+            && !pin.name.chars().any(|c| c.is_whitespace() || c == '/');
+        (pin.filename() == filename && valid_name).then_some(pin)
+    }
+}
+
+/// Attach `prog` on the interface and direction of `pin` as a tcx link and pin it at
+/// `pin_path`. tcx (kernel 6.6 and newer) runs its programs on the netdev hook ahead of the
+/// filters on the interface's qdiscs, such as the `ingress` qdisc the rate limiter installs.
+/// `TC_ACT_UNSPEC` hands a packet on to the next program and then to the filters. Any other
+/// verdict ends the chain right there. So a subsystem's program returns `TC_ACT_UNSPEC` for
+/// whatever it leaves alone. Programs of several subsystems run in attach order, each new link
+/// goes last.
+pub fn attach_and_pin(
+    prog: &mut SchedClassifier,
+    pin: &LinkPin,
+    pin_path: &Path,
+) -> Result<(), Error> {
+    let link_id = prog
+        .attach_with_options(
+            &pin.name,
+            pin.dir.aya_type(),
+            TcAttachOptions::TcxOrder(LinkOrder::last()),
+        )
+        .map_err(|e| match Error::from(e) {
+            // the interface can go away between the name lookup and the attach
+            Error::Other(_) if matches!(iface_name(pin.ifindex), Ok(None)) => Error::IfaceGone,
+            e => e,
+        })?;
+    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 as well, the
+    // unpinned link detaches again when its fd drops
+    fd_link.pin(pin_path)?;
+    Ok(())
+}
+
+/// 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) -> Result<(), Error> {
+    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) -> Result<(), Error> {
+    let pinned = PinnedLink::from_pin(pin_path)?;
+    let _fd_link = pinned.unpin()?;
+    Ok(())
+}
+
+/// The name of an interface by index, none when it is gone. Any other failure says nothing
+/// about the interface and is not taken as its absence, so the lookup reads errno itself.
+pub fn iface_name(ifindex: u32) -> anyhow::Result<Option<String>> {
+    let mut buf = [0u8; nix::libc::IF_NAMESIZE];
+    // SAFETY: the buffer holds IF_NAMESIZE bytes, the most the call writes
+    let name = unsafe { nix::libc::if_indextoname(ifindex, buf.as_mut_ptr().cast()) };
+    if name.is_null() {
+        return match nix::errno::Errno::last() {
+            nix::errno::Errno::ENXIO => Ok(None),
+            e => Err(e).with_context(|| format!("name of interface {ifindex}")),
+        };
+    }
+    let name = std::ffi::CStr::from_bytes_until_nul(&buf)?
+        .to_str()
+        .with_context(|| format!("name of interface {ifindex} is not UTF-8"))?;
+    Ok(Some(name.to_owned()))
+}
+
+/// The pins in `links_dir`, see [`LinkPin`], none for a missing directory. A file that is not a
+/// pin is skipped with a warning.
+pub fn read_pinned_links(links_dir: &Path) -> anyhow::Result<Vec<LinkPin>> {
+    let mut out = Vec::new();
+    let dir = match std::fs::read_dir(links_dir) {
+        Ok(d) => d,
+        Err(e) if matches!(e.kind(), ErrorKind::NotFound | ErrorKind::NotADirectory) => {
+            return Ok(out);
+        }
+        Err(e) => return Err(e.into()),
+    };
+    for entry in dir {
+        let entry = entry?;
+        let name = entry.file_name();
+        match name.to_str().and_then(LinkPin::parse) {
+            Some(pin) => out.push(pin),
+            None => log::warn!(
+                "unrecognized pin file {} in {}",
+                name.to_string_lossy(),
+                links_dir.display()
+            ),
+        }
+    }
+    Ok(out)
+}
+
+#[cfg(test)]
+mod test {
+    use super::*;
+
+    #[test]
+    fn pin_names_round_trip() {
+        for name in ["tap100i0", "veth100i31", "vmbr0.100", "bond0.5.7"] {
+            let pin = LinkPin {
+                ifindex: 42,
+                name: name.into(),
+                dir: Direction::Ingress,
+            };
+            assert!(!pin.filename().contains('.'), "{}", pin.filename());
+            assert_eq!(LinkPin::parse(&pin.filename()), Some(pin));
+        }
+        assert_eq!(
+            LinkPin::parse("7:egress:eno1").map(|p| p.name),
+            Some("eno1".into())
+        );
+    }
+
+    #[test]
+    fn pin_names_are_canonical() {
+        for bad in [
+            "042:ingress:tap0",
+            "+42:ingress:tap0",
+            "42:ingress:",
+            "42:ingress",
+            "42:Ingress:tap0",
+            "42-ingress",
+            "4294967296:ingress:tap0",
+            "42:ingress:tap 0",
+            "42:ingress::",
+            "42:ingress:::",
+            "42:ingress:sixteencharsname",
+        ] {
+            assert_eq!(LinkPin::parse(bad), None, "{bad}");
+        }
+    }
+}
-- 
2.47.3





  reply	other threads:[~2026-10-19 14:24 UTC|newest]

Thread overview: 6+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-10-19 14:24 [PATCH proxmox-ebpf v4 0/5] add proxmox-ebpf library Hannes Laimer
2026-10-19 14:24 ` Hannes Laimer [this message]
2026-10-19 14:24 ` [PATCH proxmox-ebpf v4 2/5] subsystem: add the shared tc program handling Hannes Laimer
2026-10-19 14:24 ` [PATCH proxmox-ebpf v4 3/5] subsystem: place the links of different subsystems in a fixed order Hannes Laimer
2026-10-19 14:24 ` [PATCH proxmox-ebpf v4 4/5] tests: add a native harness for the BPF C programs Hannes Laimer
2026-10-19 14:24 ` [PATCH proxmox-ebpf v4 5/5] 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=20261019142413.2255644-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