Bitcoin relay (mesh/bitcoin_relay.rs): - BlockHeaderCache: stores latest block headers from internet peers for SPV - RelayTracker: tracks in-flight TX and Lightning relay requests - Builder functions: block header announcements (Ed25519 signed), TX relay request/response, Lightning invoice relay/response - All amounts as u64 sats, never float - 4 unit tests Emergency alerts (mesh/alerts.rs): - AlertConfig: dead man switch settings, GPS, emergency contacts - DeadManSwitch: background timer, auto-trigger after configurable interval (default 6h), signed alert broadcast with GPS coordinates - check_in() resets timer, is_triggered() checks elapsed time - GPS as integer microdegrees (Coordinate type from message_types) - Disk persistence for config - 4 unit tests Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
416 lines
13 KiB
Rust
416 lines
13 KiB
Rust
//! Mesh networking: Meshcore LoRa radio integration for offline peer discovery
|
|
//! and encrypted messaging between Archipelago nodes.
|
|
//!
|
|
//! Supports Meshcore firmware on Heltec V3, T-Beam, RAK WisBlock, Station G2,
|
|
//! and other ESP32/nRF52-based LoRa boards via USB serial (Companion USB mode).
|
|
|
|
#[allow(dead_code)]
|
|
pub mod alerts;
|
|
#[allow(dead_code)]
|
|
pub mod bitcoin_relay;
|
|
#[allow(dead_code)]
|
|
pub mod crypto;
|
|
#[allow(dead_code)]
|
|
pub mod listener;
|
|
#[allow(dead_code)]
|
|
pub mod protocol;
|
|
#[allow(dead_code)]
|
|
pub mod serial;
|
|
#[allow(dead_code)]
|
|
pub mod types;
|
|
#[allow(dead_code)]
|
|
pub mod message_types;
|
|
#[allow(dead_code)]
|
|
pub mod outbox;
|
|
#[allow(dead_code)]
|
|
pub mod ratchet;
|
|
#[allow(dead_code)]
|
|
pub mod session;
|
|
#[allow(dead_code)]
|
|
pub mod x3dh;
|
|
|
|
pub use types::*;
|
|
|
|
use anyhow::{Context, Result};
|
|
use ed25519_dalek::SigningKey;
|
|
use listener::MeshState;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::Arc;
|
|
use tokio::fs;
|
|
use tokio::sync::{broadcast, watch};
|
|
use tracing::info;
|
|
|
|
const MESH_CONFIG_FILE: &str = "mesh-config.json";
|
|
|
|
/// Mesh configuration (persisted to disk).
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct MeshConfig {
|
|
pub enabled: bool,
|
|
/// Specific device path, or None for auto-detection.
|
|
#[serde(default)]
|
|
pub device_path: Option<String>,
|
|
/// Channel name for broadcasts.
|
|
#[serde(default)]
|
|
pub channel_name: Option<String>,
|
|
/// Whether to periodically broadcast our identity.
|
|
#[serde(default)]
|
|
pub broadcast_identity: bool,
|
|
/// Custom advertised name on the mesh network.
|
|
#[serde(default)]
|
|
pub advert_name: Option<String>,
|
|
/// Off-grid mode: disable Tor/internet, route everything via mesh only.
|
|
#[serde(default)]
|
|
pub mesh_only_mode: Option<bool>,
|
|
}
|
|
|
|
impl Default for MeshConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
enabled: false,
|
|
device_path: None,
|
|
channel_name: Some("archipelago".to_string()),
|
|
broadcast_identity: true,
|
|
advert_name: None,
|
|
mesh_only_mode: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn load_config(data_dir: &Path) -> Result<MeshConfig> {
|
|
let path = data_dir.join(MESH_CONFIG_FILE);
|
|
if !path.exists() {
|
|
return Ok(MeshConfig::default());
|
|
}
|
|
let content = fs::read_to_string(&path)
|
|
.await
|
|
.context("Failed to read mesh config")?;
|
|
let config: MeshConfig = serde_json::from_str(&content).unwrap_or_default();
|
|
Ok(config)
|
|
}
|
|
|
|
pub async fn save_config(data_dir: &Path, config: &MeshConfig) -> Result<()> {
|
|
fs::create_dir_all(data_dir)
|
|
.await
|
|
.context("Failed to create data dir")?;
|
|
let content =
|
|
serde_json::to_string_pretty(config).context("Failed to serialize mesh config")?;
|
|
fs::write(data_dir.join(MESH_CONFIG_FILE), content)
|
|
.await
|
|
.context("Failed to write mesh config")?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Detect serial devices that could be mesh radios.
|
|
/// Checks both Meshcore (via probe) and legacy Meshtastic paths.
|
|
pub async fn detect_devices() -> Vec<String> {
|
|
serial::detect_serial_devices().await
|
|
}
|
|
|
|
// ─── MeshService ────────────────────────────────────────────────────────
|
|
|
|
/// Top-level mesh networking service.
|
|
/// Manages the background listener, exposes APIs for RPC handlers.
|
|
pub struct MeshService {
|
|
state: Arc<MeshState>,
|
|
config: MeshConfig,
|
|
data_dir: PathBuf,
|
|
shutdown_tx: Option<watch::Sender<bool>>,
|
|
listener_handle: Option<tokio::task::JoinHandle<()>>,
|
|
cmd_rx: Option<tokio::sync::mpsc::Receiver<listener::MeshCommand>>,
|
|
// Crypto identity for this node
|
|
our_did: String,
|
|
our_ed_pubkey_hex: String,
|
|
our_x25519_secret: [u8; 32],
|
|
our_x25519_pubkey_hex: String,
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
impl MeshService {
|
|
/// Create a new MeshService. Does not start the listener yet.
|
|
pub async fn new(
|
|
data_dir: &Path,
|
|
signing_key: &SigningKey,
|
|
did: &str,
|
|
ed_pubkey_hex: &str,
|
|
) -> Result<Self> {
|
|
let config = load_config(data_dir).await?;
|
|
let channel_name = config
|
|
.channel_name
|
|
.clone()
|
|
.unwrap_or_else(|| "archipelago".to_string());
|
|
|
|
let (state, _rx, cmd_rx) = MeshState::new(&channel_name);
|
|
|
|
// Derive X25519 keys from Ed25519 identity
|
|
let x25519_secret = crypto::ed25519_secret_to_x25519(signing_key);
|
|
let x25519_pubkey = crypto::ed25519_pubkey_to_x25519(
|
|
&signing_key.verifying_key().to_bytes(),
|
|
)?;
|
|
let x25519_pubkey_hex = hex::encode(x25519_pubkey);
|
|
|
|
Ok(Self {
|
|
state,
|
|
config,
|
|
data_dir: data_dir.to_path_buf(),
|
|
shutdown_tx: None,
|
|
listener_handle: None,
|
|
cmd_rx: Some(cmd_rx),
|
|
our_did: did.to_string(),
|
|
our_ed_pubkey_hex: ed_pubkey_hex.to_string(),
|
|
our_x25519_secret: x25519_secret,
|
|
our_x25519_pubkey_hex: x25519_pubkey_hex,
|
|
})
|
|
}
|
|
|
|
/// Start the background mesh listener.
|
|
pub fn start(&mut self) -> Result<()> {
|
|
if self.listener_handle.is_some() {
|
|
anyhow::bail!("Mesh listener already running");
|
|
}
|
|
|
|
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
|
self.shutdown_tx = Some(shutdown_tx);
|
|
|
|
let cmd_rx = self.cmd_rx.take()
|
|
.ok_or_else(|| anyhow::anyhow!("Command channel already consumed"))?;
|
|
|
|
let handle = listener::spawn_mesh_listener(
|
|
Arc::clone(&self.state),
|
|
self.config.device_path.clone(),
|
|
self.our_did.clone(),
|
|
self.our_ed_pubkey_hex.clone(),
|
|
self.our_x25519_secret,
|
|
self.our_x25519_pubkey_hex.clone(),
|
|
shutdown_rx,
|
|
cmd_rx,
|
|
);
|
|
self.listener_handle = Some(handle);
|
|
|
|
info!("Mesh service started");
|
|
Ok(())
|
|
}
|
|
|
|
/// Stop the background listener.
|
|
pub async fn stop(&mut self) {
|
|
if let Some(tx) = self.shutdown_tx.take() {
|
|
let _ = tx.send(true);
|
|
}
|
|
if let Some(handle) = self.listener_handle.take() {
|
|
let _ = handle.await;
|
|
}
|
|
info!("Mesh service stopped");
|
|
}
|
|
|
|
/// Get current mesh status.
|
|
pub async fn status(&self) -> MeshStatus {
|
|
self.state.status.read().await.clone()
|
|
}
|
|
|
|
/// Get list of discovered peers.
|
|
pub async fn peers(&self) -> Vec<MeshPeer> {
|
|
self.state.peers.read().await.values().cloned().collect()
|
|
}
|
|
|
|
/// Get message history.
|
|
pub async fn messages(&self, limit: Option<usize>) -> Vec<MeshMessage> {
|
|
let messages = self.state.messages.read().await;
|
|
let limit = limit.unwrap_or(MAX_MESSAGES_DEFAULT);
|
|
// Return in chronological order (oldest first) — take last N items
|
|
let len = messages.len();
|
|
let skip = if len > limit { len - limit } else { 0 };
|
|
messages.iter().skip(skip).cloned().collect()
|
|
}
|
|
|
|
/// Send a message to a peer by contact_id.
|
|
/// Routes through the background listener which owns the serial port.
|
|
pub async fn send_message(&self, contact_id: u32, text: &str) -> Result<MeshMessage> {
|
|
let status = self.state.status.read().await;
|
|
if !status.device_connected {
|
|
anyhow::bail!("No mesh device connected");
|
|
}
|
|
drop(status);
|
|
|
|
// Look up the peer's public key to get the 6-byte prefix for addressing
|
|
let peers = self.state.peers.read().await;
|
|
let peer = peers
|
|
.get(&contact_id)
|
|
.ok_or_else(|| anyhow::anyhow!("Peer not found"))?;
|
|
let pubkey_hex = peer
|
|
.pubkey_hex
|
|
.as_ref()
|
|
.ok_or_else(|| anyhow::anyhow!("Peer has no public key"))?;
|
|
let pubkey_bytes = hex::decode(pubkey_hex)
|
|
.map_err(|_| anyhow::anyhow!("Invalid peer public key"))?;
|
|
if pubkey_bytes.len() < 6 {
|
|
anyhow::bail!("Peer public key too short");
|
|
}
|
|
let mut dest_prefix = [0u8; 6];
|
|
dest_prefix.copy_from_slice(&pubkey_bytes[..6]);
|
|
drop(peers);
|
|
|
|
let payload = text.as_bytes().to_vec();
|
|
let encrypted = false;
|
|
|
|
if payload.len() > protocol::MAX_MESSAGE_LEN {
|
|
anyhow::bail!(
|
|
"Message too large for LoRa: {} bytes (max {})",
|
|
payload.len(),
|
|
protocol::MAX_MESSAGE_LEN
|
|
);
|
|
}
|
|
|
|
// Send through the listener's command channel
|
|
self.state
|
|
.cmd_tx
|
|
.send(listener::MeshCommand::SendText {
|
|
dest_pubkey_prefix: dest_prefix,
|
|
payload,
|
|
})
|
|
.await
|
|
.map_err(|_| anyhow::anyhow!("Mesh listener not running"))?;
|
|
|
|
let msg_id = self.state.next_id().await;
|
|
let peer_name = self
|
|
.state
|
|
.peers
|
|
.read()
|
|
.await
|
|
.get(&contact_id)
|
|
.map(|p| p.advert_name.clone());
|
|
|
|
let msg = MeshMessage {
|
|
id: msg_id,
|
|
direction: MessageDirection::Sent,
|
|
peer_contact_id: contact_id,
|
|
peer_name,
|
|
plaintext: text.to_string(),
|
|
timestamp: chrono::Utc::now().to_rfc3339(),
|
|
delivered: false,
|
|
encrypted,
|
|
};
|
|
|
|
self.state.store_message(msg.clone()).await;
|
|
{
|
|
let mut status = self.state.status.write().await;
|
|
status.messages_sent += 1;
|
|
}
|
|
|
|
Ok(msg)
|
|
}
|
|
|
|
/// Broadcast our advertisement over mesh so other nodes can discover us.
|
|
/// Sends an immediate advert via the listener's command channel.
|
|
pub async fn broadcast_identity(&self) -> Result<()> {
|
|
let status = self.state.status.read().await;
|
|
if !status.device_connected {
|
|
anyhow::bail!("No mesh device connected. Check USB connection.");
|
|
}
|
|
drop(status);
|
|
|
|
self.state
|
|
.cmd_tx
|
|
.send(listener::MeshCommand::SendAdvert)
|
|
.await
|
|
.map_err(|_| anyhow::anyhow!("Mesh listener not running"))?;
|
|
|
|
info!("Mesh self-advert broadcast triggered");
|
|
Ok(())
|
|
}
|
|
|
|
/// Update mesh configuration.
|
|
pub async fn configure(&mut self, config: MeshConfig) -> Result<()> {
|
|
save_config(&self.data_dir, &config).await?;
|
|
|
|
let was_enabled = self.config.enabled;
|
|
self.config = config.clone();
|
|
|
|
// Update the status to reflect new config
|
|
{
|
|
let mut status = self.state.status.write().await;
|
|
status.enabled = config.enabled;
|
|
status.channel_name = config.channel_name.clone().unwrap_or_else(|| "archipelago".to_string());
|
|
}
|
|
|
|
// If enabled state changed, start/stop the listener
|
|
if config.enabled && !was_enabled {
|
|
self.start()?;
|
|
} else if !config.enabled && was_enabled {
|
|
self.stop().await;
|
|
// Clear connected state
|
|
let mut status = self.state.status.write().await;
|
|
status.device_connected = false;
|
|
status.device_path = None;
|
|
status.firmware_version = None;
|
|
status.self_node_id = None;
|
|
status.peer_count = 0;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Subscribe to mesh events.
|
|
pub fn subscribe(&self) -> broadcast::Receiver<MeshEvent> {
|
|
self.state.event_tx.subscribe()
|
|
}
|
|
|
|
/// Get a reference to shared state (for RPC handlers).
|
|
pub fn shared_state(&self) -> Arc<MeshState> {
|
|
Arc::clone(&self.state)
|
|
}
|
|
}
|
|
|
|
const MAX_MESSAGES_DEFAULT: usize = 100;
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_mesh_config_default() {
|
|
let config = MeshConfig::default();
|
|
assert!(!config.enabled);
|
|
assert_eq!(config.channel_name, Some("archipelago".to_string()));
|
|
assert!(config.broadcast_identity);
|
|
}
|
|
|
|
#[test]
|
|
fn test_mesh_config_serialization() {
|
|
let config = MeshConfig {
|
|
enabled: true,
|
|
device_path: Some("/dev/ttyUSB0".to_string()),
|
|
channel_name: Some("test".to_string()),
|
|
broadcast_identity: false,
|
|
advert_name: Some("MyNode".to_string()),
|
|
};
|
|
let json = serde_json::to_string(&config).unwrap();
|
|
let parsed: MeshConfig = serde_json::from_str(&json).unwrap();
|
|
assert!(parsed.enabled);
|
|
assert_eq!(parsed.device_path, Some("/dev/ttyUSB0".to_string()));
|
|
assert_eq!(parsed.advert_name, Some("MyNode".to_string()));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_load_config_default_when_no_file() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let config = load_config(dir.path()).await.unwrap();
|
|
assert!(!config.enabled);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_save_and_load_config_roundtrip() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let config = MeshConfig {
|
|
enabled: true,
|
|
device_path: Some("/dev/ttyUSB0".to_string()),
|
|
channel_name: Some("archy".to_string()),
|
|
broadcast_identity: true,
|
|
advert_name: None,
|
|
};
|
|
save_config(dir.path(), &config).await.unwrap();
|
|
let loaded = load_config(dir.path()).await.unwrap();
|
|
assert!(loaded.enabled);
|
|
assert_eq!(loaded.device_path, Some("/dev/ttyUSB0".to_string()));
|
|
}
|
|
}
|