iroh QUIC datagrams for peer-to-peer networking with NAT traversal, donglora-client for radio access (direct USB or mux), ratatui TUI with four-panel dashboard (peers, radio config, stats, packet log). - Packet envelope with origin tracking, sequence numbers, RSSI/SNR - Dual dedup: iroh-level (origin+seq) and radio echo (blake3 hash) - SNR filtering per PROTOCOL.md (forward GOOD+MARGINAL, drop rest) - Auto-generate config + identity on first run - Graceful mux config fallback (adopts locked config if SetConfig rejected) - WouldBlock workaround for donglora-client socket timeout bug - Reconnect with exponential backoff on dongle disconnect - CLI: show-id, generate-config subcommands, --log-only headless mode
412 lines
13 KiB
Rust
412 lines
13 KiB
Rust
use std::collections::HashMap;
|
|
use std::sync::Arc;
|
|
use std::time::Instant;
|
|
|
|
use anyhow::{Context, Result};
|
|
use bytes::Bytes;
|
|
use iroh::endpoint::Connection;
|
|
use iroh::{Endpoint, PublicKey, SecretKey, endpoint::presets};
|
|
use tokio::sync::{Mutex, mpsc};
|
|
use tokio_util::sync::CancellationToken;
|
|
use tracing::{debug, info, warn};
|
|
|
|
use crate::packet::Envelope;
|
|
|
|
/// ALPN protocol identifier for donglora-bridge.
|
|
const ALPN: &[u8] = b"donglora-bridge/0";
|
|
|
|
/// Info about a configured peer.
|
|
#[derive(Debug, Clone)]
|
|
pub struct PeerConfig {
|
|
pub public_key: PublicKey,
|
|
pub name: String,
|
|
pub addresses: Vec<String>,
|
|
}
|
|
|
|
/// Observable state of a peer connection.
|
|
#[derive(Debug, Clone)]
|
|
pub struct PeerState {
|
|
pub name: String,
|
|
pub public_key: PublicKey,
|
|
pub connected: bool,
|
|
pub last_seen: Option<Instant>,
|
|
pub packets_in: u64,
|
|
pub packets_out: u64,
|
|
}
|
|
|
|
/// Event from the network to the router.
|
|
#[allow(dead_code)]
|
|
pub enum NetworkEvent {
|
|
/// A packet received from an iroh peer.
|
|
Packet {
|
|
from: PublicKey,
|
|
envelope: Envelope,
|
|
},
|
|
/// A peer connected.
|
|
PeerConnected(PublicKey),
|
|
/// A peer disconnected.
|
|
PeerDisconnected(PublicKey),
|
|
}
|
|
|
|
/// Shared peer connection map.
|
|
type PeerConnections = Arc<Mutex<HashMap<PublicKey, Connection>>>;
|
|
|
|
/// Shared peer state map.
|
|
type PeerStates = Arc<Mutex<HashMap<PublicKey, PeerState>>>;
|
|
|
|
/// The network layer: manages the iroh endpoint and peer connections.
|
|
pub struct Network {
|
|
endpoint: Endpoint,
|
|
peer_configs: Vec<PeerConfig>,
|
|
connections: PeerConnections,
|
|
states: PeerStates,
|
|
cancel: CancellationToken,
|
|
}
|
|
|
|
impl Network {
|
|
/// Create a new network layer with the given identity and peer configuration.
|
|
pub async fn new(secret_key: SecretKey, peer_configs: Vec<PeerConfig>) -> Result<Self> {
|
|
let endpoint = Endpoint::builder(presets::N0)
|
|
.secret_key(secret_key)
|
|
.alpns(vec![ALPN.to_vec()])
|
|
.bind()
|
|
.await
|
|
.context("binding iroh endpoint")?;
|
|
|
|
// Wait for relay connection before trying to reach peers.
|
|
info!("waiting for iroh relay connection...");
|
|
endpoint.online().await;
|
|
let addr = endpoint.addr();
|
|
info!("iroh online, addr: {addr:?}");
|
|
|
|
let states = Arc::new(Mutex::new(HashMap::new()));
|
|
{
|
|
let mut s = states.lock().await;
|
|
for pc in &peer_configs {
|
|
s.insert(
|
|
pc.public_key,
|
|
PeerState {
|
|
name: pc.name.clone(),
|
|
public_key: pc.public_key,
|
|
connected: false,
|
|
last_seen: None,
|
|
packets_in: 0,
|
|
packets_out: 0,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
Ok(Self {
|
|
endpoint,
|
|
peer_configs,
|
|
connections: Arc::new(Mutex::new(HashMap::new())),
|
|
states,
|
|
cancel: CancellationToken::new(),
|
|
})
|
|
}
|
|
|
|
/// Get a snapshot of all peer states.
|
|
pub async fn peer_states(&self) -> Vec<PeerState> {
|
|
self.states.lock().await.values().cloned().collect()
|
|
}
|
|
|
|
/// Start the network: spawn outbound connection tasks + inbound accept loop.
|
|
///
|
|
/// Returns a receiver for network events and a sender for outbound envelopes.
|
|
pub fn start(
|
|
&self,
|
|
) -> (mpsc::Receiver<NetworkEvent>, mpsc::Sender<(Envelope, Option<PublicKey>)>) {
|
|
let (event_tx, event_rx) = mpsc::channel::<NetworkEvent>(256);
|
|
let (send_tx, send_rx) = mpsc::channel::<(Envelope, Option<PublicKey>)>(256);
|
|
|
|
// Spawn outbound connection tasks for each configured peer.
|
|
for pc in &self.peer_configs {
|
|
let endpoint = self.endpoint.clone();
|
|
let pk = pc.public_key;
|
|
let addrs = pc.addresses.clone();
|
|
let connections = self.connections.clone();
|
|
let states = self.states.clone();
|
|
let event_tx = event_tx.clone();
|
|
let cancel = self.cancel.clone();
|
|
|
|
tokio::spawn(async move {
|
|
outbound_connection_loop(endpoint, pk, addrs, connections, states, event_tx, cancel).await;
|
|
});
|
|
}
|
|
|
|
// Spawn inbound accept loop.
|
|
{
|
|
let endpoint = self.endpoint.clone();
|
|
let connections = self.connections.clone();
|
|
let states = self.states.clone();
|
|
let event_tx = event_tx.clone();
|
|
let cancel = self.cancel.clone();
|
|
let known_peers: Vec<PublicKey> =
|
|
self.peer_configs.iter().map(|pc| pc.public_key).collect();
|
|
|
|
tokio::spawn(async move {
|
|
accept_loop(endpoint, known_peers, connections, states, event_tx, cancel).await;
|
|
});
|
|
}
|
|
|
|
// Spawn broadcast sender task.
|
|
{
|
|
let connections = self.connections.clone();
|
|
let states = self.states.clone();
|
|
let cancel = self.cancel.clone();
|
|
|
|
tokio::spawn(async move {
|
|
broadcast_loop(connections, states, send_rx, cancel).await;
|
|
});
|
|
}
|
|
|
|
(event_rx, send_tx)
|
|
}
|
|
|
|
/// Shut down the network layer.
|
|
pub async fn shutdown(&self) {
|
|
self.cancel.cancel();
|
|
self.endpoint.close().await;
|
|
}
|
|
}
|
|
|
|
/// Maintain a persistent outbound connection to a peer, reconnecting on failure.
|
|
async fn outbound_connection_loop(
|
|
endpoint: Endpoint,
|
|
peer_key: PublicKey,
|
|
addresses: Vec<String>,
|
|
connections: PeerConnections,
|
|
states: PeerStates,
|
|
event_tx: mpsc::Sender<NetworkEvent>,
|
|
cancel: CancellationToken,
|
|
) {
|
|
let mut backoff = std::time::Duration::from_secs(1);
|
|
let max_backoff = std::time::Duration::from_secs(30);
|
|
|
|
loop {
|
|
if cancel.is_cancelled() {
|
|
return;
|
|
}
|
|
|
|
// Skip if we already have a connection (e.g., from inbound accept).
|
|
{
|
|
let conns = connections.lock().await;
|
|
if let Some(c) = conns.get(&peer_key)
|
|
&& c.stable_id() != 0
|
|
{
|
|
// Connection exists and looks alive — wait and retry check.
|
|
drop(conns);
|
|
tokio::select! {
|
|
() = cancel.cancelled() => return,
|
|
() = tokio::time::sleep(std::time::Duration::from_secs(5)) => continue,
|
|
}
|
|
}
|
|
}
|
|
|
|
info!("connecting to peer {}", fmt_key(&peer_key));
|
|
let mut addr = iroh::EndpointAddr::new(peer_key);
|
|
// Add our relay URL as a hint — the peer is likely on the same relay network.
|
|
for relay_url in endpoint.addr().relay_urls() {
|
|
addr = addr.with_relay_url(relay_url.clone());
|
|
}
|
|
for a in &addresses {
|
|
if let Ok(sock) = a.parse::<std::net::SocketAddr>() {
|
|
addr = addr.with_ip_addr(sock);
|
|
}
|
|
}
|
|
debug!("connect addr: {addr:?}");
|
|
match endpoint.connect(addr, ALPN).await {
|
|
Ok(conn) => {
|
|
backoff = std::time::Duration::from_secs(1);
|
|
let remote = conn.remote_id();
|
|
info!("connected to peer {}", fmt_key(&remote));
|
|
|
|
{
|
|
let mut conns = connections.lock().await;
|
|
conns.insert(remote, conn.clone());
|
|
}
|
|
update_peer_connected(&states, &remote, true).await;
|
|
let _ = event_tx.send(NetworkEvent::PeerConnected(remote)).await;
|
|
|
|
// Read datagrams from this connection.
|
|
read_datagrams(conn, remote, connections.clone(), states.clone(), event_tx.clone(), cancel.clone()).await;
|
|
|
|
update_peer_connected(&states, &remote, false).await;
|
|
let _ = event_tx
|
|
.send(NetworkEvent::PeerDisconnected(remote))
|
|
.await;
|
|
}
|
|
Err(e) => {
|
|
warn!("connect to {} failed: {e:#}", fmt_key(&peer_key));
|
|
}
|
|
}
|
|
|
|
tokio::select! {
|
|
() = cancel.cancelled() => return,
|
|
() = tokio::time::sleep(backoff) => {},
|
|
}
|
|
backoff = (backoff * 2).min(max_backoff);
|
|
}
|
|
}
|
|
|
|
/// Accept inbound connections from known peers.
|
|
async fn accept_loop(
|
|
endpoint: Endpoint,
|
|
known_peers: Vec<PublicKey>,
|
|
connections: PeerConnections,
|
|
states: PeerStates,
|
|
event_tx: mpsc::Sender<NetworkEvent>,
|
|
cancel: CancellationToken,
|
|
) {
|
|
loop {
|
|
let incoming = tokio::select! {
|
|
() = cancel.cancelled() => return,
|
|
inc = endpoint.accept() => match inc {
|
|
Some(i) => i,
|
|
None => return,
|
|
},
|
|
};
|
|
|
|
let conn = match incoming.await {
|
|
Ok(c) => c,
|
|
Err(e) => {
|
|
warn!("inbound connection failed: {e:#}");
|
|
continue;
|
|
}
|
|
};
|
|
|
|
let remote = conn.remote_id();
|
|
if !known_peers.contains(&remote) {
|
|
warn!("rejecting unknown peer {}", fmt_key(&remote));
|
|
conn.close(0u32.into(), b"unknown peer");
|
|
continue;
|
|
}
|
|
|
|
info!("accepted connection from peer {}", fmt_key(&remote));
|
|
{
|
|
let mut conns = connections.lock().await;
|
|
conns.insert(remote, conn.clone());
|
|
}
|
|
update_peer_connected(&states, &remote, true).await;
|
|
let _ = event_tx.send(NetworkEvent::PeerConnected(remote)).await;
|
|
|
|
// Spawn a reader for this inbound connection.
|
|
let conns = connections.clone();
|
|
let sts = states.clone();
|
|
let etx = event_tx.clone();
|
|
let ct = cancel.clone();
|
|
tokio::spawn(async move {
|
|
read_datagrams(conn, remote, conns.clone(), sts.clone(), etx.clone(), ct).await;
|
|
update_peer_connected(&sts, &remote, false).await;
|
|
let _ = etx.send(NetworkEvent::PeerDisconnected(remote)).await;
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Read datagrams from a connection until it closes.
|
|
async fn read_datagrams(
|
|
conn: Connection,
|
|
remote: PublicKey,
|
|
connections: PeerConnections,
|
|
states: PeerStates,
|
|
event_tx: mpsc::Sender<NetworkEvent>,
|
|
cancel: CancellationToken,
|
|
) {
|
|
loop {
|
|
let data = tokio::select! {
|
|
() = cancel.cancelled() => break,
|
|
result = conn.read_datagram() => match result {
|
|
Ok(d) => d,
|
|
Err(e) => {
|
|
debug!("datagram read from {} ended: {e}", fmt_key(&remote));
|
|
break;
|
|
}
|
|
},
|
|
};
|
|
|
|
if let Some(envelope) = Envelope::decode(&data) {
|
|
{
|
|
let mut sts = states.lock().await;
|
|
if let Some(ps) = sts.get_mut(&remote) {
|
|
ps.packets_in += 1;
|
|
ps.last_seen = Some(Instant::now());
|
|
}
|
|
}
|
|
let _ = event_tx
|
|
.send(NetworkEvent::Packet {
|
|
from: remote,
|
|
envelope,
|
|
})
|
|
.await;
|
|
} else {
|
|
warn!("malformed envelope from {}", fmt_key(&remote));
|
|
}
|
|
}
|
|
|
|
// Clean up connection.
|
|
let mut conns = connections.lock().await;
|
|
if let Some(existing) = conns.get(&remote)
|
|
&& existing.stable_id() == conn.stable_id()
|
|
{
|
|
conns.remove(&remote);
|
|
}
|
|
}
|
|
|
|
/// Broadcast envelopes to all connected peers (except the optional skip key).
|
|
async fn broadcast_loop(
|
|
connections: PeerConnections,
|
|
states: PeerStates,
|
|
mut send_rx: mpsc::Receiver<(Envelope, Option<PublicKey>)>,
|
|
cancel: CancellationToken,
|
|
) {
|
|
loop {
|
|
let (envelope, skip) = tokio::select! {
|
|
() = cancel.cancelled() => return,
|
|
msg = send_rx.recv() => match msg {
|
|
Some(m) => m,
|
|
None => return,
|
|
},
|
|
};
|
|
|
|
let data = Bytes::from(envelope.encode());
|
|
let conns = connections.lock().await;
|
|
|
|
for (pk, conn) in conns.iter() {
|
|
if Some(*pk) == skip {
|
|
continue;
|
|
}
|
|
match conn.send_datagram(data.clone()) {
|
|
Ok(()) => {
|
|
let mut sts = states.lock().await;
|
|
if let Some(ps) = sts.get_mut(pk) {
|
|
ps.packets_out += 1;
|
|
}
|
|
}
|
|
Err(e) => {
|
|
debug!("send to {} failed: {e}", fmt_key(pk));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn update_peer_connected(states: &PeerStates, pk: &PublicKey, connected: bool) {
|
|
let mut sts = states.lock().await;
|
|
if let Some(ps) = sts.get_mut(pk) {
|
|
ps.connected = connected;
|
|
if connected {
|
|
ps.last_seen = Some(Instant::now());
|
|
}
|
|
}
|
|
}
|
|
|
|
fn fmt_key(pk: &PublicKey) -> String {
|
|
let s = pk.to_string();
|
|
if s.len() > 12 {
|
|
format!("{}...", &s[..12])
|
|
} else {
|
|
s
|
|
}
|
|
}
|