# rlldpctl
[](https://github.com/sntns/rlldpctl/actions/workflows/ci.yml)
[](https://crates.io/crates/rlldpctl)
[](https://docs.rs/rlldpctl)
[](LICENSE)
A pure-Rust client for [`lldpd`](https://lldpd.github.io/)'s control socket:
list interfaces and read discovered LLDP neighbors without shelling out to
`lldpcli` or linking `liblldpctl.so`.
```rust
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:
```sh
cargo run --example show_neighbors [socket-path]
cargo run --example watch_neighbors [socket-path] # live feed via SUBSCRIBE
```
An async equivalent, `AsyncClient`/`AsyncSubscription` (backed by
`tokio::net::UnixStream`), is available behind the `tokio` feature:
```toml
rlldpctl = { version = "0.1", features = ["tokio"] }
```
```rust
let mut client = rlldpctl::AsyncClient::connect().await?;
for iface in client.interfaces().await? {
let details = client.interface(&iface.name).await?;
// ...
}
```
```sh
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.
## 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 `lldpd` version window - narrower than you'd expect.** Diffing
`src/lldpd-structs.h` across every tag from `0.9.0` (2008-ish) to `master`
shows 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.14` | no `SET_CHASSIS` in the `hmsg_type` enum -> `GET_INTERFACE` is `5`, not `6` |
| `< 1.0.20` | no `lldpd_port.p_vlan_advertise_pattern` |
| `< 1.0.21` | no `lldpd_interface.alias` / `lldpd_hardware.h_ifalias` |
| `master` | unreleased `lldpd_hardware.h_flags_previous` added after `1.0.22` |
Net effect: **this crate is verified against `lldpd` 1.0.21 and 1.0.22
only** (matching this workspace's Yocto-packaged `1.0.22`) - not "any
1.0.x", and not yet whatever ships after `1.0.22`. Sending our hardcoded
`GET_INTERFACE = 6` to anything older than `1.0.14` doesn't even fail
loudly: the daemon just answers a different request (`GET_DEFAULT_PORT`)
instead. A struct layout mismatch below `1.0.21` would desync parsing the
same way.
- **The build-time feature flags `lldpd` was compiled with.** Several struct
fields only exist under `ENABLE_DOT1` / `ENABLE_DOT3` / `ENABLE_LLDPMED` /
`ENABLE_CUSTOM`. This crate assumes `dot1`, `dot3`, `cdp`, `fdp` and
`lldpmed` on and `custom` off, matching this workspace's `lldpd` recipe
(`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 target `lldpd` runs on) and
`time_t` tracking 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`](https://github.com/lldpd/lldpd/blob/master/src/lib/lldpctl.h)
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`; or `AsyncClient::subscribe` +
`AsyncSubscription::next_change` behind the `tokio` feature). Subscribing
consumes the client: the protocol doesn't allow more
`GET_INTERFACES`/`GET_INTERFACE` calls 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. `AsyncSubscription` is a
plain `async fn next_change`, not a `futures::Stream` - wrap it with
`futures::stream::unfold` yourself if you want one.
## Testing
- `src/wire/decode.rs` has 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 live `lldpd`) covering interface lists, a neighbor
with a chassis and a management address, and the chassis-deduplication
path (two neighbor ports sharing one chassis pointer, as `lldpd` does for
a device seen via more than one discovery protocol).
- `src/wire/decode.rs` also covers `NOTIFICATION` decoding: a deleted-neighbor
event with no attached port data, and an added-neighbor event with a full
chassis.
- `src/subscription.rs` unit-tests `Subscription`'s `Iterator` impl against a
socket pair: a clean peer close ends iteration (`None`), while a
well-framed-but-undecodable message surfaces as `Some(Err(_))` without
ending it.
- `tests/integration.rs` drives the public `Client` API over a real Unix
socket against a hand-rolled fake `lldpd`, independently of the crate's
own encoder.
- `src/async_client.rs` mirrors the sync transport tests (`request`,
rejection, clean-close) with `#[tokio::test]`, and
`tests/async_integration.rs` mirrors the black-box socket test - both
compile to nothing without `--features tokio`.
- `examples/show_neighbors.rs`/`examples/watch_neighbors.rs` and their
`_async` counterparts talk to a real, running `lldpd` - not run in CI, but
useful for a manual sanity check on real hardware.
```sh
cargo test
cargo test --features tokio
cargo clippy --all-targets -- -D warnings
cargo clippy --all-targets --features tokio -- -D warnings
cargo fmt -- --check
```
## License
MIT