5 Commits
Author SHA1 Message Date
swaits c2f6894ef5 docs: clarify that bridge relays raw LoRa packets, protocol-agnostic 2026-04-06 17:11:46 -06:00
swaits 943f2615f3 fix: reduce TUI minimum width from 86 to 80 columns 2026-04-06 17:08:56 -06:00
swaits ae284d571a fix: remove stale sibling-clone CI step (donglora-client is on crates.io) 2026-04-06 17:05:22 -06:00
swaits ca26368136 feat: auto-detect connection — fall back to direct USB serial when mux is unavailable 2026-04-06 16:17:39 -06:00
swaits 6bae65c464 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 14:52:18 -06:00
8 changed files with 109 additions and 44 deletions

No files matched your search

+3 -6
View File
@@ -13,13 +13,10 @@ jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Clone donglora (sibling dependency)
uses: actions/checkout@v4
with:
repository: swaits/donglora
path: ../donglora
- name: Install system dependencies
run: sudo apt-get update && sudo apt-get install -y libudev-dev
- uses: dtolnay/rust-toolchain@master
with:
+41
View File
@@ -0,0 +1,41 @@
# Changelog
## [0.2.3] - 2026-04-06
### Changed
- README: clarify that the bridge relays raw LoRa packets and works with any
protocol (Meshtastic, MeshCore, etc.) simultaneously.
## [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
+3 -1
View File
@@ -906,7 +906,7 @@ dependencies = [
[[package]]
name = "donglora-bridge"
version = "0.1.0"
version = "0.2.2"
dependencies = [
"anyhow",
"blake3",
@@ -939,6 +939,8 @@ dependencies = [
[[package]]
name = "donglora-client"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "162468289ac189a798c4ecd961ed92c5dde2d9c5709d779ae324e0bb9a5ac4dc"
dependencies = [
"anyhow",
"serialport",
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "donglora-bridge"
version = "0.1.0"
version = "0.2.3"
edition = "2024"
authors = ["Stephen Waits <steve@waits.net>"]
license = "MIT"
@@ -14,7 +14,7 @@ rust-version = "1.94.1"
exclude = [".jj/", ".github/"]
[dependencies]
donglora-client = { path = "../donglora/clients/rust" }
donglora-client = "0.1"
iroh = "0.97"
iroh-gossip = "0.97"
mainline = { version = "6", features = ["async"] }
+34 -14
View File
@@ -4,16 +4,19 @@
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
![Rust](https://img.shields.io/badge/rust-1.94.1%2B-orange)
**donglora-bridge** connects LoRa radios to the internet so packets heard by one
radio can be retransmitted by another radio anywhere in the world.
**donglora-bridge** relays raw LoRa packets over the internet. Every packet your
radio hears is forwarded to every other bridge running the same passphrase, and
their radios retransmit it locally. Because it operates on raw packets, it works
with any protocol built on LoRa --
[Meshtastic](https://meshtastic.org/),
[MeshCore](https://github.com/rocketgithub/meshcore-firmware),
or anything else -- simultaneously and without configuration. If your radio can
hear it, the bridge will relay it.
[LoRa](https://en.wikipedia.org/wiki/LoRa) is a long-range, low-power wireless
protocol used by off-grid mesh networks like
[MeshCore](https://github.com/rocketgithub/meshcore-firmware). A single LoRa
radio might cover a few kilometers. This bridge removes that limit: any packet
your radio hears gets relayed over the internet to every other bridge running the
same passphrase, and their radios retransmit it locally. The result is a single
mesh network across a larger geographic area.
modulation used by off-grid mesh networks. A single radio might cover a few
kilometers. This bridge removes that limit, extending a local LoRa network
across a larger geographic area.
There is no central server. Bridges discover each other automatically using the
[Mainline DHT](https://en.wikipedia.org/wiki/Mainline_DHT) and communicate
@@ -39,16 +42,17 @@ similar ESP32+SX1262 board) into a USB LoRa dongle. You will need to:
### 2. (Optional) donglora-mux
If you want multiple programs to share a single DongLoRa radio at the same time,
install the multiplexer daemon:
The bridge works fine without the mux -- it connects directly to the USB device.
The mux is only needed if you want multiple programs to share a single DongLoRa
radio at the same time (e.g. running two bridges with different configs, or a
bridge alongside another donglora-client program).
```sh
cargo install donglora-mux
```
Run `donglora-mux` before starting the bridge. The bridge will auto-detect and
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.
Run `donglora-mux` before starting the bridge. The bridge auto-detects the mux
when it's running and falls back to direct USB serial when it's not.
### 3. donglora-bridge (this project)
@@ -112,6 +116,22 @@ donglora-bridge --config path.toml # custom config file
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
- **Gossip swarm** -- broadcast packets via [iroh-gossip](https://github.com/n0-computer/iroh), a QUIC-based overlay network
@@ -142,7 +162,7 @@ Default location: `~/.config/donglora-bridge/config.toml`
```toml
[radio]
# port = "/dev/ttyACM0" # serial port (omit for auto-detect via mux)
# port = "/dev/ttyACM0" # serial port (omit for auto-detect: tries mux, then USB)
frequency = 910525000 # Hz (default: 910.525 MHz)
bandwidth = "62.5kHz" # 7.8kHz, 10.4kHz, 15.6kHz, 20.8kHz, 31.25kHz,
# 41.7kHz, 62.5kHz, 125kHz, 250kHz, 500kHz
+1 -3
View File
@@ -36,6 +36,4 @@ wildcards = "warn"
[sources]
unknown-registry = "deny"
unknown-git = "warn"
allow-git = [
"https://github.com/swaits/donglora",
]
allow-git = []
+22 -15
View File
@@ -96,9 +96,13 @@ fn connect_and_run(
let (mut client, device) = if let Some(port) = port {
let c = donglora_client::connect(Some(port), timeout)?;
(c, shorten_path(port))
} else {
let c = donglora_client::connect_mux_auto(timeout)?;
} else if let Ok(c) = donglora_client::connect_mux_auto(timeout) {
(c, "mux".to_string())
} else {
let port_path = donglora_client::find_port().ok_or_else(|| anyhow::anyhow!("no mux or USB dongle found"))?;
info!("no mux found, connecting to serial port {port_path}");
let c = donglora_client::connect(Some(&port_path), timeout)?;
(c, shorten_path(&port_path))
};
info!("[radio] transport connected: {device}");
@@ -112,7 +116,7 @@ fn connect_and_run(
}
info!("[radio] negotiating config...");
let (active_config, config_source) = match negotiate_config(&mut client, config) {
let (active_config, config_source) = match negotiate_config(&mut client, config, &device) {
Ok(result) => {
info!("[radio] config negotiated: source={:?}, config={}", result.1, format_radio_config(&result.0));
result
@@ -203,28 +207,31 @@ fn connect_and_run(
/// Negotiate radio config with the dongle/mux.
///
/// Strategy:
/// 1. `GetConfig` first to read what's active (caches the result).
/// 1. `GetConfig` first to read what's active.
/// 2. If it matches our desired config, use it — no `SetConfig` needed.
/// 3. If it differs, only try `SetConfig` if we haven't been rejected before.
/// 4. If `SetConfig` is rejected, set the flag IMMEDIATELY (before the mux can
/// disconnect us and lose this state), then return the cached `GetConfig` result.
///
/// On reconnect, the flag ensures we never retry `SetConfig`.
/// 3. If it differs and we're on the mux, accept the mux's config.
/// 4. If it differs and we're on direct serial, `SetConfig` to push ours.
/// 5. If `GetConfig` fails, try `SetConfig` with our desired config.
fn negotiate_config<T: Transport>(
client: &mut donglora_client::Client<T>,
desired: &RadioConfig,
device: &str,
) -> Result<(RadioConfig, ConfigSource)> {
info!("[negotiate] get_config...");
match client.get_config() {
Ok(cfg) => {
let source = if configs_match(&cfg, desired) {
if configs_match(&cfg, desired) {
info!("[negotiate] get_config OK (matches desired): {}", format_radio_config(&cfg));
ConfigSource::Ours
Ok((cfg, ConfigSource::Ours))
} else if device == "mux" {
info!("[negotiate] get_config OK (mux config differs, accepting): {}", format_radio_config(&cfg));
Ok((cfg, ConfigSource::Mux))
} else {
info!("[negotiate] get_config OK (differs from desired): {}", format_radio_config(&cfg));
ConfigSource::Mux
};
Ok((cfg, source))
info!("[negotiate] get_config OK (serial config differs, setting ours)");
client.set_config(*desired)?;
info!("[negotiate] set_config OK");
Ok((*desired, ConfigSource::Ours))
}
}
Err(e) => {
info!("[negotiate] get_config FAILED: {e:#}");
+3 -3
View File
@@ -245,8 +245,8 @@ fn draw_ui(
series: &TimeSeries,
) {
// Minimum terminal size for the 3-column layout.
if area.width < 86 || area.height < 12 {
let msg = format!("Terminal too small ({}x{}), need 86x12+", area.width, area.height);
if area.width < 80 || area.height < 12 {
let msg = format!("Terminal too small ({}x{}), need 80x12+", area.width, area.height);
frame.render_widget(
Paragraph::new(msg).alignment(Alignment::Center).style(Style::default().fg(C_RATE_DROP)),
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
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(30), Constraint::Length(46), Constraint::Min(10)])
.constraints([Constraint::Length(30), Constraint::Length(46), Constraint::Min(4)])
.split(rows[1]);
// Left column: Network, Radio, Stats stacked.