Skip to content
asmiePublic

About

DLEP daemon Rust implementation

Resources

Stars

3 stars

Watchers

0 watching

Forks

Repository files navigation

dlep

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, and Link Characteristics Request/Response, the extension plug-in API, and deployable CLI binaries with reconnect-on-drop. See §9 of doc/architecture.md for the milestone log and remaining follow-ups, and doc/deployment.md for 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.

Goals

  • 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.

Workspace layout

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.

Build

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 warnings

Run 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.

Run

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 --locked

For 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.toml

CLI 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 = 5000

The 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 need CAP_NET_RAW for strict TCP GTSM; the supplied systemd units grant the capabilities for each role.

Library use

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.

Network tests

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 --offline

This 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.

Shutdown

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.

Documentation

  • 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 between dlep-modem and an external radio adapter.
  • RFC 8175 — the protocol specification.

License

MIT — see LICENSE.

About

DLEP daemon Rust implementation

Resources

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages