rlldpctl
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 = connect?;
for iface in client.interfaces?
Run it live against a real daemon with:
An async equivalent, AsyncClient/AsyncSubscription (backed by
tokio::net::UnixStream), is available behind the tokio feature:
= { = "0.1", = ["tokio"] }
let mut client = connect.await?;
for iface in client.interfaces.await?
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.
Why this exists, and why it's fragile
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_previousadded after1.0.22Net effect: this crate is verified against
lldpd1.0.21 and 1.0.22 only (matching this workspace's Yocto-packaged1.0.22) - not "any 1.0.x", and not yet whatever ships after1.0.22. 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. -
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.
Scope
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.
Testing
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.
License
MIT