A pure-Rust client for lldpd's control socket:
list interfaces and read discovered LLDP neighbors without shelling out to
lldpcli or linking liblldpctl.so.
let mut client = rlldpctl::Client::connect()?;
for iface in client.interfaces()? {
let details = client.interface(&iface.name)?;
for neighbor in &details.neighbors {
println!(
"{}: {} ({:?})",
iface.name,
neighbor.chassis.name.as_deref().unwrap_or("?"),
neighbor.port_id_str(),
);
}
}Run it live against a real daemon with:
cargo run --example show_neighbors [socket-path]
cargo run --example watch_neighbors [socket-path] # live feed via SUBSCRIBEAn async equivalent, AsyncClient/AsyncSubscription (backed by
tokio::net::UnixStream), is available behind the tokio feature:
rlldpctl = { version = "0.1", features = ["tokio"] }let mut client = rlldpctl::AsyncClient::connect().await?;
for iface in client.interfaces().await? {
let details = client.interface(&iface.name).await?;
// ...
}cargo run --example show_neighbors_async --features tokio [socket-path]
cargo run --example watch_neighbors_async --features tokio [socket-path]The sync API has no tokio dependency at all with the feature off - this is
purely additive, and both share the exact same encode/decode logic
(src/wire), only the socket I/O differs.
lldpd does not define a stable IPC protocol for lldpcli/liblldpctl to
talk to it with. What's actually on the wire is lldpd's own internal C
structures (struct lldpd_port, struct lldpd_chassis, ...), memcpy'd with
a small pointer-graph-following envelope around them (src/marshal.c and
src/ctl.c upstream). This crate reimplements that envelope and mirrors the
relevant structs directly (src/wire/raw.rs), rather than linking
liblldpctl.so or shelling out to lldpcli.
That means it's coupled to, and only known to work with:
-
A narrow
lldpdversion window - narrower than you'd expect. Diffingsrc/lldpd-structs.hacross every tag from0.9.0(2008-ish) tomastershows the framing and marshaling mechanism has been untouched that whole time, but several fields this crate actually reads are recent additions:Version Change < 1.0.14no SET_CHASSISin thehmsg_typeenum ->GET_INTERFACEis5, not6< 1.0.20no lldpd_port.p_vlan_advertise_pattern< 1.0.21no lldpd_interface.alias/lldpd_hardware.h_ifaliasmasterunreleased lldpd_hardware.h_flags_previous, inserted after1.0.22Net effect: this crate is verified against
lldpd1.0.21, 1.0.22, and currentmaster(matching this workspace's Yocto-packaged1.0.22, plus tracking upstream ahead of its next tag) - not "any 1.0.x". Sending our hardcodedGET_INTERFACE = 6to anything older than1.0.14doesn't even fail loudly: the daemon just answers a different request (GET_DEFAULT_PORT) instead. A struct layout mismatch below1.0.21would desync parsing the same way. "Verified" here is not just a claim: CI builds reallldpd1.0.21, 1.0.22, andmasterfrom source and runs this crate against them (tests/real_lldpd.rs, see Testing). Theh_flags_previousrow above is exactly what thatmasterleg caught in practice -decode_hardwarenow tries both the pre- and post-h_flags_previouslayouts and keeps whichever one actually parses (src/wire/raw.rs'sRawHardwarevsRawHardwareWithFlagsPrevious), since there's no version negotiation on the wire to tell it which one a given daemon uses. That's a real fix for a real drift, not a guarantee against every future one: ifmasterchanges shape again before its next tag, that CI leg will fail again until this crate is updated to match. -
The build-time feature flags
lldpdwas compiled with. Several struct fields only exist underENABLE_DOT1/ENABLE_DOT3/ENABLE_LLDPMED/ENABLE_CUSTOM. This crate assumesdot1,dot3,cdp,fdpandlldpmedon andcustomoff, matching this workspace'slldpdrecipe (PACKAGECONFIG ??= "cdp fdp edp sonmp lldpmed dot1 dot3"). -
The host's C ABI - pointer width (handled automatically via
#[repr(C)], as long as you build for the same targetlldpdruns on) andtime_ttracking pointer width (true of the traditional glibc/musl Linux ABI on 32- and 64-bit; not true of a 32-bit "time64" C library).
In short: don't expect this to survive an lldpd upgrade unverified, and
don't expect it to work talking to some other machine's lldpd over a
different architecture. This is the same trade-off upstream itself accepts
between lldpd and liblldpctl.so internally - just without linking that
library. If you need a protocol that doesn't have this problem, you want the
real liblldpctl
C API, or shelling out to lldpcli -f json.
v1 implements four requests: GET_INTERFACES (list interfaces),
GET_INTERFACE (one interface's local info + discovered neighbors), and
SUBSCRIBE/NOTIFICATION (a live feed of neighbor changes via
Client::subscribe, which returns an Iterator<Item = Result<NeighborChange>>
- see
examples/watch_neighbors.rs; orAsyncClient::subscribe+AsyncSubscription::next_changebehind thetokiofeature). Subscribing consumes the client: the protocol doesn't allow moreGET_INTERFACES/GET_INTERFACEcalls on a connection once it starts pushing notifications, so open a second client if you need both. Chassis name/description/capabilities/management addresses and LLDP-MED inventory TLVs are decoded; per-port VLAN/PPVID/PI TLVs are walked (for correct byte accounting) but not yet surfaced in the model. Nothing that changes daemon state (SET_PORT,SET_CONFIG, ...) is implemented.AsyncSubscriptionis a plainasync fn next_change, not afutures::Stream- wrap it withfutures::stream::unfoldyourself if you want one.
-
src/wire/decode.rshas unit tests that hand-build wire messages (using#[repr(C)]reinterpretation of the same raw structs the decoder reads, so they don't require a livelldpd) covering interface lists, a neighbor with a chassis and a management address, and the chassis-deduplication path (two neighbor ports sharing one chassis pointer, aslldpddoes for a device seen via more than one discovery protocol). -
src/wire/decode.rsalso coversNOTIFICATIONdecoding: a deleted-neighbor event with no attached port data, and an added-neighbor event with a full chassis. -
src/subscription.rsunit-testsSubscription'sIteratorimpl against a socket pair: a clean peer close ends iteration (None), while a well-framed-but-undecodable message surfaces asSome(Err(_))without ending it. -
tests/integration.rsdrives the publicClientAPI over a real Unix socket against a hand-rolled fakelldpd, independently of the crate's own encoder. -
src/async_client.rsmirrors the sync transport tests (request, rejection, clean-close) with#[tokio::test], andtests/async_integration.rsmirrors the black-box socket test - both compile to nothing without--features tokio. -
examples/show_neighbors.rs/examples/watch_neighbors.rsand their_asynccounterparts talk to a real, runninglldpd- not run in CI, but useful for a manual sanity check on real hardware. -
tests/real_lldpd.rsis the one exception to "no livelldpdrequired": it runs a reallldpd, not a fake one, and is what backs the version support claim above. CI (.github/workflows/ci.yml'sreal-lldpdjob) buildslldpdfrom source at1.0.21,1.0.22, andmaster(tests/real_lldpd/Dockerfile, using upstream's own build recipe), starts two independent instances talking over a real veth pair (tests/real_lldpd/entrypoint.sh- not a Docker bridge network, which would silently drop LLDP's reserved multicast destination), and checks that this crate'sClient/AsyncClientdecode each side's neighbor data to match the peer's real MAC address read straight from the kernel. This test file no-ops outside that container (no root, no reallldpd, no veth needed for a plain localcargo test). To run it yourself:docker build --build-arg LLDPD_REF=1.0.22 -t rlldpctl-real-lldpd:1.0.22 \ -f tests/real_lldpd/Dockerfile . docker run --rm --cap-add=NET_ADMIN --cap-add=NET_RAW rlldpctl-real-lldpd:1.0.22Swap
LLDPD_REFfor1.0.21ormasterto check another version.
cargo test
cargo test --features tokio
cargo clippy --all-targets -- -D warnings
cargo clippy --all-targets --features tokio -- -D warnings
cargo fmt -- --checkMIT