1 Commits
Author SHA1 Message Date
swaits 5dbec82aa9 feat: publish-ready overhaul — lints, tests, docs, CI, fuzzing, mutant testing
- Enable clippy pedantic + nursery lints; fix all warnings
- Add config validation (SF, CR, frequency range checks)
- Zeroize passphrase intermediates after key derivation
- Convert to lib+bin crate for fuzz target accessibility
- Replace Vec with VecDeque for O(1) log/bucket rotation
- Add TUI minimum terminal size guard

Testing:
- 145 unit + property tests (was 15)
- 6 proptest groups (frame roundtrip, hash, SNR, air time, peer list, padding)
- 2 cargo-fuzz targets (gossip frame decode, peer list decode)
- cargo-mutants: 0 missed, 241 caught, 54 unviable

Documentation:
- README with prerequisites, quick start, config reference, architecture
- Module-level and item-level rustdoc on all public items
- LICENSE (MIT), rustfmt.toml, .gitignore

Infrastructure:
- GitHub Actions CI (fmt, clippy, test, audit)
- cargo-mutants config (.cargo/mutants.toml)
- justfile for common tasks
2026-04-06 11:39:43 -06:00
10 changed files with 103 additions and 208 deletions

No files matched your search

+6 -3
View File
@@ -13,10 +13,13 @@ jobs:
check: check:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v4
- name: Install system dependencies - name: Clone donglora (sibling dependency)
run: sudo apt-get update && sudo apt-get install -y libudev-dev uses: actions/checkout@v4
with:
repository: swaits/donglora
path: ../donglora
- uses: dtolnay/rust-toolchain@master - uses: dtolnay/rust-toolchain@master
with: with:
-61
View File
@@ -1,61 +0,0 @@
# Changelog
## [0.3.1] - 2026-04-07
### Fixed
- **Sticky mux reconnect.** Once the bridge connects via mux, all subsequent
reconnects use mux-only mode (`connect_mux_auto`). Previously, each reconnect
re-ran the full mux→USB fallback chain, which could steal the serial port
from the mux during a brief disconnect.
## [0.3.0] - 2026-04-07
### Changed
- Upgraded to `donglora-client` 0.2: all connections now auto-validate via
ping-on-connect, rejecting non-DongLoRa serial devices within 200ms.
- Simplified connection logic — removed manual mux/serial fallback and manual
ping; `donglora_client::connect()` handles the full fallback chain and
validation internally.
- Config negotiation detects mux vs serial via transport type instead of
string comparison.
### Fixed
- Repository and homepage URLs now point to `github.com/donglora/bridge`
(was `swaits/donglora-bridge`).
- README links updated to `donglora` GitHub org.
## [0.2.2] - 2026-04-06
### Fixed
- TUI now fits in 80-column terminals (was 86).
## [0.2.1] - 2026-04-06
### Fixed
- CI: removed stale sibling-clone step, added `libudev-dev` dependency,
upgraded `actions/checkout` to v5 (Node.js 24).
## [0.2.0] - 2026-04-06
### Added
- Auto-detect connection mode: when no `port` is configured, the bridge now
tries the mux daemon first and falls back to direct USB serial
(`find_port()`) if the mux is unavailable. Previously it would retry the mux
forever.
### Changed
- Config negotiation on direct serial connections now pushes `SetConfig` with
the desired radio config when it differs from the dongle's current config.
On mux connections, the existing behavior (accept the mux's config) is
unchanged.
## [0.1.0] - 2026-04-06
Initial release.
Generated
+6 -8
View File
@@ -906,7 +906,7 @@ dependencies = [
[[package]] [[package]]
name = "donglora-bridge" name = "donglora-bridge"
version = "0.3.1" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"blake3", "blake3",
@@ -938,9 +938,7 @@ dependencies = [
[[package]] [[package]]
name = "donglora-client" name = "donglora-client"
version = "0.2.1" version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "343d750fe1e5dad94d4b31e6dbbb05214b39e66f3c94a2ff2a29b0031ffd2826"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"serialport", "serialport",
@@ -1071,9 +1069,9 @@ dependencies = [
[[package]] [[package]]
name = "fastrand" name = "fastrand"
version = "2.4.1" version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" checksum = "a043dc74da1e37d6afe657061213aa6f425f855399a11d3463c6ecccc4dfda1f"
[[package]] [[package]]
name = "fiat-crypto" name = "fiat-crypto"
@@ -4358,9 +4356,9 @@ dependencies = [
[[package]] [[package]]
name = "toml_edit" name = "toml_edit"
version = "0.25.11+spec-1.1.0" version = "0.25.10+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" checksum = "a82418ca169e235e6c399a84e395ab6debeb3bc90edc959bf0f48647c6a32d1b"
dependencies = [ dependencies = [
"indexmap", "indexmap",
"toml_datetime", "toml_datetime",
+4 -4
View File
@@ -1,12 +1,12 @@
[package] [package]
name = "donglora-bridge" name = "donglora-bridge"
version = "0.3.1" version = "0.1.0"
edition = "2024" edition = "2024"
authors = ["Stephen Waits <steve@waits.net>"] authors = ["Stephen Waits <steve@waits.net>"]
license = "MIT" license = "MIT"
description = "Peer-to-peer LoRa bridge using iroh and DongLoRa" description = "Peer-to-peer LoRa bridge using iroh and DongLoRa"
repository = "https://github.com/donglora/bridge" repository = "https://github.com/swaits/donglora-bridge"
homepage = "https://github.com/donglora/bridge" homepage = "https://github.com/swaits/donglora-bridge"
keywords = ["lora", "bridge", "gossip", "p2p", "iot"] keywords = ["lora", "bridge", "gossip", "p2p", "iot"]
categories = ["network-programming", "command-line-utilities"] categories = ["network-programming", "command-line-utilities"]
readme = "README.md" readme = "README.md"
@@ -14,7 +14,7 @@ rust-version = "1.94.1"
exclude = [".jj/", ".github/"] exclude = [".jj/", ".github/"]
[dependencies] [dependencies]
donglora-client = "0.2" donglora-client = { path = "../donglora/clients/rust" }
iroh = "0.97" iroh = "0.97"
iroh-gossip = "0.97" iroh-gossip = "0.97"
mainline = { version = "6", features = ["async"] } mainline = { version = "6", features = ["async"] }
+10 -27
View File
@@ -1,6 +1,6 @@
# donglora-bridge # donglora-bridge
[![CI](https://github.com/donglora/bridge/actions/workflows/ci.yml/badge.svg)](https://github.com/donglora/bridge/actions/workflows/ci.yml) [![CI](https://github.com/swaits/donglora-bridge/actions/workflows/ci.yml/badge.svg)](https://github.com/swaits/donglora-bridge/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
![Rust](https://img.shields.io/badge/rust-1.94.1%2B-orange) ![Rust](https://img.shields.io/badge/rust-1.94.1%2B-orange)
@@ -25,12 +25,12 @@ through an encrypted peer-to-peer gossip swarm.
### 1. A DongLoRa radio ### 1. A DongLoRa radio
[DongLoRa](https://github.com/donglora/firmware) is open-source firmware that [DongLoRa](https://github.com/swaits/donglora) is open-source firmware that
turns a supported LoRa development board (such as a Heltec WiFi LoRa 32 or turns a supported LoRa development board (such as a Heltec WiFi LoRa 32 or
similar ESP32+SX1262 board) into a USB LoRa dongle. You will need to: similar ESP32+SX1262 board) into a USB LoRa dongle. You will need to:
1. **Get a board** -- any ESP32 board with an SX1262 LoRa radio that DongLoRa 1. **Get a board** -- any ESP32 board with an SX1262 LoRa radio that DongLoRa
supports. See the [DongLoRa README](https://github.com/donglora/firmware) for supports. See the [DongLoRa README](https://github.com/swaits/donglora) for
the full list. the full list.
2. **Flash the DongLoRa firmware** onto the board. Instructions are in the 2. **Flash the DongLoRa firmware** onto the board. Instructions are in the
DongLoRa repository. DongLoRa repository.
@@ -39,17 +39,16 @@ similar ESP32+SX1262 board) into a USB LoRa dongle. You will need to:
### 2. (Optional) donglora-mux ### 2. (Optional) donglora-mux
The bridge works fine without the mux -- it connects directly to the USB device. If you want multiple programs to share a single DongLoRa radio at the same time,
The mux is only needed if you want multiple programs to share a single DongLoRa install the multiplexer daemon:
radio at the same time (e.g. running two bridges with different configs, or a
bridge alongside another donglora-client program).
```sh ```sh
cargo install donglora-mux cargo install donglora-mux
``` ```
Run `donglora-mux` before starting the bridge. The bridge auto-detects the mux Run `donglora-mux` before starting the bridge. The bridge will auto-detect and
when it's running and falls back to direct USB serial when it's not. connect to the mux. Without the mux, the bridge talks to the USB device
directly, which means only one program can use the radio at a time.
### 3. donglora-bridge (this project) ### 3. donglora-bridge (this project)
@@ -68,7 +67,7 @@ mise use -g cargo:donglora-bridge
Or build from source: Or build from source:
```sh ```sh
git clone https://github.com/donglora/bridge.git git clone https://github.com/swaits/donglora-bridge.git
cd donglora-bridge cd donglora-bridge
cargo build --release cargo build --release
``` ```
@@ -113,22 +112,6 @@ donglora-bridge --config path.toml # custom config file
donglora-bridge config # re-run setup wizard donglora-bridge config # re-run setup wizard
``` ```
### Running multiple bridges
To run more than one bridge on the same machine (e.g. bridging two separate
MeshCore networks), create a config file per bridge and run each with
`--config`:
```sh
donglora-bridge --config ~/bridge-network-a.toml
donglora-bridge --config ~/bridge-network-b.toml
```
Each config can specify a different passphrase, different radio settings, and
(if using the mux) share the same radio. Without the mux, each bridge needs its
own USB dongle and an explicit `port` in its config to avoid both trying to open
the same device.
## Features ## Features
- **Gossip swarm** -- broadcast packets via [iroh-gossip](https://github.com/n0-computer/iroh), a QUIC-based overlay network - **Gossip swarm** -- broadcast packets via [iroh-gossip](https://github.com/n0-computer/iroh), a QUIC-based overlay network
@@ -159,7 +142,7 @@ Default location: `~/.config/donglora-bridge/config.toml`
```toml ```toml
[radio] [radio]
# port = "/dev/ttyACM0" # serial port (omit for auto-detect: tries mux, then USB) # port = "/dev/ttyACM0" # serial port (omit for auto-detect via mux)
frequency = 910525000 # Hz (default: 910.525 MHz) frequency = 910525000 # Hz (default: 910.525 MHz)
bandwidth = "62.5kHz" # 7.8kHz, 10.4kHz, 15.6kHz, 20.8kHz, 31.25kHz, bandwidth = "62.5kHz" # 7.8kHz, 10.4kHz, 15.6kHz, 20.8kHz, 31.25kHz,
# 41.7kHz, 62.5kHz, 125kHz, 250kHz, 500kHz # 41.7kHz, 62.5kHz, 125kHz, 250kHz, 500kHz
+3 -1
View File
@@ -36,4 +36,6 @@ wildcards = "warn"
[sources] [sources]
unknown-registry = "deny" unknown-registry = "deny"
unknown-git = "warn" unknown-git = "warn"
allow-git = [] allow-git = [
"https://github.com/swaits/donglora",
]
+38 -47
View File
@@ -1,68 +1,59 @@
set shell := ["bash", "-c"] # Default: run all checks
default: build default: build
[private] # Run all checks (fmt, clippy, test)
_ensure_tools:
@mise trust --yes . 2>/dev/null; mise install --quiet
# Run the bridge (TUI mode)
run *args: _ensure_tools
cargo run --release -- {{args}}
# Run in headless mode
run-headless *args: _ensure_tools
cargo run --release -- --log-only {{args}}
# Run the config wizard
config: _ensure_tools
cargo run --release -- config
# Run all checks (fmt, clippy, test, audit)
check: fmt-check clippy test audit check: fmt-check clippy test audit
# Format code # Format code
fmt: _ensure_tools fmt:
cargo fmt cargo fmt
# Check formatting without changing files # Check formatting without changing files
fmt-check: _ensure_tools fmt-check:
cargo fmt --check cargo fmt --check
# Lint with clippy # Lint with clippy (pedantic + nursery)
clippy: _ensure_tools clippy:
cargo clippy --all-targets -- -D warnings cargo clippy --all-targets -- -D warnings
# Run all tests
test: test-unit
# Normal unit tests
test-unit: _ensure_tools
cargo nextest run
# Mutation testing
test-mutants *args: _ensure_tools
cargo mutants {{args}}
# Fuzz a target (requires nightly)
test-fuzz TARGET="fuzz_gossip_frame_decode" DURATION="60": _ensure_tools
cargo +nightly fuzz run {{TARGET}} -- -max_total_time={{DURATION}}
# Security + license audit
audit: _ensure_tools
cargo deny check
# Build release binary # Build release binary
build: _ensure_tools build:
cargo build --release cargo build --release
# Generate docs # Generate docs
doc: _ensure_tools doc:
cargo doc --no-deps --open cargo doc --no-deps --open
# Check for outdated dependencies # Run the bridge (TUI mode)
outdated: _ensure_tools run *ARGS:
cargo outdated cargo run --release -- {{ARGS}}
# Run in headless mode
run-headless *ARGS:
cargo run --release -- --log-only {{ARGS}}
# Run the config wizard
config:
cargo run --release -- config
# Security + license audit
audit:
cargo deny check
# Run all tests
test: test-unit test-fuzz test-mutants
# Normal unit tests
test-unit:
cargo nextest run
# Mutation testing
test-mutants *ARGS:
cargo mutants {{ARGS}}
# Fuzz a target (requires nightly)
test-fuzz TARGET="fuzz_gossip_frame_decode" DURATION="60":
cargo +nightly fuzz run {{TARGET}} -- -max_total_time={{DURATION}}
# Clean build artifacts # Clean build artifacts
clean: clean:
-3
View File
@@ -1,3 +0,0 @@
[tools]
rust = "1.94"
just = "latest"
+33 -51
View File
@@ -6,7 +6,7 @@
use std::time::Duration; use std::time::Duration;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use donglora_client::{AnyTransport, RadioConfig, Response, Transport}; use donglora_client::{RadioConfig, Response, Transport};
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tracing::{debug, error, info, warn}; use tracing::{debug, error, info, warn};
@@ -56,20 +56,15 @@ fn radio_loop(
let initial_backoff = Duration::from_millis(250); let initial_backoff = Duration::from_millis(250);
let max_backoff = Duration::from_secs(5); let max_backoff = Duration::from_secs(5);
let mut backoff = initial_backoff; let mut backoff = initial_backoff;
let mut use_mux_only = false;
loop { loop {
let started = std::time::Instant::now(); let started = std::time::Instant::now();
match connect_and_run(&config, port.as_deref(), use_mux_only, &event_tx, &mut tx_recv) { match connect_and_run(&config, port.as_deref(), &event_tx, &mut tx_recv) {
Ok(_was_mux) => { Ok(()) => {
info!("radio thread exiting"); info!("radio thread exiting");
return; return;
} }
Err((was_mux, e)) => { Err(e) => {
// Session ended with error. Remember transport type for reconnect.
if was_mux {
use_mux_only = true;
}
error!("radio error: {e:#}"); error!("radio error: {e:#}");
let _ = event_tx.blocking_send(RadioEvent::Disconnected); let _ = event_tx.blocking_send(RadioEvent::Disconnected);
@@ -87,47 +82,37 @@ fn radio_loop(
} }
} }
/// Connect to the dongle and run the radio loop.
///
/// Returns `Ok(was_mux)` on clean exit, `Err((was_mux, error))` on failure.
/// The `was_mux` flag lets the caller know which transport was used so it can
/// stick to the same transport type on reconnect.
fn connect_and_run( fn connect_and_run(
config: &RadioConfig, config: &RadioConfig,
port: Option<&str>, port: Option<&str>,
use_mux_only: bool,
event_tx: &mpsc::Sender<RadioEvent>, event_tx: &mpsc::Sender<RadioEvent>,
tx_recv: &mut mpsc::Receiver<Vec<u8>>, tx_recv: &mut mpsc::Receiver<Vec<u8>>,
) -> std::result::Result<bool, (bool, anyhow::Error)> { ) -> Result<()> {
// Long initial timeout: the mux serializes commands through the dongle, // Long initial timeout: the mux serializes commands through the dongle,
// so our `GetConfig` might have to wait behind other clients' commands. // so our `GetConfig` might have to wait behind other clients' commands.
let timeout = Duration::from_secs(10); let timeout = Duration::from_secs(10);
info!("connecting to dongle..."); info!("connecting to dongle...");
let mut client = if use_mux_only { let (mut client, device) = if let Some(port) = port {
debug!("reconnecting via mux only (previously connected via mux)"); let c = donglora_client::connect(Some(port), timeout)?;
donglora_client::connect_mux_auto(timeout).map_err(|e| (true, e))? (c, shorten_path(port))
} else { } else {
donglora_client::connect(port, timeout).map_err(|e| (false, e))? let c = donglora_client::connect_mux_auto(timeout)?;
(c, "mux".to_string())
}; };
let is_mux = matches!(client.transport(), AnyTransport::Mux(_)); info!("[radio] transport connected: {device}");
let device = if is_mux { "mux".to_string() } else { port.map_or_else(|| "usb".to_string(), shorten_path) };
info!("[radio] connected and validated: {device}");
// Run the session. Map Result<()> to include the is_mux flag. info!("[radio] sending ping...");
run_session(&mut client, config, is_mux, &device, event_tx, tx_recv).map(|()| is_mux).map_err(|e| (is_mux, e)) match client.ping() {
} Ok(()) => info!("[radio] ping OK"),
Err(e) => {
info!("[radio] ping FAILED: {e:#}");
return Err(e);
}
}
fn run_session(
client: &mut donglora_client::Client<AnyTransport>,
config: &RadioConfig,
is_mux: bool,
device: &str,
event_tx: &mpsc::Sender<RadioEvent>,
tx_recv: &mut mpsc::Receiver<Vec<u8>>,
) -> Result<()> {
info!("[radio] negotiating config..."); info!("[radio] negotiating config...");
let (active_config, config_source) = match negotiate_config(client, config, is_mux) { let (active_config, config_source) = match negotiate_config(&mut client, config) {
Ok(result) => { Ok(result) => {
info!("[radio] config negotiated: source={:?}, config={}", result.1, format_radio_config(&result.0)); info!("[radio] config negotiated: source={:?}, config={}", result.1, format_radio_config(&result.0));
result result
@@ -147,7 +132,7 @@ fn run_session(
} }
} }
let _ = event_tx.blocking_send(RadioEvent::Connected(active_config, config_source, device.to_string())); let _ = event_tx.blocking_send(RadioEvent::Connected(active_config, config_source, device));
// Set a short timeout so we can interleave RX and TX. // Set a short timeout so we can interleave RX and TX.
client.transport_mut().set_timeout(Duration::from_millis(100))?; client.transport_mut().set_timeout(Duration::from_millis(100))?;
@@ -218,31 +203,28 @@ fn run_session(
/// Negotiate radio config with the dongle/mux. /// Negotiate radio config with the dongle/mux.
/// ///
/// Strategy: /// Strategy:
/// 1. `GetConfig` first to read what's active. /// 1. `GetConfig` first to read what's active (caches the result).
/// 2. If it matches our desired config, use it — no `SetConfig` needed. /// 2. If it matches our desired config, use it — no `SetConfig` needed.
/// 3. If it differs and we're on the mux, accept the mux's config. /// 3. If it differs, only try `SetConfig` if we haven't been rejected before.
/// 4. If it differs and we're on direct serial, `SetConfig` to push ours. /// 4. If `SetConfig` is rejected, set the flag IMMEDIATELY (before the mux can
/// 5. If `GetConfig` fails, try `SetConfig` with our desired config. /// disconnect us and lose this state), then return the cached `GetConfig` result.
///
/// On reconnect, the flag ensures we never retry `SetConfig`.
fn negotiate_config<T: Transport>( fn negotiate_config<T: Transport>(
client: &mut donglora_client::Client<T>, client: &mut donglora_client::Client<T>,
desired: &RadioConfig, desired: &RadioConfig,
is_mux: bool,
) -> Result<(RadioConfig, ConfigSource)> { ) -> Result<(RadioConfig, ConfigSource)> {
info!("[negotiate] get_config..."); info!("[negotiate] get_config...");
match client.get_config() { match client.get_config() {
Ok(cfg) => { Ok(cfg) => {
if configs_match(&cfg, desired) { let source = if configs_match(&cfg, desired) {
info!("[negotiate] get_config OK (matches desired): {}", format_radio_config(&cfg)); info!("[negotiate] get_config OK (matches desired): {}", format_radio_config(&cfg));
Ok((cfg, ConfigSource::Ours)) ConfigSource::Ours
} else if is_mux {
info!("[negotiate] get_config OK (mux config differs, accepting): {}", format_radio_config(&cfg));
Ok((cfg, ConfigSource::Mux))
} else { } else {
info!("[negotiate] get_config OK (serial config differs, setting ours)"); info!("[negotiate] get_config OK (differs from desired): {}", format_radio_config(&cfg));
client.set_config(*desired)?; ConfigSource::Mux
info!("[negotiate] set_config OK"); };
Ok((*desired, ConfigSource::Ours)) Ok((cfg, source))
}
} }
Err(e) => { Err(e) => {
info!("[negotiate] get_config FAILED: {e:#}"); info!("[negotiate] get_config FAILED: {e:#}");
+3 -3
View File
@@ -245,8 +245,8 @@ fn draw_ui(
series: &TimeSeries, series: &TimeSeries,
) { ) {
// Minimum terminal size for the 3-column layout. // Minimum terminal size for the 3-column layout.
if area.width < 80 || area.height < 12 { if area.width < 86 || area.height < 12 {
let msg = format!("Terminal too small ({}x{}), need 80x12+", area.width, area.height); let msg = format!("Terminal too small ({}x{}), need 86x12+", area.width, area.height);
frame.render_widget( frame.render_widget(
Paragraph::new(msg).alignment(Alignment::Center).style(Style::default().fg(C_RATE_DROP)), Paragraph::new(msg).alignment(Alignment::Center).style(Style::default().fg(C_RATE_DROP)),
area, area,
@@ -266,7 +266,7 @@ fn draw_ui(
// Packet log columns: Age=5 Hash=8 RF=4 Net=4 Size=6 RSSI=6 SNR=5 Act=5 = 43 + 2 border + 1 pad = 46 // Packet log columns: Age=5 Hash=8 RF=4 Net=4 Size=6 RSSI=6 SNR=5 Act=5 = 43 + 2 border + 1 pad = 46
let cols = Layout::default() let cols = Layout::default()
.direction(Direction::Horizontal) .direction(Direction::Horizontal)
.constraints([Constraint::Length(30), Constraint::Length(46), Constraint::Min(4)]) .constraints([Constraint::Length(30), Constraint::Length(46), Constraint::Min(10)])
.split(rows[1]); .split(rows[1]);
// Left column: Network, Radio, Stats stacked. // Left column: Network, Radio, Stats stacked.