feat: replace direct P2P networking with iroh-gossip swarm and DHT peer discovery

Replace manually configured peer connections with a passphrase-based gossip
swarm. Peers sharing the same passphrase automatically discover each other
via mainline DHT and join an iroh-gossip topic.

Key changes:
- Add gossip.rs: iroh-gossip swarm with DHT bootstrap, heartbeat, and
  graceful shutdown that publishes neighbor list
- Add setup.rs: interactive config wizard replacing generate-config
- Add rate_limit.rs: token-bucket TX rate limiter derived from LoRa air time
- Remove network.rs: old direct P2P connection management
- Config: remove [[peers]] section, add passphrase to [bridge], update
  radio defaults to MeshCore US/Canada recommended values
- Packet: rename Envelope to GossipFrame, simplify wire format (drop
  version byte and seq number)
- Router: unify dedup into single content-hash cache, add rate limiting
- Identity: use ephemeral keypair per launch instead of persistent secret key
- Radio: improve config negotiation (GetConfig-first strategy), add device
  info tracking
- TUI: complete redesign with network/swarm panel, sparkline activity
  charts, color-coded RSSI/SNR, and shutdown overlay
This commit is contained in:
swaits committed 2026-04-05 19:00:26 -06:00
1 parent 4387549e7f
commit 4508f317be
12 files changed
+2025 -1229

No files matched your search

+51 -63
View File
@@ -1,10 +1,5 @@
use iroh::PublicKey;
/// Wire protocol version.
const VERSION: u8 = 0;
/// Envelope header size: version(1) + origin(32) + seq(8) + rssi(2) + snr(2) + payload_len(2) = 47
const HEADER_SIZE: usize = 47;
/// Gossip frame header size: sender(32) + rssi(2) + snr(2) + payload_len(2) = 38
const FRAME_HEADER_SIZE: usize = 38;
/// SNR quality grade for a received LoRa packet.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -38,7 +33,7 @@ impl std::fmt::Display for Grade {
}
/// Grade a received packet's SNR against the demodulation floor for the given
/// spreading factor. See PROTOCOL.md for the formula.
/// spreading factor.
pub fn snr_grade(snr: i16, sf: u8) -> Grade {
if !(-32..=32).contains(&snr) {
return Grade::Invalid;
@@ -54,7 +49,7 @@ pub fn snr_grade(snr: i16, sf: u8) -> Grade {
}
}
/// A packet received from the radio, before wrapping in an envelope.
/// A packet received from the radio, before wrapping in a gossip frame.
#[derive(Debug, Clone)]
pub struct RadioPacket {
pub rssi: i16,
@@ -62,13 +57,13 @@ pub struct RadioPacket {
pub payload: Vec<u8>,
}
/// A bridge envelope — the wire format sent between iroh peers.
/// A gossip frame — the wire format sent through the iroh-gossip swarm.
///
/// Wire format: `[sender: 32B] [rssi: i16 LE] [snr: i16 LE] [payload_len: u16 LE] [payload: NB]`
#[derive(Debug, Clone)]
pub struct Envelope {
/// NodeId of the bridge that first heard this packet on radio.
pub origin: PublicKey,
/// Per-origin sequence number (monotonically increasing).
pub seq: u64,
pub struct GossipFrame {
/// EndpointId (PublicKey bytes) of the bridge that first heard this packet on radio.
pub sender: [u8; 32],
/// RSSI at the originating bridge (informational).
pub rssi: i16,
/// SNR at the originating bridge (informational).
@@ -77,13 +72,21 @@ pub struct Envelope {
pub payload: Vec<u8>,
}
impl Envelope {
/// Encode this envelope into bytes for sending as a QUIC datagram.
impl GossipFrame {
/// Create a new gossip frame from a public key and radio packet data.
pub fn new(sender: &iroh::PublicKey, rssi: i16, snr: i16, payload: Vec<u8>) -> Self {
Self {
sender: *sender.as_bytes(),
rssi,
snr,
payload,
}
}
/// Encode this frame into bytes for gossip broadcast.
pub fn encode(&self) -> Vec<u8> {
let mut buf = Vec::with_capacity(HEADER_SIZE + self.payload.len());
buf.push(VERSION);
buf.extend_from_slice(self.origin.as_bytes());
buf.extend_from_slice(&self.seq.to_le_bytes());
let mut buf = Vec::with_capacity(FRAME_HEADER_SIZE + self.payload.len());
buf.extend_from_slice(&self.sender);
buf.extend_from_slice(&self.rssi.to_le_bytes());
buf.extend_from_slice(&self.snr.to_le_bytes());
let len = self.payload.len() as u16;
@@ -92,39 +95,30 @@ impl Envelope {
buf
}
/// Decode an envelope from bytes received as a QUIC datagram.
/// Decode a gossip frame from bytes received from the swarm.
pub fn decode(data: &[u8]) -> Option<Self> {
if data.len() < HEADER_SIZE {
if data.len() < FRAME_HEADER_SIZE {
return None;
}
if data[0] != VERSION {
return None;
}
let origin = PublicKey::from_bytes(data[1..33].try_into().ok()?).ok()?;
let seq = u64::from_le_bytes(data[33..41].try_into().ok()?);
let rssi = i16::from_le_bytes(data[41..43].try_into().ok()?);
let snr = i16::from_le_bytes(data[43..45].try_into().ok()?);
let payload_len = u16::from_le_bytes(data[45..47].try_into().ok()?) as usize;
if data.len() != HEADER_SIZE + payload_len {
let sender: [u8; 32] = data[..32].try_into().ok()?;
let rssi = i16::from_le_bytes(data[32..34].try_into().ok()?);
let snr = i16::from_le_bytes(data[34..36].try_into().ok()?);
let payload_len = u16::from_le_bytes(data[36..38].try_into().ok()?) as usize;
if data.len() != FRAME_HEADER_SIZE + payload_len {
return None;
}
Some(Self {
origin,
seq,
sender,
rssi,
snr,
payload: data[HEADER_SIZE..].to_vec(),
payload: data[FRAME_HEADER_SIZE..].to_vec(),
})
}
}
/// Compute a 16-byte content hash for deduplication.
pub fn content_hash(payload: &[u8]) -> [u8; 16] {
let hash = blake3::hash(payload);
let bytes = hash.as_bytes();
let mut out = [0u8; 16];
out.copy_from_slice(&bytes[..16]);
out
/// Compute a 32-byte blake3 content hash for deduplication.
pub fn content_hash(payload: &[u8]) -> [u8; 32] {
*blake3::hash(payload).as_bytes()
}
#[cfg(test)]
@@ -146,34 +140,28 @@ mod tests {
}
#[test]
fn envelope_roundtrip() {
fn gossip_frame_roundtrip() {
let key = iroh::SecretKey::generate(&mut rand::rng());
let env = Envelope {
origin: key.public(),
seq: 42,
rssi: -85,
snr: 8,
payload: vec![1, 2, 3, 4, 5],
};
let encoded = env.encode();
let decoded = Envelope::decode(&encoded).unwrap();
assert_eq!(decoded.origin, env.origin);
assert_eq!(decoded.seq, env.seq);
assert_eq!(decoded.rssi, env.rssi);
assert_eq!(decoded.snr, env.snr);
assert_eq!(decoded.payload, env.payload);
let frame = GossipFrame::new(&key.public(), -85, 8, vec![1, 2, 3, 4, 5]);
let encoded = frame.encode();
let decoded = GossipFrame::decode(&encoded).expect("decode should succeed");
assert_eq!(decoded.sender, *key.public().as_bytes());
assert_eq!(decoded.rssi, -85);
assert_eq!(decoded.snr, 8);
assert_eq!(decoded.payload, vec![1, 2, 3, 4, 5]);
}
#[test]
fn envelope_reject_short() {
assert!(Envelope::decode(&[0u8; 10]).is_none());
fn gossip_frame_reject_short() {
assert!(GossipFrame::decode(&[0u8; 10]).is_none());
}
#[test]
fn envelope_reject_bad_version() {
let mut data = vec![1u8]; // version 1
data.extend_from_slice(&[0u8; 46]);
assert!(Envelope::decode(&data).is_none());
fn gossip_frame_reject_wrong_length() {
// Header says payload_len=5 but only 3 bytes follow.
let mut data = vec![0u8; 38 + 3];
data[36..38].copy_from_slice(&5u16.to_le_bytes());
assert!(GossipFrame::decode(&data).is_none());
}
#[test]