A Rust implementation of the Dynamic Link Exchange Protocol (DLEP, RFC 8175), covering both the router and modem sides from a single workspace.
DLEP is an event-driven protocol that lets a router obtain timely link-state and link-quality information from a co-located modem (typically a wireless or radio modem) over a single Layer-2 segment. Discovery happens over UDP multicast; the session itself is a long-lived TCP (or TLS) connection over which the modem reports destinations, advertises and updates per-destination metrics (data rate, latency, link quality, MTU, …) and exchanges heartbeats.
Status: core implementation with remaining conformance gaps. Wire codec, both state machines, TCP + TLS (mutual TLS supported) transport, UDP multicast discovery with GTSM, destinations & metrics,
Session Update,Destination Announce, andLink Characteristics Request/Response, the extension plug-in API, and deployable CLI binaries with reconnect-on-drop. See §9 ofdoc/architecture.mdfor the milestone log and remaining follow-ups, anddoc/deployment.mdfor deployment.The modem binary integrates a radio through an external adapter process that reports destinations and answers Link Characteristics Requests; embedders can register a link-control backend directly. Busy commands return explicit acceptance errors.
- Implement both sides of the protocol from a single tree, so end-to-end tests can run on loopback.
- Expose the daemon as a library (
dlep-daemon) so a third party can embed DLEP into their own networking stack without taking the bundled binaries. - Keep wire format and state machines fully tested in isolation (no I/O), so logic bugs are caught without spinning up sockets.
- Provide a stable plug-in API for DLEP extensions (RFC 8175 §13.6 reserves a Private Use range for them).
- Support TLS as a first-class transport, per the RFC's security guidance.
crates/
├── dlep-core wire types, data items, byte-level codec
├── dlep-fsm state machines (no I/O, no tokio)
├── dlep-net transport: UDP multicast, TCP, TLS, framing
├── dlep-ext extension plug-in trait + registry
├── dlep-daemon integration layer + public library API
├── dlep-router router-side daemon binary
└── dlep-modem modem-side daemon binary
The dependency DAG is acyclic; dlep-core is the leaf and has no internal
dependencies. See doc/architecture.md for per-crate
responsibilities and the design rationale.
Requires Rust 1.85 or newer (edition 2024). The daemons and transport currently require Linux; the core, FSM, and extension API are portable.
cargo build --workspace --locked
cargo clippy --workspace --all-targets --locked -- -D warningsRun network tests using the isolated namespace command below. CI also checks Rust 1.85, the portable crates on macOS, coverage, workflow syntax, and dependency advisories. See CI documentation for job scope, local commands, coverage artifacts, and the guarded audit exception.
Version 0.2.0 provides a combined launcher and separate role-specific CLIs:
cargo install dlep --version 0.2.0 --locked
dlep router --help
dlep modem --help
# Alternatively:
cargo install dlep-router dlep-modem --version 0.2.0 --lockedFor a source checkout, cargo build --release --workspace --locked builds all
three commands. dlep router and dlep modem run the same implementations as
the standalone binaries, with identical flags. The published 0.1.0 dlep
command was a placeholder; see release notes and the
release validation guide.
Provision certificates and install the binaries with the capabilities described in the deployment guide before using the example configs. Both binaries take a TOML configuration file:
dlep-router --config /etc/dlep/router.toml
dlep-modem --config /etc/dlep/modem.tomlCLI flags shared by both binaries:
| Flag | Purpose |
|---|---|
--config |
Path to the TOML config file. |
--interface |
Select the discovery interface (overrides config). |
--log-level |
Override the DLEP_LOG filter (info, debug, …). |
--no-tls |
Force plain TCP regardless of config. |
--cert, --key |
Override the TLS identity files. |
--ca-bundle |
Override the trusted CA bundle. |
--check-config |
Validate configuration and TLS material, then exit. |
--help / -h |
Show options without starting networking. |
--version / -V |
Show the installed version. |
Logging precedence is --log-level, then DLEP_LOG, then info; RUST_LOG
is not read. --config also accepts -c and the role-specific
DLEP_ROUTER_CONFIG / DLEP_MODEM_CONFIG environment variable; an explicit flag
wins. Router-only --peer ADDR is repeatable and selects static connections.
See the deployment guide for details.
A minimal plaintext development configuration for either role (select an interface present on your host):
[network]
interface = "eth0"
discovery_v4_group = "224.0.0.117"
discovery_v6_group = "ff02::1:7"
discovery_port = 854
use_tls = false
gtsm_enforce = true
[timers]
heartbeat_interval_ms = 60000
discovery_interval_ms = 5000The full schema (network, TLS, timers, plus router/modem-specific keys) is
documented in §6 of doc/architecture.md.
Unknown keys and sections are rejected, including at the top level. Run
--check-config after editing. Heartbeat and discovery intervals must be at
least 1000 ms; initialization and termination timeouts must be positive.
Daemon builders enforce the same timer limits for programmatic configuration.
The modem binds UDP and TCP port 854 and normally needs
CAP_NET_BIND_SERVICE. The router uses ephemeral source ports and does not need that capability. Both roles needCAP_NET_RAWfor strict TCP GTSM; the supplied systemd units grant the capabilities for each role.
use std::sync::Arc;
use dlep_daemon::{DaemonEvent, RouterDaemon, RouterConfig};
use dlep_ext::DlepExtension;
let daemon = RouterDaemon::builder()
.config(RouterConfig::default())
.register_extension(my_extension as Arc<dyn DlepExtension>)
.with_rustls_client(my_client_tls_config)
.spawn()
.await?;
let mut events = daemon.subscribe(); // broadcast::Receiver<DaemonEvent>
daemon.start_discovery().await?;
while let Ok(event) = events.recv().await {
match event {
DaemonEvent::PeerDiscovered(offer) => {
daemon.connect_discovered(&offer).await?;
}
_ => { /* react to SessionUp, Destination { .. }, Metrics { .. }, etc. */ }
}
}
daemon.shutdown().await?;This setup sketch omits application shutdown and event-loss recovery. Discovery
only reports offers; the application chooses when to connect. The router binary
adds concurrent scheduling and reconnection using the retained connection-state
feed described below. Library applications must supply their own policy and
handle broadcast::error::RecvError::Lagged if they need to continue after event
loss.
The modem-side API is symmetric, with
add_destination / update_destination / drop_destination in place of
start_discovery / connect_static.
ModemDaemon::update_session_metrics sends session-wide metric changes via
Session Update (RFC 8175 §12.7). The router compatibility method returns an
error: routers may report Layer 3 changes but cannot originate metric items.
announce_destination is router-side only. The modem denies destinations it
does not know; successful responses create destinations at the router.
Set ModemConfig.metrics (the TOML [metrics] section) to the initial
session-wide values. Rates use bits/second and latency_us uses microseconds.
The mandatory rates and latency default to zero; configure them for the actual
link before deployment. Optional resources, rlq_rx, rlq_tx, and mtu
default to unsupported and are omitted from the wire. Only configure optional
metrics that the modem can supply. Percentages must be 0–100; configured current
rates must not exceed their corresponding maximum rates. Invalid configuration
fails --check-config and daemon startup.
API change: the four optional LinkMetrics fields now use Option:
write mtu: Some(1500) to supply a value. Some(0) is an explicit zero.
For destination creation, None inherits the session value; for updates, it
preserves the existing value. Events expose effective values, with None for
unsupported metrics. Support is fixed at session initialization: the modem API
rejects attempts to introduce undeclared metrics, and the router terminates a
peer that sends them. Session metric updates apply supplied values to all
existing destinations and the defaults for future destinations in that session.
Layer 3 changes use AddressChanges { added, removed }; each side contains a
DestinationAddrs set with IPv4/IPv6 addresses and attached subnets.
ModemDaemon::add_destination_with_addresses supplies the initial destination
snapshot, and update_destination_addresses sends later changes. Both daemons
provide update_session_addresses(session_id, changes) for local peer addresses
on one session, using address-only Session Update messages.
Applications receive DestinationEvent::AddressesChanged or
DaemonEvent::SessionAddresses, with effective changes and the resulting full
snapshot, plus peer/session attribution. Initialization addresses and Announce
Response addresses are retained. DestinationEvent::Announced now also carries
requested_addresses; existing exhaustive patterns need that field or ...
Address-only updates preserve metrics. Destination changes made before Up is
acknowledged are coalesced; changes made while a router has withdrawn interest
are retained for a later Announce. Session address commands return an explicit
Busy error when another session transaction is in progress.
RouterDaemon::drop_destination(session_id, destination) withdraws interest
from one modem. The modem acknowledges with Destination Down Response, stops
reporting that destination on this session, and emits a DestinationEvent::Down.
The router emits its own Down event when the response arrives. The session stays
active; a later Destination Announce can restore reports using the modem's
latest local metrics and addresses. Busy/unknown destinations and stale session
IDs return explicit errors as described below.
RouterDaemon::request_link_characteristics(session_id, destination, requested)
requests rate or latency changes from one modem. LinkCharacteristics has
optional receive rate, transmit rate, and latency fields; supply at least one.
Listen for DestinationEvent::LinkCharacteristicsResponse to obtain the status,
status text, and current metrics. A response must include every core metric the
peer declared during initialization. Requests remain pending until a response
arrives or the session resets; RFC 8175 §8 does not permit independent
transaction deadlines. Peer silence is detected by the session heartbeat
mechanism. The former [timers].link_characteristics_timeout_ms setting has
been removed and is rejected if present in a configuration file.
On the modem, ModemBuilder::link_control(backend) registers a
dlep_daemon::LinkControl backend that decides each request. It receives a
LinkRequest (session, peer, destination, requested values, and current
metrics) and returns a LinkResponse: granted(metrics) for an applied change
or denied() when it cannot be met, optionally with_text(..). The future may
run as long as the radio needs; the request holds that destination's transaction
slot, so the router's further requests and the modem's drop_destination for it
return Busy until the answer. Destination updates remain available. Granted
metrics merge into the destination like update_destination and are reported
only on the requesting session. Undeclared metrics, session-terminating
statuses, and backend panics produce Request Denied with current metrics. The
backend future is dropped if the session ends first. A closure works:
let modem = ModemDaemon::builder()
.config(config)
.link_control(Arc::new(|request: LinkRequest| async move {
match apply_to_radio(&request).await {
Ok(metrics) => LinkResponse::granted(metrics),
Err(reason) => LinkResponse::denied().with_text(reason.to_string()),
}
}))
.spawn()
.await?;Without a backend, the modem returns Request Denied with current destination
metrics, keeping the session alive. The dlep-modem binary uses its
radio adapter as the backend when [radio] is
configured and denies requests otherwise. Either way, the modem application receives
DestinationEvent::LinkCharacteristicsRequested for each request.
Command methods now await local acceptance by the session task. Ok(()) means
the command was applied, sent, or retained as a pending Up update; peer responses
still arrive as protocol events. DaemonError::CommandRejected(report) contains
accepted session IDs and per-session rejections (Busy, NotReady,
UnknownDestination, AlreadyExists, Unsupported, or InvalidInput).
NoMatchingSession covers stale IDs and an empty session list. FSM rejections
also produce DaemonEvent::CommandRejected with the command kind, destination,
and peer/session context.
Busy commands are not queued or replayed. Retry after the outstanding response,
using application-controlled backoff when no completion event is available.
Broadcasts can partially succeed: inspect the report and use
send_command_to(session_id, SessionCommand::...) on either daemon to retry
only the rejected sessions. undelivered counts channels closed before enqueue;
unknown counts sessions that ended without an acceptance receipt, where the
command may already have taken effect. Canceling an API future after enqueue
also does not cancel the protocol operation. Shutdown bypasses busy checks.
For direct run_session users, command channels now carry SessionRequest;
SessionCommand::... .into() constructs a request without a receipt.
Discovery events carry a PeerOffer containing ordered connection points.
Use RouterDaemon::connect_discovered(&offer) to try compatible endpoints;
TLS-required configurations never fall back to plaintext. Discovery continues
while sessions are active, allowing additional modems to be found. A modem
silently ignores discovery from a router address with an accepted TCP
connection, including during TLS and DLEP initialization. Offers resume after
the last connection from that address closes.
The termination-response timeout defaults to four local heartbeat intervals
(240 seconds with the default heartbeat). Omit [timers].termination_timeout_ms
to retain this automatic behavior, or set a positive explicit override.
For Rust callers, TimersConfig::termination_timeout_ms and
SessionConfig::termination_timeout now use Option: None selects the
automatic deadline, and Some(...) supplies an override.
The router binary retries dropped sessions with exponential backoff (1 second,
doubling to a 30-second cap). TCP connection success preserves that retry
history while DLEP initialization is pending; only SessionUp resets it.
Repeated discovery offers for a queued endpoint do not bypass the delay.
Static startup, discovered modems, and retries share at most eight concurrent
TCP/TLS connection attempts. Slow peers leave other connections and event
handling responsive. Unavailable static peers stay queued for retry; queued
peers wait when all slots are occupied, and discovery can retry on later offers.
Reconnect decisions use RouterDaemon::connection_states(), a retained state
feed independent of the lossy public event broadcast. Missing SessionUp or
SessionDown events therefore cannot prevent reconnection or backoff reset
while a peer remains retained. Router limits default to 64 connections/sessions,
256 discovered endpoints, and 300 seconds of inactive discovery history. Static
peers stay eligible for retries; expired/evicted discovered peers require a fresh
offer or explicit connection request. See router limits
for configuration and snapshot-consumer requirements.
[network].interface / --interface selects discovery group membership,
multicast egress, unicast reply source, and accepted ingress interface. For
example, interface = "eth1" with bind_addr = "0.0.0.0" discovers peers on
eth1 while the modem's TCP listener still binds all addresses. A specific IPv4
bind_addr must belong to the selected interface and serves as the preferred
source address. With several addresses and no preference, the lowest IPv4
address is selected. Without an interface name, a specific IPv4 bind_addr
selects its interface; a wildcard lets the routing table choose.
The interface must exist, be up, and have an address in the selected family
plus multicast support (loopback is also supported for IPv4 testing). --check-config and startup validate an explicit
name against the current host. Failure to create discovery on a named interface
fails startup instead of silently disabling discovery. The setting does not bind
TCP connections to a device.
The bind_addr family selects one discovery transport per daemon. For IPv6,
set bind_addr = "::" and interface = "eth1" on both peers; this uses
discovery_v6_group (default ff02::1:7). A wildcard IPv6 bind requires an
interface because multicast needs a link scope. A concrete IPv6 address can
identify its interface without a name, provided it belongs to only one link.
With no preferred address, discovery prefers a link-local address on that
interface. Wildcard TCP listeners advertise that unicast address; discovered
link-local connection points retain their interface scope for TCP. IPv4
remains the default. Simultaneous discovery over both families is not enabled.
DiscoverySocket::recv_with_local now returns IpAddr for the local address.
SessionUp, SessionDown, Destination, and Metrics events carry a
session_id; destination and metric events also carry peer and event
fields. Consumers of the earlier tuple variants must update their matches:
DaemonEvent::Destination { session_id, peer, event }. Metric event values are
effective merged values, including defaults received during initialization.
On Linux, TCP sends use TTL/hop limit 255, including the connection handshake.
With gtsm_enforce = true, the kernel filters lower-TTL traffic and a packet
monitor immediately resets the affected connection. This requires CAP_NET_RAW;
missing privileges fail explicitly. The supplied systemd units grant it.
Setting gtsm_enforce = false disables TCP receive enforcement for development;
outbound TTL remains 255 and discovery still checks inbound TTL before decoding.
Malformed discovery packets are discarded without a per-packet retry delay.
The network tests exercise strict enforcement. On Linux, build first, then run
in an isolated network namespace (requires unprivileged user namespaces and
iproute2):
cargo build --workspace --all-targets --locked
unshare --user --map-root-user bash .github/scripts/test-network.sh \
cargo test --workspace --locked --offlineThis grants capabilities only inside the temporary namespace, without changing binary capabilities or the host network. See the CI workflow for the privileged namespace alternative on systems that restrict user namespaces.
The standalone daemons handle SIGTERM (systemctl stop) and SIGINT (Ctrl-C)
with a graceful DLEP Session Termination exchange. Pending connection attempts
and incomplete TLS handshakes are cancelled during shutdown.
doc/architecture.md— full architecture document: per-crate responsibilities, design decisions, configuration schema, public API shape, testing strategy, milestone status and open questions. Read this before contributing.doc/deployment.md— PKI, privileges, firewall, and systemd deployment.doc/radio-adapter.md— the JSON Lines protocol betweendlep-modemand an external radio adapter.- RFC 8175 — the protocol specification.
MIT — see LICENSE.