4 Commits
Author SHA1 Message Date
swaits f867383b12 fix: exclusive serial port lock prevents USB port theft
The mux now acquires an flock-based exclusive lock on the serial port
before opening it. This prevents bridges or other processes from directly
opening the same device while the mux owns it. The lock is held for the
duration of the dongle session and auto-released on disconnect or crash.
2026-04-07 18:04:52 -06:00
swaits 903729ccbe feat: upgrade to donglora-client 0.2, fix org URLs, publish-ready
- Upgrade donglora-client from 0.1 (monorepo path dep) to 0.2 (crates.io)
- Import wire-level constants from donglora_client::protocol::*
- Fix repository URL to github.com/donglora/mux-rs
- Add homepage, rust-version, exclude to Cargo.toml
- Fix README (remove monorepo paths, add install instructions)
- Allow clippy unwrap in test module
2026-04-07 16:14:50 -06:00
swaits 2b7bdb5628 chore: add consistent mise.toml, justfile, and tooling config 2026-04-07 14:41:26 -06:00
swaits 3871772d9b WIP: functional checkpoint! 2026-04-06 21:15:35 -06:00
12 changed files with 201 additions and 135 deletions

No files matched your search

+23
View File
@@ -1,5 +1,28 @@
# Changelog
## 0.2.1 — 2026-04-07
### Fixed
- **Exclusive serial port lock.** The mux now acquires an `flock`-based
exclusive lock on the serial port before opening it. This prevents other
processes (bridges, other mux instances) from directly opening the same
device while the mux owns it. The lock is held for the duration of the
dongle session and auto-released on disconnect or crash.
## 0.2.0 — 2026-04-07
### Changed
- Upgraded to `donglora-client` 0.2 (published crate, no more monorepo path dep).
- Wire-level protocol constants now imported from `donglora_client::protocol::*`.
### Fixed
- Repository URL now points to `github.com/donglora/mux-rs`.
- README links updated (removed stale monorepo paths).
- Added `rust-version`, `homepage`, and `exclude` to Cargo.toml.
## 0.1.1 — 2026-04-06
### Fixed
Generated
+4 -2
View File
@@ -161,7 +161,9 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "donglora-client"
version = "0.1.0"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "343d750fe1e5dad94d4b31e6dbbb05214b39e66f3c94a2ff2a29b0031ffd2826"
dependencies = [
"anyhow",
"serialport",
@@ -171,7 +173,7 @@ dependencies = [
[[package]]
name = "donglora-mux"
version = "0.1.1"
version = "0.2.1"
dependencies = [
"anyhow",
"clap",
+6 -3
View File
@@ -1,16 +1,19 @@
[package]
name = "donglora-mux"
version = "0.1.1"
version = "0.2.1"
edition = "2024"
rust-version = "1.85"
authors = ["Stephen Waits <steve@waits.net>"]
license = "MIT"
description = "DongLoRa USB Multiplexer — share one dongle with multiple applications"
repository = "https://github.com/swaits/donglora"
repository = "https://github.com/donglora/mux-rs"
homepage = "https://github.com/donglora/mux-rs"
keywords = ["lora", "radio", "usb", "multiplexer"]
categories = ["hardware-support"]
exclude = [".jj/", "deny.toml", "justfile", "mise.toml", "rustfmt.toml"]
[dependencies]
donglora-client = { version = "0.1.0", path = "../../clients/rust" }
donglora-client = "0.2"
anyhow = "1"
clap = { version = "4", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
+11 -5
View File
@@ -3,6 +3,12 @@
USB multiplexer daemon — lets multiple applications share one DongLoRa
dongle simultaneously.
## Install
```sh
cargo install donglora-mux
```
## What It Does
- Owns the USB serial connection exclusively
@@ -18,12 +24,12 @@ dongle simultaneously.
## Running
```sh
just run # start the mux daemon
just verbose # start with verbose logging
just run -- --tcp 5741 --port /dev/ttyACM0 # with options
donglora-mux # start the mux daemon
donglora-mux --verbose # start with verbose logging
donglora-mux --tcp 5741 --port /dev/ttyACM0 # with options
```
## Depends On
- [clients/rust](../../clients/rust/) — the `donglora-client` library
(protocol types, COBS framing, device discovery)
- [`donglora-client`](https://crates.io/crates/donglora-client) — protocol
types, COBS framing, device discovery
+34
View File
@@ -0,0 +1,34 @@
# cargo-deny configuration (v0.19+)
# Run with: cargo deny check
[licenses]
version = 2
# Permissive licenses only. No copyleft.
allow = [
"MIT",
"MIT-0",
"Apache-2.0",
"BSD-1-Clause",
"BSD-2-Clause",
"BSD-3-Clause",
"BSL-1.0",
"ISC",
"Zlib",
"Unlicense",
"CC0-1.0",
"CDLA-Permissive-2.0",
"Unicode-3.0",
"MPL-2.0",
]
[advisories]
[bans]
multiple-versions = "warn"
wildcards = "warn"
[sources]
unknown-registry = "deny"
unknown-git = "warn"
allow-git = []
+37 -6
View File
@@ -2,18 +2,49 @@ set shell := ["bash", "-c"]
default: run
[private]
_ensure_tools:
@mise trust --yes . 2>/dev/null; mise install --quiet
# Run the mux daemon
run *args:
run *args: _ensure_tools
cargo run -- {{args}}
# Run with verbose logging
verbose *args:
verbose *args: _ensure_tools
cargo run -- --verbose {{args}}
# Run clippy and tests
check:
cargo clippy -- -D warnings && cargo test
# Run all checks (fmt, clippy, test, audit)
check: fmt-check clippy test audit
# Format code
fmt: _ensure_tools
cargo fmt
# Check formatting without changing files
fmt-check: _ensure_tools
cargo fmt --check
# Lint with clippy
clippy: _ensure_tools
cargo clippy --all-targets -- -D warnings
# Run tests
test: _ensure_tools
cargo test
# Security + license audit
audit: _ensure_tools
cargo deny check
# Build release
build:
build: _ensure_tools
cargo build --release
# Check for outdated dependencies
outdated: _ensure_tools
cargo outdated
# Clean build artifacts
clean:
cargo clean
+3
View File
@@ -0,0 +1,3 @@
[tools]
rust = "1.94"
just = "latest"
+3
View File
@@ -0,0 +1,3 @@
edition = "2024"
max_width = 120
use_small_heuristics = "Max"
+55 -62
View File
@@ -9,13 +9,13 @@ use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use donglora_client::{
encode_frame, FrameReader, CMD_TAG_SET_CONFIG, CMD_TAG_START_RX, CMD_TAG_STOP_RX,
RADIO_CONFIG_SIZE, RESP_TAG_OK, RESP_TAG_RX_PACKET,
use donglora_client::protocol::{
CMD_TAG_SET_CONFIG, CMD_TAG_START_RX, CMD_TAG_STOP_RX, RADIO_CONFIG_SIZE, RESP_TAG_OK, RESP_TAG_RX_PACKET,
};
use donglora_client::{FrameReader, encode_frame};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, UnixListener};
use tokio::sync::{mpsc, oneshot, Mutex};
use tokio::sync::{Mutex, mpsc, oneshot};
/// A queued command: (client_id, raw_cmd_bytes).
type QueuedCmd = (u64, Vec<u8>);
@@ -63,28 +63,20 @@ impl MuxDaemon {
tcp_addr: Option<(String, u16)>,
shutdown: CancellationToken,
) -> Self {
Self {
serial_port,
socket_path,
tcp_addr,
shutdown,
}
Self { serial_port, socket_path, tcp_addr, shutdown }
}
/// Run the mux daemon. Returns when shutdown is signalled.
pub async fn run(&self) -> anyhow::Result<()> {
let state: SharedState = Arc::new(Mutex::new(MuxInner {
sessions: HashMap::new(),
mux_state: MuxState::new(),
}));
let state: SharedState =
Arc::new(Mutex::new(MuxInner { sessions: HashMap::new(), mux_state: MuxState::new() }));
// Command channel: client handlers → dongle writer
let (cmd_tx, cmd_rx) = mpsc::channel::<(u64, Vec<u8>)>(64);
let cmd_rx = Arc::new(Mutex::new(cmd_rx));
// Pending response slot: dongle writer installs, dongle reader resolves
let pending_response: Arc<Mutex<Option<oneshot::Sender<Vec<u8>>>>> =
Arc::new(Mutex::new(None));
let pending_response: Arc<Mutex<Option<oneshot::Sender<Vec<u8>>>>> = Arc::new(Mutex::new(None));
// Prepare socket directory
let sock_path = Path::new(&self.socket_path);
@@ -134,24 +126,13 @@ impl MuxDaemon {
};
// Spawn client accept tasks
tokio::spawn(accept_unix_clients(
unix_listener,
state.clone(),
cmd_tx.clone(),
self.shutdown.clone(),
));
tokio::spawn(accept_unix_clients(unix_listener, state.clone(), cmd_tx.clone(), self.shutdown.clone()));
if let Some(listener) = tcp_listener {
tokio::spawn(accept_tcp_clients(
listener,
state.clone(),
cmd_tx.clone(),
self.shutdown.clone(),
));
tokio::spawn(accept_tcp_clients(listener, state.clone(), cmd_tx.clone(), self.shutdown.clone()));
}
// Connect/reconnect loop
self.reconnect_loop(state, cmd_tx, cmd_rx, pending_response)
.await;
self.reconnect_loop(state, cmd_tx, cmd_rx, pending_response).await;
// Cleanup
info!("shutting down...");
@@ -175,12 +156,12 @@ impl MuxDaemon {
}
info!("opening dongle on {}", self.serial_port);
let serial = loop {
let (serial, _serial_lock_guard) = loop {
if self.shutdown.is_cancelled() {
return;
}
match open_and_ping(&self.serial_port).await {
Ok(s) => break s,
Ok(pair) => break pair,
Err(e) => {
debug!("could not open dongle: {e}");
tokio::select! {
@@ -243,10 +224,42 @@ impl MuxDaemon {
// ── Dongle I/O ─────────────────────────────────────────────────────
async fn open_and_ping(port: &str) -> anyhow::Result<tokio_serial::SerialStream> {
/// Lock file path for exclusive serial port access.
fn serial_lock_path(port: &str) -> String {
format!("/tmp/donglora-serial-{}.lock", port.replace('/', "_"))
}
/// Open the serial port with an exclusive lock and verify the dongle responds to ping.
///
/// Returns the serial stream and the lock. The caller must keep `_serial_lock` alive
/// for the duration of the dongle session. Dropping it releases the exclusive flock,
/// allowing other processes to claim the port.
async fn open_and_ping(
port: &str,
) -> anyhow::Result<(tokio_serial::SerialStream, fd_lock::RwLock<std::fs::File>)> {
// Acquire exclusive flock before opening the serial port. This prevents bridges
// or other processes from directly opening the same device while the mux owns it.
let lock_path = serial_lock_path(port);
let lock_file = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(false)
.open(&lock_path)
.map_err(|e| anyhow::anyhow!("failed to open serial lock file {lock_path}: {e}"))?;
let mut lock = fd_lock::RwLock::new(lock_file);
// Acquire exclusive flock (non-blocking). Fails if another process holds it.
let guard = lock
.try_write()
.map_err(|_| anyhow::anyhow!("serial port {port} is locked by another process"))?;
// Forget the guard so its Drop doesn't call flock(UNLOCK). The underlying flock
// stays held as long as the file descriptor (owned by `lock`) remains open.
// When `lock` is dropped at the end of the dongle session, the fd closes and
// the OS releases the flock automatically.
std::mem::forget(guard);
let builder = tokio_serial::new(port, 115_200).timeout(Duration::from_secs(2));
let mut serial = tokio_serial::SerialStream::open(&builder)
.map_err(|e| anyhow::anyhow!("failed to open {port}: {e}"))?;
let mut serial =
tokio_serial::SerialStream::open(&builder).map_err(|e| anyhow::anyhow!("failed to open {port}: {e}"))?;
let ping_frame = encode_frame(&[0]);
AsyncWriteExt::write_all(&mut serial, &ping_frame)
@@ -279,7 +292,7 @@ async fn open_and_ping(port: &str) -> anyhow::Result<tokio_serial::SerialStream>
for frame in reader.feed(&buf[..n]) {
if frame.first() == Some(&0) {
info!("dongle responded to Ping");
return Ok(serial);
return Ok((serial, lock));
}
}
}
@@ -360,9 +373,7 @@ async fn dispatch_frame(
}
}
async fn resolve_pending_with_error(
pending_response: &Arc<Mutex<Option<oneshot::Sender<Vec<u8>>>>>,
) {
async fn resolve_pending_with_error(pending_response: &Arc<Mutex<Option<oneshot::Sender<Vec<u8>>>>>) {
let mut slot = pending_response.lock().await;
if let Some(sender) = slot.take() {
let _ = sender.send(vec![5, 1]); // Error(RadioBusy)
@@ -397,12 +408,7 @@ async fn dongle_writer(
let intercepted = {
let mut inner = state.lock().await;
let locked = inner.mux_state.locked_config;
intercept::maybe_intercept(
&raw_cmd,
client_id,
&mut inner.sessions,
&locked,
)
intercept::maybe_intercept(&raw_cmd, client_id, &mut inner.sessions, &locked)
};
if let Some(synthetic) = intercepted {
let inner = state.lock().await;
@@ -607,27 +613,16 @@ async fn client_writer<W: AsyncWriteExt + Unpin>(
() = shutdown.cancelled() => return,
};
if AsyncWriteExt::write_all(&mut write_half, &frame)
.await
.is_err()
{
if AsyncWriteExt::write_all(&mut write_half, &frame).await.is_err() {
return;
}
}
}
async fn remove_client(
client_id: u64,
state: &SharedState,
label: &str,
cmd_tx: &mpsc::Sender<(u64, Vec<u8>)>,
) {
async fn remove_client(client_id: u64, state: &SharedState, label: &str, cmd_tx: &mpsc::Sender<(u64, Vec<u8>)>) {
let mut inner = state.lock().await;
let was_interested = inner
.sessions
.get(&client_id)
.is_some_and(|s| s.rx_interested);
let was_interested = inner.sessions.get(&client_id).is_some_and(|s| s.rx_interested);
inner.sessions.remove(&client_id);
info!("{label} disconnected ({} remain)", inner.sessions.len());
@@ -637,9 +632,7 @@ async fn remove_client(
info!("last RX-interested client gone — sending StopRx");
// Drop the lock before sending to avoid deadlock
drop(inner);
let _ = cmd_tx
.send((SYNTHETIC_CLIENT_ID, vec![CMD_TAG_STOP_RX]))
.await;
let _ = cmd_tx.send((SYNTHETIC_CLIENT_ID, vec![CMD_TAG_STOP_RX])).await;
return;
}
+12 -34
View File
@@ -6,9 +6,9 @@
use std::collections::HashMap;
use donglora_client::{
CMD_TAG_SET_CONFIG, CMD_TAG_START_RX, CMD_TAG_STOP_RX, ERROR_INVALID_CONFIG, RADIO_CONFIG_SIZE,
RESP_TAG_ERROR, RESP_TAG_OK,
use donglora_client::protocol::{
CMD_TAG_SET_CONFIG, CMD_TAG_START_RX, CMD_TAG_STOP_RX, ERROR_INVALID_CONFIG, RADIO_CONFIG_SIZE, RESP_TAG_ERROR,
RESP_TAG_OK,
};
use tracing::{debug, warn};
@@ -22,9 +22,7 @@ pub struct MuxState {
impl MuxState {
pub fn new() -> Self {
Self {
locked_config: None,
}
Self { locked_config: None }
}
}
@@ -67,27 +65,18 @@ fn intercept_set_config(
// Same config — reply Ok without hitting the dongle
if config_bytes == locked.as_slice() {
let label = sessions
.get(&client_id)
.map(ClientSession::label)
.unwrap_or_else(|| format!("id-{client_id}"));
let label = sessions.get(&client_id).map(ClientSession::label).unwrap_or_else(|| format!("id-{client_id}"));
debug!("{label}: SetConfig matches locked config — Ok");
return Some(vec![RESP_TAG_OK]);
}
// Different config — reject
let label = sessions
.get(&client_id)
.map(ClientSession::label)
.unwrap_or_else(|| format!("id-{client_id}"));
let label = sessions.get(&client_id).map(ClientSession::label).unwrap_or_else(|| format!("id-{client_id}"));
warn!("{label}: SetConfig rejected (conflicts with locked config)");
Some(vec![RESP_TAG_ERROR, ERROR_INVALID_CONFIG])
}
fn intercept_start_rx(
client_id: u64,
sessions: &mut HashMap<u64, ClientSession>,
) -> Option<Vec<u8>> {
fn intercept_start_rx(client_id: u64, sessions: &mut HashMap<u64, ClientSession>) -> Option<Vec<u8>> {
let client = sessions.get(&client_id)?;
// Already interested — no-op
@@ -107,10 +96,7 @@ fn intercept_start_rx(
None
}
fn intercept_stop_rx(
client_id: u64,
sessions: &mut HashMap<u64, ClientSession>,
) -> Option<Vec<u8>> {
fn intercept_stop_rx(client_id: u64, sessions: &mut HashMap<u64, ClientSession>) -> Option<Vec<u8>> {
let client = sessions.get(&client_id)?;
// Wasn't interested — no-op
@@ -140,6 +126,7 @@ pub fn rx_interest_count(sessions: &HashMap<u64, ClientSession>) -> usize {
// ── Tests ──────────────────────────────────────────────────────────
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
@@ -201,10 +188,7 @@ mod tests {
let id = first_id(&sessions);
let mut cmd = vec![CMD_TAG_SET_CONFIG];
cmd.extend_from_slice(&[99, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]);
assert_eq!(
maybe_intercept(&cmd, id, &mut sessions, &locked),
Some(vec![RESP_TAG_ERROR, ERROR_INVALID_CONFIG])
);
assert_eq!(maybe_intercept(&cmd, id, &mut sessions, &locked), Some(vec![RESP_TAG_ERROR, ERROR_INVALID_CONFIG]));
}
// ── StartRx ────────────────────────────────────────────────────
@@ -223,10 +207,7 @@ mod tests {
let locked = None;
let id = first_id(&sessions);
sessions.get_mut(&id).unwrap().rx_interested = true;
assert_eq!(
maybe_intercept(&[CMD_TAG_START_RX], id, &mut sessions, &locked),
Some(vec![RESP_TAG_OK])
);
assert_eq!(maybe_intercept(&[CMD_TAG_START_RX], id, &mut sessions, &locked), Some(vec![RESP_TAG_OK]));
}
#[test]
@@ -248,10 +229,7 @@ mod tests {
let mut sessions = make_sessions(1);
let locked = None;
let id = first_id(&sessions);
assert_eq!(
maybe_intercept(&[CMD_TAG_STOP_RX], id, &mut sessions, &locked),
Some(vec![RESP_TAG_OK])
);
assert_eq!(maybe_intercept(&[CMD_TAG_STOP_RX], id, &mut sessions, &locked), Some(vec![RESP_TAG_OK]));
}
#[test]
+12 -18
View File
@@ -50,22 +50,18 @@ async fn main() -> anyhow::Result<()> {
// Resolve serial port
let port = match args.port {
Some(p) => p,
None => {
tokio::task::spawn_blocking(|| {
donglora_client::find_port().unwrap_or_else(|| {
info!("waiting for DongLoRa device...");
donglora_client::wait_for_device()
})
None => tokio::task::spawn_blocking(|| {
donglora_client::find_port().unwrap_or_else(|| {
info!("waiting for DongLoRa device...");
donglora_client::wait_for_device()
})
.await
.map_err(|e| anyhow::anyhow!("device discovery task failed: {e}"))?
}
})
.await
.map_err(|e| anyhow::anyhow!("device discovery task failed: {e}"))?,
};
// Resolve socket path
let socket_path = args
.socket
.unwrap_or_else(donglora_client::default_socket_path);
let socket_path = args.socket.unwrap_or_else(donglora_client::default_socket_path);
// Parse TCP address
let tcp_addr = parse_tcp_addr(&args.tcp);
@@ -102,12 +98,10 @@ fn parse_tcp_addr(tcp: &str) -> Option<(String, u16)> {
}
async fn signal_handler(shutdown: CancellationToken) -> anyhow::Result<()> {
let mut sigint =
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())
.map_err(|e| anyhow::anyhow!("failed to register SIGINT handler: {e}"))?;
let mut sigterm =
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.map_err(|e| anyhow::anyhow!("failed to register SIGTERM handler: {e}"))?;
let mut sigint = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())
.map_err(|e| anyhow::anyhow!("failed to register SIGINT handler: {e}"))?;
let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.map_err(|e| anyhow::anyhow!("failed to register SIGTERM handler: {e}"))?;
tokio::select! {
_ = sigint.recv() => info!("received SIGINT"),
+1 -5
View File
@@ -30,11 +30,7 @@ impl ClientSession {
pub fn new() -> (Self, mpsc::Receiver<Vec<u8>>) {
let id = NEXT_CLIENT_ID.fetch_add(1, Ordering::Relaxed);
let (tx, rx) = mpsc::channel(SEND_QUEUE_CAP);
let session = Self {
id,
rx_interested: false,
tx,
};
let session = Self { id, rx_interested: false, tx };
(session, rx)
}