An AgentX (RFC 2741) subagent that serves IF-MIB ifStackTable
(1.3.6.1.2.1.31.1.2) for Linux hosts, so SNMP monitoring systems can discover interface relationships.
The subagent serves GET, GETNEXT, and GETBULK requests. It refreshes the topology on demand after a five-second cache window. It reconnects and registers again when the master closes the session or the socket fails.
Packages support amd64 Linux hosts. Both formats contain a stripped musl static binary, so the executable does not require a particular glibc version. Download the package for your distribution from the project's GitHub Releases.
On Debian 12 or 13:
sudo apt install ./agentx-ifstack_*_amd64.debOn an RPM distribution with DNF:
sudo dnf install ./agentx-ifstack-*.x86_64.rpmThe packages depend on iproute2 on Debian and iproute on RPM distributions.
They install the service without enabling or starting it. Add this line to
/etc/snmp/snmpd.conf:
master agentx
Edit /etc/agentx-ifstack.toml if needed, then start the services:
sudo systemctl restart snmpd.service
sudo systemctl enable --now agentx-ifstack.service
systemctl status agentx-ifstack.service
journalctl -u agentx-ifstack.service
agentx-ifstack --version
man agentx-ifstackThe service runs as root because /var/agentx is normally root-owned with mode
0700. It has no capabilities and writes no files. Its systemd sandbox permits
Unix and netlink sockets and execution of ip in the host network namespace.
The unit orders itself after snmpd.service without pulling that service in.
A missing master causes connection retries, not startup failure.
On Debian, sudo apt remove agentx-ifstack preserves the configuration;
sudo apt purge agentx-ifstack removes it. On RPM distributions,
sudo dnf remove agentx-ifstack removes the package. RPM saves a modified
configuration as /etc/agentx-ifstack.toml.rpmsave on removal.
Both package formats preserve local configuration edits during upgrades.
An upgrade restarts the service only if it is already running.
The default file is /etc/agentx-ifstack.toml. It accepts exactly four keys:
socket = "/var/agentx/master"
refresh = 5
priority = 127
log_level = "info"socketis a nonempty AgentX Unix socket path.refreshis an integer cache interval in seconds, at least 1.priorityis an integer from 1 to 255. Lower registration priorities win.log_leveliserror,warn,info,debug, ortrace.
Omitted keys use built-in defaults. An absent default file is allowed.
--config PATH selects another file and requires that file to exist, even if
PATH names the default location. Invalid TOML, unknown keys, invalid values,
and unreadable files cause an error before any connection attempt.
File values override built-in defaults. --socket PATH overrides the file's
socket value. RUST_LOG overrides the log filter. Fatal startup errors go directly
to stderr even when RUST_LOG=off. Logs go to stderr without
timestamps; journald supplies timestamps for the service. A healthy idle daemon
is quiet at info. Restart the process after configuration changes.
sudo agentx-ifstack --config /etc/agentx-ifstack.toml --socket /run/agentx/master
agentx-ifstack --helpThe process uses one thread. It runs ip -details -json link show in the
current network namespace. Refresh is demand driven: the first read after the
cache expires loads topology again. Failed commands and invalid topology data
return an AgentX processing error. The next read retries the refresh.
Reconnect delays start at one second and double to a maximum of 30 seconds.
A session that lasts at least 30 seconds resets the delay.
Install Rust using the toolchain pinned in rust-toolchain.toml, then:
cargo build --release
sudo ./target/release/agentx-ifstackFor both packages, install musl-tools, binutils, and gzip on a Debian
build host, then:
rustup target add x86_64-unknown-linux-musl
cargo install --locked --version 3.8.0 cargo-deb
cargo install --locked --version 0.21.0 cargo-generate-rpm
sh packaging/build.shThe build script writes .deb and .rpm files to dist/. It compresses the
handwritten man page and checks that the binary is static, stripped, and free
of build-directory paths. The Cargo metadata defines the installed assets.
The systemd unit installs under /usr/lib/systemd/system; /lib/systemd/system
resolves to the same location on the supported Debian releases.
The Debian maintainer contact is a placeholder until a public contact is set.
CI runs formatting, strict Clippy, tests, and a musl package build on branch pushes and pull requests. It installs the packages in Debian 12, Debian 13, and Fedora containers. Checks cover the executable, unit, man page, licenses, configuration registration, local edits across reinstall, and removal. Debian also checks configuration preservation on remove and deletion on purge. Tag releases require the shared formatting, Clippy, test, and package workflows before publishing. Only the release workflow handles tag pushes, so each tag builds packages once.
To run the container checks after building packages:
docker run --rm -v "$PWD:/work:ro" debian:12 sh -c 'sh /work/packaging/test-deb.sh /work/dist/*.deb'
docker run --rm -v "$PWD:/work:ro" debian:13 sh -c 'sh /work/packaging/test-deb.sh /work/dist/*.deb'
docker run --rm -v "$PWD:/work:ro" fedora:latest sh -c 'sh /work/packaging/test-rpm.sh /work/dist/*.rpm'Lintian runs with errors and warnings treated as failures. The package documents
two exceptions: initial-upload-closes-no-bugs, because this upstream package
has no Debian intent-to-package bug, and shared-library-lacks-prerequisites,
because the musl static PIE requires no shared libraries. The Apache license
reference uses Debian's common-license path; both full licenses also ship.
RPM linting permits statically-linked-binary for the musl executable and
no-signature for unsigned GitHub Release artifacts. The spelling filter accepts
subagent, the term used by RFC 2741. The no-buildhost-tag filter permits
reproducible builds to omit the build hostname. The no-changelogname-tag filter
permits this upstream binary package to use Cargo metadata; its packaging
changelog ships as the Debian changelog. Other RPM findings remain
visible in CI. RPM contents and installation are checked by the Fedora job.
The container checks use offline systemd inspection and do not exercise a live
systemd service manager.
Register the region 1.3.6.1.2.1.31.1.2 at the configured priority (default 127) in the default
context. Serve ifStackStatus as INTEGER 1. TestSet returns
notWritable(17) at the first binding. CleanupSet requires no response.
Emit rows in RFC index order:
ifStackStatus.<higher_ifindex>.<lower_ifindex> = 1 # active
The higher sub-layer is the first index component. The lower sub-layer is the second. RFC 2863, ifStackTable DESCRIPTION defines the rule:
when the sub-layer with ifIndex value x runs over the sub-layer with ifIndex value y, then this table contains: ifStackStatus.x.y=active
A bond (9) runs over its member (3), so its row is 9.3. A VLAN (10)
runs over that bond (9), so its row is 10.9.
Emit only direct relationships. A VLAN on a bond adds a (VLAN, bond) row.
It does not add VLAN-to-member rows. Include zero-index rows for each
missing side of the emitted stack. A plain interface has both (0, index)
and (index, 0). Consumers wanting only real relationships filter those rows.
Counts of non-zero relationships exclude boundary rows. Tests assert the two sets separately.
GETNEXT honors inclusive starts and exclusive ends. A null end has no
upper bound. Exhausted ranges return endOfMibView with the requested OID.
GETBULK returns repeaters in iteration order and stops when all repeaters
are exhausted or the response reaches 4096 bindings. Input PDUs are limited
to 1 MiB of payload. Request OIDs are limited to 128 sub-identifiers after
prefix expansion, per RFC 2741 section 5.1.
Validate both ends of every SearchRange, every TestSet name, and every
OID-valued TestSet value before processing the request. Oversized OIDs
return parseError, and the session continues to serve requests.
Handshakes and partial reads have five-second socket timeouts. An idle
registered connection has no read timeout.
ip -details -json link show supplies everything needed:
| field | meaning |
|---|---|
ifindex |
the interface's own ifIndex |
master |
bond or bridge membership, as an interface name |
linkinfo.info_kind |
interface kind, used to distinguish stack layers from peers |
link |
lower interface name for vlan, macvlan, ipvlan, and macvtap |
link_index |
lower interface index when emitted in numeric form |
linkinfo.info_data.link |
lower interface name for vxlan |
Verified against real Proxmox hosts: master and link are
names, not indices, and link_index is absent entirely. Build an
ifname -> ifindex map from the same output and resolve through it. Do not
depend on link_index being present.
The parser resolves lower interfaces for vlan, macvlan, ipvlan, and
macvtap through either link or link_index. A vxlan carries its underlay in
linkinfo.info_data instead, always as a name. ip writes the literal if<index>
there when no interface resolves, so an underlay that matches no interface in the same
output leaves the vxlan standalone, as does a vxlan with no underlay. It rejects
missing lower interfaces, conflicting references, duplicate names or indices,
and self-links. It does not treat veth peer links or VRF membership as stack
relationships. A lower interface in another namespace is an error because
its ifIndex cannot identify a local interface.
Real topologies to handle, present in the collected captures:
- a bond with physical members (
bond0<-nic2,nic3) - VLANs on a bond (
bond0.110,bond0.111, eachlink: bond0) - a bond enslaved to a bridge (
bond1hasmaster: vmbr1), so an interface is a higher sub-layer for its members and a lower sub-layer for the bridge - a VLAN on a bridge (
vmbr1.120,link: vmbr1) - Proxmox firewall bridges (
fwbr<vmid>i0) joined tovmbr0by a veth pair (fwpr<vmid>p0/fwln<vmid>i0), plustap<vmid>i<n>guest interfaces
cargo build
cargo test
cargo clippy --all-targets -- -D warnings
cargo fmt --checkpython3 packaging/test_policy.py checks the release gate, push triggers, and
service restart policy. It requires PyYAML 6.0.3. The shared checks workflow runs it.
tests/session.rs runs the actual binary against a UnixListener. The master
uses real AgentX PDUs. A fixture executable supplies ip output without
changing host interfaces. Tests cover both byte orders, reads, bulk walks,
write rejection, cache refresh, errors, Close, and reconnect after socket
loss. Configuration tests use real temporary files. Wire tests prove that the
configured socket, priority, and refresh interval take effect and that the CLI
socket overrides the file. Raw wire tests cover oversized OIDs at both SearchRange ends and in
TestSet names and OID values. They check parseError, process survival, and
a normal GET on the same connection. Pure tests cover topology fixtures and
OID boundaries.
tests/fixtures/proxmox.json preserves the relationship fields from the
collected Proxmox capture. Interface names are replaced with port<index>.
Addresses and unrelated configuration fields are removed. Add sanitized
captures as JSON fixtures and assert their expected direct rows in link.rs.
The table decisions are grounded in these, not in convention:
- RFC 2863, sections 3.1.1 and 6, for the ifStackTable model, the "runs over" index rule, and the zero-index boundary rows.
- RFC 2741, sections 5.1, 5.4 and 7.2.2 through 7.2.4, for PDU handling, the 128 sub-identifier OID limit, and GETNEXT semantics.
- The
agentx0.1.1 source, for the encoder and decoder behaviour this depends on.
Licensed under either of Apache License, Version 2.0 or MIT license at your option.
- No existing subagent serves
ifStackTable. The closest is net-snmp's ownlocal/snmp-bridge-mib: Perl, AgentX, reads/sys/class/net, but BRIDGE-MIB only. - The Rust
agentxcrate implements RFC 2741 PDUs and encodings but has no connection or session handling.src/session.rsprovides that layer. drbd-reactoris a complete subagent built on that crate and is the reference implementation to crib the session loop from.- Also worth reading:
snmp_rust_agent,sunt.
- AgentX supports multiple simultaneous subagents. Source-verified in net-snmp's
agent/mibgroup/agentx/master.c(master_sessionslinked list, per-sessionsessid). Overlapping regions resolve by registration priority (default 127, lower wins); an exact duplicate region at equal priority is rejected withduplicateRegistration. net-snmp does not own1.3.6.1.2.1.31.1.2, so there is no conflict with the running master or with lldpd. /var/agentxisdrwx------ root rootand the socket issrwxr-xr-x root root. lldpd connects because its monitor process runs as root. An unprivileged subagent cannot traverse into/var/agentx, so either run as root or setagentXPermsinsnmpd.conf.- The master must reconnect cleanly. snmpd is restarted by configuration
management, so the session loop needs to survive
AgentX master disconnected usand re-register without supervision.
With master agentx in snmpd.conf and the service running, walk the table
locally:
snmpwalk -v2c -c public localhost 1.3.6.1.2.1.31.1.2Rows appear as IF-MIB::ifStackStatus.<higher>.<lower> = INTEGER: active(1).
Compare them against the host's own topology:
ip -details -json link showA bond or bridge yields one row per member, with the master's ifIndex first. A VLAN yields one row with the VLAN's ifIndex first and its base interface second. Every interface also yields the two zero-index boundary rows RFC 2863 requires.