backup commit

This commit is contained in:
Dorian
2026-03-17 00:03:08 +00:00
parent f23be63bba
commit 253c305cc8
43 changed files with 9514 additions and 308 deletions
+8
View File
@@ -85,6 +85,14 @@ mainline = "2"
zbase32 = "0.1"
bytes = "1"
# Mesh networking (Meshcore serial protocol over USB LoRa radios)
serial2-tokio = "0.1"
# Transport abstraction (Phase 2: mesh as federation transport)
ciborium = "0.2.2" # CBOR serde for compact delta sync
reed-solomon-erasure = "6.0" # FEC for chunked LoRa messages
mdns-sd = "0.18" # LAN peer discovery via mDNS
# Systemd watchdog notification
sd-notify = "0.4"
+32
View File
@@ -51,6 +51,11 @@ impl ApiHandler {
})
}
/// Access the RPC handler (for service initialization after construction).
pub fn rpc_handler(&self) -> &Arc<RpcHandler> {
&self.rpc_handler
}
/// Check if the request has a valid session cookie.
async fn is_authenticated(&self, headers: &hyper::HeaderMap) -> bool {
match session::extract_session_cookie(headers) {
@@ -161,6 +166,11 @@ impl ApiHandler {
// Electrs status — unauthenticated (read-only sync status)
(Method::GET, "/electrs-status") => Self::handle_electrs_status().await,
// LND connect info — unauthenticated (read-only, localhost only)
(Method::GET, "/lnd-connect-info") => {
Self::handle_lnd_connect_info(self.rpc_handler.clone()).await
}
// Container logs — requires session
(Method::GET, path) if path.starts_with("/api/container/logs") => {
if !self.is_authenticated(&headers).await {
@@ -307,6 +317,28 @@ impl ApiHandler {
.unwrap())
}
async fn handle_lnd_connect_info(
rpc: std::sync::Arc<super::rpc::RpcHandler>,
) -> Result<Response<hyper::Body>> {
match rpc.handle_lnd_connect_info().await {
Ok(val) => {
let body = serde_json::to_vec(&val).unwrap_or_default();
Ok(Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "application/json")
.body(hyper::Body::from(body))
.unwrap())
}
Err(e) => Ok(Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.header("Content-Type", "application/json")
.body(hyper::Body::from(
serde_json::json!({"error": e.to_string()}).to_string(),
))
.unwrap()),
}
}
async fn handle_lnd_proxy(path: &str, cors_origin: &str) -> Result<Response<hyper::Body>> {
let suffix = path.strip_prefix("/proxy/lnd").unwrap_or("/");
let url = format!("http://127.0.0.1:8080{}", suffix);
+45
View File
@@ -1,5 +1,6 @@
use super::RpcHandler;
use anyhow::{Context, Result};
use base64::Engine;
use serde::{Deserialize, Serialize};
use tracing::info;
@@ -797,6 +798,50 @@ impl RpcHandler {
"incoming_pending_count": incoming_pending,
}))
}
/// Return LND connection info: base64url-encoded TLS cert and admin macaroon
/// for building lndconnect:// URIs in the frontend.
pub(crate) async fn handle_lnd_connect_info(&self) -> Result<serde_json::Value> {
let cert_path = "/var/lib/archipelago/lnd/tls.cert";
let macaroon_path =
"/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
// Read and encode TLS cert (PEM → DER → base64url)
let cert_pem = tokio::fs::read_to_string(cert_path)
.await
.context("Failed to read LND TLS certificate")?;
let cert_der_b64: String = cert_pem
.lines()
.filter(|l| !l.starts_with("-----"))
.collect();
let cert_der = base64::engine::general_purpose::STANDARD
.decode(&cert_der_b64)
.context("Failed to decode PEM base64")?;
let cert_b64url = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&cert_der);
// Read and encode macaroon (binary → base64url)
let macaroon_bytes = tokio::fs::read(macaroon_path)
.await
.context("Failed to read LND admin macaroon")?;
let macaroon_b64url =
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&macaroon_bytes);
// Read Tor onion address if available
let tor_onion = tokio::fs::read_to_string(
"/var/lib/archipelago/tor/hidden_service_lnd/hostname",
)
.await
.ok()
.map(|s| s.trim().to_string());
Ok(serde_json::json!({
"cert_base64url": cert_b64url,
"macaroon_base64url": macaroon_b64url,
"tor_onion": tor_onion,
"rest_port": 8080,
"grpc_port": 10009,
}))
}
}
// Channel types
+105 -32
View File
@@ -1,58 +1,122 @@
use super::RpcHandler;
use crate::{identity, mesh};
use crate::mesh;
use anyhow::Result;
use tracing::info;
impl RpcHandler {
/// mesh.status — Get mesh radio status and detected devices.
/// mesh.status — Get mesh radio status, device info, and peer count.
pub(super) async fn handle_mesh_status(&self) -> Result<serde_json::Value> {
let config = mesh::load_config(&self.config.data_dir).await?;
let devices = mesh::detect_meshtastic_devices().await;
Ok(serde_json::json!({
"enabled": config.enabled,
"device_path": config.device_path,
"channel_name": config.channel_name,
"broadcast_identity": config.broadcast_identity,
"detected_devices": devices,
}))
let service = self.mesh_service.read().await;
if let Some(svc) = service.as_ref() {
let status = svc.status().await;
Ok(serde_json::to_value(status)?)
} else {
// No service running — return basic config + device detection
let config = mesh::load_config(&self.config.data_dir).await?;
let devices = mesh::detect_devices().await;
Ok(serde_json::json!({
"enabled": config.enabled,
"device_connected": false,
"device_type": "unknown",
"device_path": config.device_path,
"channel_name": config.channel_name.unwrap_or_else(|| "archipelago".to_string()),
"detected_devices": devices,
"peer_count": 0,
"messages_sent": 0,
"messages_received": 0,
}))
}
}
/// mesh.discover — Discover nodes via mesh radio.
pub(super) async fn handle_mesh_discover(
/// mesh.peersList discovered mesh peers.
pub(super) async fn handle_mesh_peers(&self) -> Result<serde_json::Value> {
let service = self.mesh_service.read().await;
if let Some(svc) = service.as_ref() {
let peers = svc.peers().await;
Ok(serde_json::json!({
"peers": peers,
"count": peers.len(),
}))
} else {
Ok(serde_json::json!({
"peers": [],
"count": 0,
}))
}
}
/// mesh.messages — Get recent mesh message history.
pub(super) async fn handle_mesh_messages(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let device_path = params
let limit = params
.as_ref()
.and_then(|p| p.get("device_path"))
.and_then(|v| v.as_str());
.and_then(|p| p.get("limit"))
.and_then(|v| v.as_u64())
.map(|n| n as usize);
let config = mesh::load_config(&self.config.data_dir).await?;
let effective_device = device_path.or(config.device_path.as_deref());
let service = self.mesh_service.read().await;
if let Some(svc) = service.as_ref() {
let messages = svc.messages(limit).await;
Ok(serde_json::json!({
"messages": messages,
"count": messages.len(),
}))
} else {
Ok(serde_json::json!({
"messages": [],
"count": 0,
}))
}
}
let nodes = mesh::discover_nodes(effective_device).await?;
/// mesh.send — Send an encrypted message to a mesh peer.
pub(super) async fn handle_mesh_send(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let contact_id = params
.get("contact_id")
.and_then(|v| v.as_u64())
.ok_or_else(|| anyhow::anyhow!("Missing contact_id"))? as u32;
let message = params
.get("message")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing message"))?;
if message.is_empty() {
anyhow::bail!("Message cannot be empty");
}
let service = self.mesh_service.read().await;
let svc = service
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running. Enable mesh first."))?;
let msg = svc.send_message(contact_id, message).await?;
info!(contact_id, encrypted = msg.encrypted, "Sent mesh message");
Ok(serde_json::json!({
"nodes": nodes,
"count": nodes.len(),
"sent": true,
"message_id": msg.id,
"encrypted": msg.encrypted,
}))
}
/// mesh.broadcast — Broadcast our node identity over mesh.
pub(super) async fn handle_mesh_broadcast(&self) -> Result<serde_json::Value> {
let config = mesh::load_config(&self.config.data_dir).await?;
if !config.enabled {
anyhow::bail!("Mesh networking is not enabled. Configure it first.");
}
let (data, _) = self.state_manager.get_snapshot().await;
let did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
let pubkey = &data.server_info.pubkey;
mesh::broadcast_identity(&did, pubkey, config.device_path.as_deref()).await?;
let service = self.mesh_service.read().await;
let svc = service
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running. Enable mesh first."))?;
svc.broadcast_identity().await?;
info!("Broadcast identity over mesh");
Ok(serde_json::json!({ "broadcast": true }))
}
@@ -77,9 +141,18 @@ impl RpcHandler {
if let Some(broadcast) = params.get("broadcast_identity").and_then(|v| v.as_bool()) {
config.broadcast_identity = broadcast;
}
if let Some(name) = params.get("advert_name").and_then(|v| v.as_str()) {
config.advert_name = Some(name.to_string());
}
mesh::save_config(&self.config.data_dir, &config).await?;
// If we have a running service, update its config
let mut service = self.mesh_service.write().await;
if let Some(svc) = service.as_mut() {
svc.configure(config.clone()).await?;
}
info!("Mesh config updated");
Ok(serde_json::json!({
"configured": true,
+31 -2
View File
@@ -23,6 +23,7 @@ mod peers;
mod router;
mod security;
mod tor;
mod transport;
mod totp;
mod system;
mod update;
@@ -166,6 +167,8 @@ pub struct RpcHandler {
login_rate_limiter: LoginRateLimiter,
endpoint_rate_limiter: EndpointRateLimiter,
response_cache: ResponseCache,
mesh_service: Arc<tokio::sync::RwLock<Option<crate::mesh::MeshService>>>,
transport_router: Arc<tokio::sync::RwLock<Option<Arc<crate::transport::TransportRouter>>>>,
}
impl RpcHandler {
@@ -196,9 +199,26 @@ impl RpcHandler {
login_rate_limiter: LoginRateLimiter::new(),
endpoint_rate_limiter: EndpointRateLimiter::new(),
response_cache: ResponseCache::new(5),
mesh_service: Arc::new(tokio::sync::RwLock::new(None)),
transport_router: Arc::new(tokio::sync::RwLock::new(None)),
})
}
/// Set the mesh service (called after identity is loaded).
pub async fn set_mesh_service(&self, service: crate::mesh::MeshService) {
*self.mesh_service.write().await = Some(service);
}
/// Set the transport router (called after all transports are initialized).
pub async fn set_transport_router(&self, router: Arc<crate::transport::TransportRouter>) {
*self.transport_router.write().await = Some(router);
}
/// Get reference to the mesh service Arc (for MeshTransport wrapper).
pub fn mesh_service_arc(&self) -> Arc<tokio::sync::RwLock<Option<crate::mesh::MeshService>>> {
Arc::clone(&self.mesh_service)
}
fn cookie_suffix(&self) -> &'static str {
if self.config.dev_mode { "" } else { "; Secure" }
}
@@ -471,6 +491,7 @@ impl RpcHandler {
"lnd.create-psbt" => self.handle_lnd_create_psbt(params).await,
"lnd.finalize-psbt" => self.handle_lnd_finalize_psbt(params).await,
"lnd.gettransactions" => self.handle_lnd_gettransactions().await,
"lnd.connect-info" => self.handle_lnd_connect_info().await,
// Multi-identity management
"identity.list" => self.handle_identity_list(params).await,
@@ -618,12 +639,20 @@ impl RpcHandler {
"marketplace.create-invoice" => self.handle_marketplace_create_invoice(params).await,
"marketplace.check-payment" => self.handle_marketplace_check_payment(params).await,
// Mesh networking
// Mesh networking (Meshcore LoRa)
"mesh.status" => self.handle_mesh_status().await,
"mesh.discover" => self.handle_mesh_discover(params).await,
"mesh.peers" => self.handle_mesh_peers().await,
"mesh.messages" => self.handle_mesh_messages(params).await,
"mesh.send" => self.handle_mesh_send(params).await,
"mesh.broadcast" => self.handle_mesh_broadcast().await,
"mesh.configure" => self.handle_mesh_configure(params).await,
// Transport layer (unified routing)
"transport.status" => self.handle_transport_status().await,
"transport.peers" => self.handle_transport_peers().await,
"transport.send" => self.handle_transport_send(params).await,
"transport.set-mode" => self.handle_transport_set_mode(params).await,
// Server settings
"server.set-name" => self.handle_server_set_name(params).await,
+139
View File
@@ -0,0 +1,139 @@
use super::RpcHandler;
use crate::transport::{MessageType, TransportMessage};
use anyhow::Result;
use tracing::info;
impl RpcHandler {
/// transport.status — Get available transports and their status.
pub(super) async fn handle_transport_status(&self) -> Result<serde_json::Value> {
let router = self.transport_router.read().await;
if let Some(r) = router.as_ref() {
let transports: Vec<serde_json::Value> = r
.transport_status()
.into_iter()
.map(|(kind, available)| {
serde_json::json!({
"kind": kind.to_string(),
"available": available,
})
})
.collect();
let peer_count = r.registry.count().await;
let mesh_only = r.is_mesh_only().await;
Ok(serde_json::json!({
"transports": transports,
"mesh_only": mesh_only,
"peer_count": peer_count,
}))
} else {
Ok(serde_json::json!({
"transports": [],
"mesh_only": false,
"peer_count": 0,
}))
}
}
/// transport.peers — Get unified peer list with per-peer transport capabilities.
pub(super) async fn handle_transport_peers(&self) -> Result<serde_json::Value> {
let router = self.transport_router.read().await;
if let Some(r) = router.as_ref() {
let peers = r.registry.all_peers().await;
let peer_values: Vec<serde_json::Value> = peers
.into_iter()
.map(|p| {
let available = p.available_transports();
let preferred = available.first().map(|t| t.to_string());
serde_json::json!({
"did": p.did,
"pubkey_hex": p.pubkey_hex,
"name": p.name,
"trust_level": p.trust_level,
"mesh_contact_id": p.mesh_contact_id,
"lan_address": p.lan_address,
"onion_address": p.onion_address,
"preferred_transport": preferred,
"available_transports": available.iter().map(|t| t.to_string()).collect::<Vec<_>>(),
"last_seen": p.last_mesh.or(p.last_lan).or(p.last_tor),
})
})
.collect();
Ok(serde_json::json!({ "peers": peer_values }))
} else {
Ok(serde_json::json!({ "peers": [] }))
}
}
/// transport.send — Send a message to a peer via best available transport.
pub(super) async fn handle_transport_send(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.as_ref().ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let did = params["did"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing 'did' param"))?
.to_string();
let payload = params["payload"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing 'payload' param"))?
.to_string();
let router = self.transport_router.read().await;
let router = router
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Transport router not initialized"))?;
let (data, _) = self.state_manager.get_snapshot().await;
let our_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)
.unwrap_or_default();
let message = TransportMessage {
from_did: our_did,
payload: payload.as_bytes().to_vec(),
message_type: MessageType::PeerMessage,
};
let transport_used = router.send_to_peer(did, &message).await?;
info!(did = %did, transport = %transport_used, "Sent message via transport");
Ok(serde_json::json!({
"sent": true,
"transport_used": transport_used.to_string(),
"did": did,
}))
}
/// transport.set-mode — Toggle mesh-only (off-grid) mode.
pub(super) async fn handle_transport_set_mode(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.as_ref().ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let mesh_only = params["mesh_only"]
.as_bool()
.ok_or_else(|| anyhow::anyhow!("Missing 'mesh_only' bool param"))?;
let router = self.transport_router.read().await;
let router = router
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Transport router not initialized"))?;
router.set_mesh_only(mesh_only).await;
// Also persist to mesh config
let mut mesh_config = crate::mesh::load_config(&self.config.data_dir)
.await
.unwrap_or_default();
mesh_config.mesh_only_mode = Some(mesh_only);
crate::mesh::save_config(&self.config.data_dir, &mesh_config).await?;
info!(mesh_only = mesh_only, "Transport mode updated");
Ok(serde_json::json!({
"mesh_only": mesh_only,
"configured": true,
}))
}
}
+14
View File
@@ -32,6 +32,8 @@ pub struct ElectrsSyncStatus {
pub error: Option<String>,
/// Index data size in human-readable format (e.g. "11.2 GB")
pub index_size: Option<String>,
/// Tor onion address for ElectrumX (if available)
pub tor_onion: Option<String>,
}
/// Get the total size of a directory in bytes.
@@ -146,6 +148,14 @@ pub async fn get_electrs_sync_status() -> ElectrsSyncStatus {
None
};
// Read Tor onion address if available
let tor_onion = tokio::fs::read_to_string(
"/var/lib/archipelago/tor/hidden_service_electrs/hostname",
)
.await
.ok()
.map(|s| s.trim().to_string());
let network_height = match bitcoin_network_height().await {
Ok(h) => h,
Err(e) => {
@@ -156,6 +166,7 @@ pub async fn get_electrs_sync_status() -> ElectrsSyncStatus {
status: "error".to_string(),
error: Some(format!("Bitcoin RPC: {}", e)),
index_size,
tor_onion,
};
}
};
@@ -196,6 +207,7 @@ pub async fn get_electrs_sync_status() -> ElectrsSyncStatus {
status,
error,
index_size,
tor_onion: tor_onion.clone(),
};
}
Err(e) => {
@@ -206,6 +218,7 @@ pub async fn get_electrs_sync_status() -> ElectrsSyncStatus {
status: "error".to_string(),
error: Some(format!("Task: {}", e)),
index_size,
tor_onion: tor_onion.clone(),
};
}
};
@@ -229,5 +242,6 @@ pub async fn get_electrs_sync_status() -> ElectrsSyncStatus {
status: status.to_string(),
error: None,
index_size,
tor_onion,
}
}
+5
View File
@@ -61,6 +61,11 @@ impl NodeIdentity {
})
}
/// Access the signing key (for key derivation, e.g. mesh encryption).
pub fn signing_key(&self) -> &SigningKey {
&self.signing_key
}
/// Public key as hex string (for ServerInfo, Nostr, etc.)
pub fn pubkey_hex(&self) -> String {
hex::encode(self.signing_key.verifying_key().as_bytes())
+1
View File
@@ -25,6 +25,7 @@ mod identity_manager;
mod marketplace;
mod mesh;
mod monitoring;
mod transport;
mod node_message;
mod nostr_discovery;
mod nostr_handshake;
-265
View File
@@ -1,265 +0,0 @@
//! Mesh networking: local node discovery over LoRa (Meshtastic) and BLE.
//!
//! Broadcasts node identity over mesh radio networks for offline peer discovery.
//! Uses Meshtastic serial protocol when a compatible radio is connected via USB.
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
use tokio::fs;
const MESH_CONFIG_FILE: &str = "mesh-config.json";
/// A node discovered via mesh radio.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MeshNode {
pub node_id: String,
pub did: Option<String>,
pub pubkey: Option<String>,
pub rssi: Option<i32>,
pub snr: Option<f64>,
pub last_heard: String,
#[serde(default)]
pub hops: u32,
#[serde(default)]
pub channel: Option<String>,
}
/// Mesh configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MeshConfig {
pub enabled: bool,
#[serde(default)]
pub device_path: Option<String>,
#[serde(default)]
pub channel_name: Option<String>,
#[serde(default)]
pub broadcast_identity: bool,
}
impl Default for MeshConfig {
fn default() -> Self {
Self {
enabled: false,
device_path: None,
channel_name: Some("archipelago".to_string()),
broadcast_identity: true,
}
}
}
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 Meshtastic-compatible USB devices.
/// Meshtastic radios typically appear as USB serial devices (CP210x, CH340, FTDI).
pub async fn detect_meshtastic_devices() -> Vec<String> {
let mut devices = Vec::new();
// Check for common serial device paths
let candidates = [
"/dev/ttyUSB0",
"/dev/ttyUSB1",
"/dev/ttyACM0",
"/dev/ttyACM1",
];
for path in &candidates {
if tokio::fs::metadata(path).await.is_ok() {
devices.push(path.to_string());
}
}
// Also scan sysfs for Meshtastic-specific USB VIDs
if let Ok(mut entries) = tokio::fs::read_dir("/sys/bus/usb/devices").await {
while let Ok(Some(entry)) = entries.next_entry().await {
let vid_path = entry.path().join("idVendor");
if let Ok(vid_str) = tokio::fs::read_to_string(&vid_path).await {
let vid = vid_str.trim();
// Silicon Labs CP210x (common Meshtastic radio)
// CH340 USB-serial
// FTDI FT232
if vid == "10c4" || vid == "1a86" || vid == "0403" {
let product = tokio::fs::read_to_string(entry.path().join("product"))
.await
.map(|s| s.trim().to_string())
.unwrap_or_else(|_| "Serial Device".to_string());
devices.push(format!("{} ({})", entry.path().display(), product));
}
}
}
}
devices
}
/// Discover nodes via Meshtastic CLI (meshtastic --nodes).
/// Returns nodes that have broadcast their Archipelago identity.
pub async fn discover_nodes(device_path: Option<&str>) -> Result<Vec<MeshNode>> {
let mut cmd = tokio::process::Command::new("meshtastic");
cmd.arg("--nodes");
if let Some(dev) = device_path {
cmd.arg("--port").arg(dev);
}
let output = cmd
.output()
.await
.context("Failed to run meshtastic CLI — is it installed?")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("No Meshtastic") || stderr.contains("not found") {
return Ok(Vec::new());
}
anyhow::bail!("meshtastic --nodes failed: {}", stderr);
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut nodes = Vec::new();
// Parse the meshtastic CLI node list output
// Format varies but typically: NodeNum | User | AKA | ...
for line in stdout.lines().skip(2) {
// Skip header lines
let parts: Vec<&str> = line.split('|').map(|s| s.trim()).collect();
if parts.len() < 3 {
continue;
}
let node_id = parts.first().unwrap_or(&"").to_string();
if node_id.is_empty() || node_id.starts_with('-') {
continue;
}
nodes.push(MeshNode {
node_id: node_id.trim().to_string(),
did: None,
pubkey: None,
rssi: None,
snr: None,
last_heard: chrono::Utc::now().to_rfc3339(),
hops: 0,
channel: None,
});
}
Ok(nodes)
}
/// Broadcast our node identity over mesh.
pub async fn broadcast_identity(
did: &str,
pubkey: &str,
device_path: Option<&str>,
) -> Result<()> {
let message = format!("ARCHY:{}:{}", did, pubkey);
let mut cmd = tokio::process::Command::new("meshtastic");
cmd.arg("--sendtext").arg(&message);
if let Some(dev) = device_path {
cmd.arg("--port").arg(dev);
}
let output = cmd
.output()
.await
.context("Failed to broadcast via meshtastic")?;
if !output.status.success() {
anyhow::bail!(
"meshtastic broadcast failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
Ok(())
}
#[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,
};
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()));
}
#[test]
fn test_mesh_node_serialization() {
let node = MeshNode {
node_id: "!aabbccdd".to_string(),
did: Some("did:key:z123".to_string()),
pubkey: Some("pubhex".to_string()),
rssi: Some(-85),
snr: Some(7.5),
last_heard: "2026-03-10T00:00:00Z".to_string(),
hops: 1,
channel: Some("archipelago".to_string()),
};
let json = serde_json::to_string(&node).unwrap();
let parsed: MeshNode = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.node_id, "!aabbccdd");
assert_eq!(parsed.rssi, Some(-85));
}
#[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,
};
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()));
}
}
+217
View File
@@ -0,0 +1,217 @@
//! Mesh message encryption: X25519 ECDH key agreement + ChaCha20-Poly1305.
//!
//! Reuses Archipelago's existing Ed25519 identity infrastructure.
//! Ed25519 keys are converted to X25519 for Diffie-Hellman key exchange,
//! then ChaCha20-Poly1305 encrypts each message with a unique random nonce.
use anyhow::Result;
use chacha20poly1305::aead::{Aead, KeyInit};
use chacha20poly1305::{ChaCha20Poly1305, Nonce};
use rand::RngCore;
/// Nonce size for ChaCha20-Poly1305.
const NONCE_SIZE: usize = 12;
/// Auth tag size for ChaCha20-Poly1305.
const TAG_SIZE: usize = 16;
/// Minimum ciphertext size: nonce + at least 1 byte + tag.
const MIN_CIPHERTEXT_SIZE: usize = NONCE_SIZE + 1 + TAG_SIZE;
/// Convert an Ed25519 public key (32 bytes) to an X25519 public key (32 bytes).
/// Uses the standard Edwards-to-Montgomery conversion.
pub fn ed25519_pubkey_to_x25519(ed_pubkey: &[u8; 32]) -> Result<[u8; 32]> {
let compressed = curve25519_dalek::edwards::CompressedEdwardsY(*ed_pubkey);
let point = compressed
.decompress()
.ok_or_else(|| anyhow::anyhow!("Invalid Ed25519 public key: decompression failed"))?;
let montgomery = point.to_montgomery();
Ok(*montgomery.as_bytes())
}
/// Convert an Ed25519 signing key to an X25519 secret key.
/// Applies SHA-512 clamping as per RFC 7748.
pub fn ed25519_secret_to_x25519(signing_key: &ed25519_dalek::SigningKey) -> [u8; 32] {
// The X25519 secret is derived from the first 32 bytes of SHA-512(ed25519_secret)
// with clamping applied. ed25519-dalek's to_scalar() handles this.
let hash = <sha2::Sha512 as sha2::Digest>::digest(signing_key.to_bytes());
let mut x25519_secret = [0u8; 32];
x25519_secret.copy_from_slice(&hash[..32]);
// Clamp per RFC 7748
x25519_secret[0] &= 248;
x25519_secret[31] &= 127;
x25519_secret[31] |= 64;
x25519_secret
}
/// Perform X25519 Diffie-Hellman key agreement.
/// Returns a 32-byte shared secret.
pub fn x25519_shared_secret(our_secret: &[u8; 32], their_public: &[u8; 32]) -> [u8; 32] {
use curve25519_dalek::montgomery::MontgomeryPoint;
use curve25519_dalek::scalar::Scalar;
let their_point = MontgomeryPoint(*their_public);
let our_scalar = Scalar::from_bytes_mod_order(*our_secret);
let shared = their_point * our_scalar;
*shared.as_bytes()
}
/// Encrypt plaintext with ChaCha20-Poly1305 using a shared secret.
/// Output format: [nonce (12 bytes)] + [ciphertext + tag (16 bytes)]
///
/// Each call generates a fresh random 12-byte nonce via OsRng (CSPRNG).
pub fn encrypt(shared_secret: &[u8; 32], plaintext: &[u8]) -> Result<Vec<u8>> {
let cipher = ChaCha20Poly1305::new_from_slice(shared_secret)
.map_err(|e| anyhow::anyhow!("Failed to create cipher: {}", e))?;
let mut nonce_bytes = [0u8; NONCE_SIZE];
rand::rngs::OsRng.fill_bytes(&mut nonce_bytes);
let nonce = Nonce::from_slice(&nonce_bytes);
let ciphertext = cipher
.encrypt(nonce, plaintext)
.map_err(|e| anyhow::anyhow!("Encryption failed: {}", e))?;
let mut output = Vec::with_capacity(NONCE_SIZE + ciphertext.len());
output.extend_from_slice(&nonce_bytes);
output.extend_from_slice(&ciphertext);
Ok(output)
}
/// Decrypt ciphertext produced by `encrypt()`.
/// Input format: [nonce (12 bytes)] + [ciphertext + tag (16 bytes)]
pub fn decrypt(shared_secret: &[u8; 32], data: &[u8]) -> Result<Vec<u8>> {
if data.len() < MIN_CIPHERTEXT_SIZE {
anyhow::bail!(
"Ciphertext too short: {} bytes (minimum {})",
data.len(),
MIN_CIPHERTEXT_SIZE
);
}
let nonce = Nonce::from_slice(&data[..NONCE_SIZE]);
let ciphertext = &data[NONCE_SIZE..];
let cipher = ChaCha20Poly1305::new_from_slice(shared_secret)
.map_err(|e| anyhow::anyhow!("Failed to create cipher: {}", e))?;
cipher
.decrypt(nonce, ciphertext)
.map_err(|_| anyhow::anyhow!("Decryption failed: invalid key or corrupted message"))
}
/// Maximum plaintext bytes that fit in a single encrypted LoRa message.
/// 160 (max LoRa payload) - 12 (nonce) - 16 (tag) = 132 bytes.
pub const MAX_ENCRYPTED_PLAINTEXT: usize = 160 - NONCE_SIZE - TAG_SIZE;
#[cfg(test)]
mod tests {
use super::*;
use ed25519_dalek::SigningKey;
use rand::rngs::OsRng;
#[test]
fn test_encrypt_decrypt_roundtrip() {
let shared_secret = [42u8; 32];
let plaintext = b"hello from mesh";
let ciphertext = encrypt(&shared_secret, plaintext).unwrap();
assert!(ciphertext.len() > plaintext.len()); // nonce + tag overhead
let decrypted = decrypt(&shared_secret, &ciphertext).unwrap();
assert_eq!(decrypted, plaintext);
}
#[test]
fn test_decrypt_wrong_key() {
let secret1 = [1u8; 32];
let secret2 = [2u8; 32];
let ciphertext = encrypt(&secret1, b"secret").unwrap();
assert!(decrypt(&secret2, &ciphertext).is_err());
}
#[test]
fn test_decrypt_corrupted() {
let secret = [42u8; 32];
let mut ciphertext = encrypt(&secret, b"test").unwrap();
// Flip a byte in the ciphertext (after nonce)
let idx = NONCE_SIZE + 1;
ciphertext[idx] ^= 0xFF;
assert!(decrypt(&secret, &ciphertext).is_err());
}
#[test]
fn test_decrypt_too_short() {
let secret = [42u8; 32];
assert!(decrypt(&secret, &[0u8; 10]).is_err());
}
#[test]
fn test_unique_nonces() {
let secret = [42u8; 32];
let ct1 = encrypt(&secret, b"same").unwrap();
let ct2 = encrypt(&secret, b"same").unwrap();
// Nonces (first 12 bytes) should differ
assert_ne!(&ct1[..NONCE_SIZE], &ct2[..NONCE_SIZE]);
}
#[test]
fn test_ed25519_to_x25519_pubkey() {
let signing_key = SigningKey::generate(&mut OsRng);
let ed_pubkey = signing_key.verifying_key().to_bytes();
let x25519 = ed25519_pubkey_to_x25519(&ed_pubkey).unwrap();
// Should produce 32 non-zero bytes
assert_eq!(x25519.len(), 32);
assert!(x25519.iter().any(|&b| b != 0));
}
#[test]
fn test_x25519_key_agreement() {
// Generate two Ed25519 keypairs
let alice_signing = SigningKey::generate(&mut OsRng);
let bob_signing = SigningKey::generate(&mut OsRng);
// Convert to X25519
let alice_secret = ed25519_secret_to_x25519(&alice_signing);
let bob_secret = ed25519_secret_to_x25519(&bob_signing);
let alice_public = ed25519_pubkey_to_x25519(&alice_signing.verifying_key().to_bytes()).unwrap();
let bob_public = ed25519_pubkey_to_x25519(&bob_signing.verifying_key().to_bytes()).unwrap();
// Both sides should derive the same shared secret
let shared_ab = x25519_shared_secret(&alice_secret, &bob_public);
let shared_ba = x25519_shared_secret(&bob_secret, &alice_public);
assert_eq!(shared_ab, shared_ba);
}
#[test]
fn test_full_encrypt_decrypt_with_key_agreement() {
let alice_signing = SigningKey::generate(&mut OsRng);
let bob_signing = SigningKey::generate(&mut OsRng);
let alice_secret = ed25519_secret_to_x25519(&alice_signing);
let bob_secret = ed25519_secret_to_x25519(&bob_signing);
let alice_public = ed25519_pubkey_to_x25519(&alice_signing.verifying_key().to_bytes()).unwrap();
let bob_public = ed25519_pubkey_to_x25519(&bob_signing.verifying_key().to_bytes()).unwrap();
let shared = x25519_shared_secret(&alice_secret, &bob_public);
// Alice encrypts
let plaintext = b"sats over mesh";
let ciphertext = encrypt(&shared, plaintext).unwrap();
// Bob decrypts with same shared secret
let bob_shared = x25519_shared_secret(&bob_secret, &alice_public);
let decrypted = decrypt(&bob_shared, &ciphertext).unwrap();
assert_eq!(decrypted, plaintext);
}
#[test]
fn test_max_encrypted_plaintext_fits() {
let secret = [42u8; 32];
let plaintext = vec![0xAB; MAX_ENCRYPTED_PLAINTEXT];
let ciphertext = encrypt(&secret, &plaintext).unwrap();
// Should fit within LoRa max message size (160 bytes)
assert!(ciphertext.len() <= 160);
}
}
+656
View File
@@ -0,0 +1,656 @@
//! Background mesh listener task.
//!
//! Runs as a long-lived tokio task that:
//! - Maintains the serial connection to the Meshcore device
//! - Reads incoming frames and dispatches events
//! - Periodically broadcasts our identity advertisement
//! - Reconnects on device disconnect
//! - Manages peer cache and message store
use super::crypto;
use super::protocol;
use super::serial::MeshcoreDevice;
use super::types::*;
use anyhow::Result;
use std::collections::{HashMap, VecDeque};
use tokio::sync::mpsc;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{broadcast, RwLock};
use tracing::{debug, error, info, warn};
/// How often to broadcast our identity advertisement (seconds).
const ADVERT_INTERVAL: Duration = Duration::from_secs(60);
/// How often to poll for queued messages when no push notifications.
const SYNC_INTERVAL: Duration = Duration::from_secs(10);
/// Maximum stored messages (circular buffer).
const MAX_MESSAGES: usize = 100;
/// Delay before reconnection attempt after device disconnect.
const RECONNECT_DELAY: Duration = Duration::from_secs(10);
/// Command sent from MeshService to the listener task (which owns the serial port).
pub enum MeshCommand {
SendText { dest_pubkey_prefix: [u8; 6], payload: Vec<u8> },
SendAdvert,
}
/// Shared state for the mesh listener, accessible from RPC handlers.
pub struct MeshState {
pub peers: RwLock<HashMap<u32, MeshPeer>>,
pub messages: RwLock<VecDeque<MeshMessage>>,
pub shared_secrets: RwLock<HashMap<u32, [u8; 32]>>,
pub status: RwLock<MeshStatus>,
pub event_tx: broadcast::Sender<MeshEvent>,
pub cmd_tx: mpsc::Sender<MeshCommand>,
next_message_id: RwLock<u64>,
}
impl MeshState {
pub fn new(channel_name: &str) -> (Arc<Self>, broadcast::Receiver<MeshEvent>, mpsc::Receiver<MeshCommand>) {
let (tx, rx) = broadcast::channel(64);
let (cmd_tx, cmd_rx) = mpsc::channel(32);
let state = Arc::new(Self {
peers: RwLock::new(HashMap::new()),
messages: RwLock::new(VecDeque::new()),
shared_secrets: RwLock::new(HashMap::new()),
cmd_tx,
status: RwLock::new(MeshStatus {
enabled: true,
device_type: DeviceType::Unknown,
device_path: None,
device_connected: false,
firmware_version: None,
self_node_id: None,
self_advert_name: None,
peer_count: 0,
channel_name: channel_name.to_string(),
messages_sent: 0,
messages_received: 0,
}),
event_tx: tx,
next_message_id: RwLock::new(1),
});
(state, rx, cmd_rx)
}
pub async fn next_id(&self) -> u64 {
let mut id = self.next_message_id.write().await;
let current = *id;
*id += 1;
current
}
pub async fn store_message(&self, msg: MeshMessage) {
let mut messages = self.messages.write().await;
messages.push_back(msg);
if messages.len() > MAX_MESSAGES {
messages.pop_front();
}
}
async fn update_peer_count(&self) {
let count = self.peers.read().await.len();
self.status.write().await.peer_count = count;
}
}
/// Spawn the background mesh listener task.
///
/// This task manages the full lifecycle:
/// 1. Detect and connect to Meshcore device
/// 2. Initialize and set advert name
/// 3. Main loop: read frames, dispatch events, periodic adverts
/// 4. Reconnect on disconnect
pub fn spawn_mesh_listener(
state: Arc<MeshState>,
device_path: Option<String>,
our_did: String,
our_ed_pubkey_hex: String,
our_x25519_secret: [u8; 32],
our_x25519_pubkey_hex: String,
shutdown: tokio::sync::watch::Receiver<bool>,
cmd_rx: mpsc::Receiver<MeshCommand>,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
let mut shutdown = shutdown;
let mut cmd_rx = cmd_rx;
loop {
if *shutdown.borrow() {
info!("Mesh listener shutting down");
return;
}
match run_mesh_session(
&state,
device_path.as_deref(),
&our_did,
&our_ed_pubkey_hex,
&our_x25519_secret,
&our_x25519_pubkey_hex,
&mut shutdown,
&mut cmd_rx,
)
.await
{
Ok(()) => {
info!("Mesh session ended cleanly");
}
Err(e) => {
error!("Mesh session error: {}", e);
}
}
// Update status to disconnected
{
let mut status = state.status.write().await;
status.device_connected = false;
status.device_path = None;
}
let _ = state.event_tx.send(MeshEvent::DeviceDisconnected);
// Wait before reconnecting
tokio::select! {
_ = tokio::time::sleep(RECONNECT_DELAY) => {},
_ = shutdown.changed() => {
if *shutdown.borrow() { return; }
},
}
}
})
}
/// Run a single mesh session (connect, initialize, main loop).
async fn run_mesh_session(
state: &Arc<MeshState>,
preferred_path: Option<&str>,
our_did: &str,
our_ed_pubkey_hex: &str,
our_x25519_secret: &[u8; 32],
our_x25519_pubkey_hex: &str,
shutdown: &mut tokio::sync::watch::Receiver<bool>,
cmd_rx: &mut mpsc::Receiver<MeshCommand>,
) -> Result<()> {
// Detect device
let device_path = if let Some(path) = preferred_path {
path.to_string()
} else {
let paths = super::serial::detect_serial_devices().await;
if paths.is_empty() {
anyhow::bail!("No serial devices found");
}
match super::serial::probe_for_meshcore(&paths).await {
Some((path, _)) => path,
None => anyhow::bail!("No Meshcore device found on available serial ports"),
}
};
// Open and initialize
let mut device = MeshcoreDevice::open(&device_path).await?;
let device_info = device.initialize().await?;
// Update status
{
let mut status = state.status.write().await;
status.device_connected = true;
status.device_type = DeviceType::Meshcore;
status.device_path = Some(device_path.clone());
status.firmware_version = Some(device_info.firmware_version.clone());
status.self_node_id = Some(device_info.node_id);
status.self_advert_name = device.advert_name.clone();
}
let _ = state.event_tx.send(MeshEvent::DeviceConnected(device_info));
// Set advert name to something identifiable
let short_did = our_did.chars().skip(8).take(8).collect::<String>();
let advert_name = format!("Archy-{}", short_did);
if let Err(e) = device.set_advert_name(&advert_name).await {
warn!("Failed to set advert name: {}", e);
}
// Broadcast our advertisement so other nodes can discover us
if let Err(e) = device.send_self_advert().await {
warn!("Failed to send initial advert: {}", e);
}
// Fetch existing contacts from the device
refresh_contacts(&mut device, state).await;
// Sync any queued messages from before we connected
sync_queued_messages(&mut device, state, our_x25519_secret).await;
// Main loop
let mut advert_timer = tokio::time::interval(ADVERT_INTERVAL);
let mut sync_timer = tokio::time::interval(SYNC_INTERVAL);
advert_timer.tick().await; // skip first immediate tick
sync_timer.tick().await;
loop {
tokio::select! {
// Check for incoming frames
frame_result = device.try_recv_frame() => {
match frame_result {
Ok(Some(frame)) => {
let should_action = handle_frame(
&frame,
state,
our_x25519_secret,
).await;
if should_action {
// Contact discovery or messages waiting — sync both
refresh_contacts(&mut device, state).await;
sync_queued_messages(&mut device, state, our_x25519_secret).await;
}
}
Ok(None) => {
// No complete frame yet, that's fine
tokio::time::sleep(Duration::from_millis(50)).await;
}
Err(e) => {
error!("Serial read error: {}", e);
return Err(e);
}
}
}
// Periodic advertisement broadcast + contact refresh
_ = advert_timer.tick() => {
debug!("Periodic self-advert broadcast");
if let Err(e) = device.send_self_advert().await {
warn!("Failed to send advert: {}", e);
}
refresh_contacts(&mut device, state).await;
}
// Process send commands from MeshService
Some(cmd) = cmd_rx.recv() => {
match cmd {
MeshCommand::SendText { dest_pubkey_prefix, payload } => {
if let Err(e) = device.send_text(&dest_pubkey_prefix, &payload).await {
warn!("Failed to send text via mesh: {}", e);
} else {
info!(dest = %hex::encode(dest_pubkey_prefix), len = payload.len(), "Sent mesh message");
}
}
MeshCommand::SendAdvert => {
if let Err(e) = device.send_self_advert().await {
warn!("Failed to send advert: {}", e);
}
}
}
}
// Periodic message sync
_ = sync_timer.tick() => {
sync_queued_messages(&mut device, state, our_x25519_secret).await;
}
// Shutdown signal
_ = shutdown.changed() => {
if *shutdown.borrow() {
info!("Mesh listener received shutdown signal");
return Ok(());
}
}
}
}
}
/// Handle a single inbound frame from the device.
/// Returns `true` if contacts should be refreshed from the device.
async fn handle_frame(
frame: &protocol::InboundFrame,
state: &Arc<MeshState>,
our_x25519_secret: &[u8; 32],
) -> bool {
match frame.code {
protocol::PUSH_NEW_CONTACT | protocol::PUSH_CONTACT_ADVERT => {
info!(code = frame.code, "Contact discovery event — refreshing contacts");
return true; // Signal caller to fetch contacts
}
protocol::PUSH_ACK => {
debug!("Message delivery confirmed");
// Could track which message was ACKed from frame.data
}
protocol::PUSH_MESSAGES_WAITING => {
info!("Device has messages waiting — will sync");
return true; // Signal caller to sync immediately
}
protocol::RESP_CONTACT_MSG_V3 => {
// Direct message received (v3 format)
match protocol::parse_contact_msg_v3(&frame.data) {
Ok((sender_prefix, text, _snr)) => {
if !text.is_empty() {
let peer_name = {
let peers = state.peers.read().await;
peers.values()
.find(|p| p.pubkey_hex.as_ref().map(|k| k.starts_with(&sender_prefix)).unwrap_or(false))
.map(|p| (p.contact_id, p.advert_name.clone()))
};
let (contact_id, name) = peer_name.unwrap_or((0, sender_prefix.clone()));
let msg_id = state.next_id().await;
let msg = MeshMessage {
id: msg_id,
direction: MessageDirection::Received,
peer_contact_id: contact_id,
peer_name: Some(name),
plaintext: text,
timestamp: chrono::Utc::now().to_rfc3339(),
delivered: true,
encrypted: false,
};
state.store_message(msg.clone()).await;
state.status.write().await.messages_received += 1;
info!(from = %sender_prefix, "Received mesh DM (v3)");
let _ = state.event_tx.send(MeshEvent::MessageReceived(msg));
}
}
Err(e) => warn!("Failed to parse v3 message: {}", e),
}
}
protocol::RESP_CONTACT_MSG => {
// Direct message received (v1 format)
match protocol::parse_contact_msg_v1(&frame.data) {
Ok((sender_prefix, text)) => {
if !text.is_empty() {
let peer_name = {
let peers = state.peers.read().await;
peers.values()
.find(|p| p.pubkey_hex.as_ref().map(|k| k.starts_with(&sender_prefix)).unwrap_or(false))
.map(|p| (p.contact_id, p.advert_name.clone()))
};
let (contact_id, name) = peer_name.unwrap_or((0, sender_prefix.clone()));
let msg_id = state.next_id().await;
let msg = MeshMessage {
id: msg_id,
direction: MessageDirection::Received,
peer_contact_id: contact_id,
peer_name: Some(name),
plaintext: text,
timestamp: chrono::Utc::now().to_rfc3339(),
delivered: true,
encrypted: false,
};
state.store_message(msg.clone()).await;
state.status.write().await.messages_received += 1;
info!(from = %sender_prefix, "Received mesh DM (v1)");
let _ = state.event_tx.send(MeshEvent::MessageReceived(msg));
}
}
Err(e) => warn!("Failed to parse v1 message: {}", e),
}
}
protocol::RESP_CHANNEL_MSG_V3 => {
// Channel broadcast received (v3)
match protocol::parse_channel_msg_v3(&frame.data) {
Ok((channel_idx, text)) => {
if !text.is_empty() {
let msg_id = state.next_id().await;
let chan_contact_id = -((channel_idx as i32) + 1);
let msg = MeshMessage {
id: msg_id,
direction: MessageDirection::Received,
peer_contact_id: chan_contact_id as u32,
peer_name: Some(format!("Channel {}", channel_idx)),
plaintext: text,
timestamp: chrono::Utc::now().to_rfc3339(),
delivered: true,
encrypted: false,
};
state.store_message(msg.clone()).await;
state.status.write().await.messages_received += 1;
info!(channel = channel_idx, "Received mesh channel message (v3)");
let _ = state.event_tx.send(MeshEvent::MessageReceived(msg));
}
}
Err(e) => warn!("Failed to parse v3 channel message: {}", e),
}
}
protocol::RESP_CHANNEL_MSG => {
// Channel broadcast received (v1)
match protocol::parse_channel_msg_v1(&frame.data) {
Ok((channel_idx, text)) => {
if !text.is_empty() {
let msg_id = state.next_id().await;
let chan_contact_id = -((channel_idx as i32) + 1);
let msg = MeshMessage {
id: msg_id,
direction: MessageDirection::Received,
peer_contact_id: chan_contact_id as u32,
peer_name: Some(format!("Channel {}", channel_idx)),
plaintext: text,
timestamp: chrono::Utc::now().to_rfc3339(),
delivered: true,
encrypted: false,
};
state.store_message(msg.clone()).await;
state.status.write().await.messages_received += 1;
info!(channel = channel_idx, "Received mesh channel message");
let _ = state.event_tx.send(MeshEvent::MessageReceived(msg));
}
}
Err(e) => warn!("Failed to parse channel message: {}", e),
}
}
protocol::PUSH_LOG_DATA | protocol::PUSH_PATH_UPDATE | protocol::PUSH_RAW_DATA => {
// Internal device logging/path data — safe to ignore
}
_ => {
if protocol::is_push_notification(frame.code) {
debug!(code = frame.code, "Unhandled push notification");
}
}
}
false
}
/// Handle a received identity broadcast from a peer.
async fn handle_identity_received(
contact_id: u32,
rssi: i16,
did: &str,
ed_pubkey_hex: &str,
x25519_pubkey_hex: &str,
state: &Arc<MeshState>,
our_x25519_secret: &[u8; 32],
) {
info!(
contact_id,
did = %did,
rssi,
"Archipelago peer discovered over mesh"
);
// Decode X25519 public key
let x25519_bytes = match hex::decode(x25519_pubkey_hex) {
Ok(b) if b.len() == 32 => {
let mut arr = [0u8; 32];
arr.copy_from_slice(&b);
arr
}
_ => {
warn!("Invalid X25519 public key from peer");
return;
}
};
// Derive shared secret for encrypted messaging
let shared_secret = crypto::x25519_shared_secret(our_x25519_secret, &x25519_bytes);
state
.shared_secrets
.write()
.await
.insert(contact_id, shared_secret);
// Update peer record
let peer = MeshPeer {
contact_id,
advert_name: format!("Archy-{}", &did[8..16.min(did.len())]),
did: Some(did.to_string()),
pubkey_hex: Some(ed_pubkey_hex.to_string()),
x25519_pubkey: Some(x25519_bytes),
rssi: Some(rssi),
snr: None,
last_heard: chrono::Utc::now().to_rfc3339(),
hops: 0,
};
let is_new = {
let mut peers = state.peers.write().await;
let is_new = !peers.contains_key(&contact_id);
peers.insert(contact_id, peer.clone());
is_new
};
state.update_peer_count().await;
let event = if is_new {
MeshEvent::PeerDiscovered(peer)
} else {
MeshEvent::PeerUpdated(peer)
};
let _ = state.event_tx.send(event);
let _ = state.event_tx.send(MeshEvent::IdentityReceived {
contact_id,
did: did.to_string(),
pubkey_hex: ed_pubkey_hex.to_string(),
x25519_pubkey: x25519_bytes,
});
}
/// Handle a received message (direct or channel).
async fn handle_received_message(
contact_id: u32,
payload: &[u8],
rssi: i16,
is_channel: bool,
state: &Arc<MeshState>,
_our_x25519_secret: &[u8; 32],
) {
// Try to decrypt if we have a shared secret for this contact
let shared_secrets = state.shared_secrets.read().await;
let (plaintext, encrypted) = if let Some(secret) = shared_secrets.get(&contact_id) {
match crypto::decrypt(secret, payload) {
Ok(pt) => (String::from_utf8_lossy(&pt).to_string(), true),
Err(_) => {
// Not encrypted or wrong key — treat as plaintext
(String::from_utf8_lossy(payload).to_string(), false)
}
}
} else {
(String::from_utf8_lossy(payload).to_string(), false)
};
drop(shared_secrets);
// Update peer last_heard
{
let mut peers = state.peers.write().await;
if let Some(peer) = peers.get_mut(&contact_id) {
peer.last_heard = chrono::Utc::now().to_rfc3339();
peer.rssi = Some(rssi);
}
}
let peer_name = state
.peers
.read()
.await
.get(&contact_id)
.map(|p| p.advert_name.clone());
let msg_id = state.next_id().await;
let msg = MeshMessage {
id: msg_id,
direction: MessageDirection::Received,
peer_contact_id: contact_id,
peer_name,
plaintext: plaintext.clone(),
timestamp: chrono::Utc::now().to_rfc3339(),
delivered: true,
encrypted,
};
state.store_message(msg.clone()).await;
{
let mut status = state.status.write().await;
status.messages_received += 1;
}
info!(
contact_id,
encrypted,
channel = is_channel,
"Received mesh message"
);
let _ = state.event_tx.send(MeshEvent::MessageReceived(msg));
}
/// Drain any queued messages from the device.
async fn sync_queued_messages(
device: &mut MeshcoreDevice,
state: &Arc<MeshState>,
our_x25519_secret: &[u8; 32],
) {
match device.sync_messages().await {
Ok(frames) => {
for frame in &frames {
handle_frame(frame, state, our_x25519_secret).await;
}
if !frames.is_empty() {
info!(count = frames.len(), "Synced queued mesh messages");
}
}
Err(e) => {
debug!("Message sync: {}", e);
}
}
}
/// Fetch the contacts list from the device and update the peer cache.
async fn refresh_contacts(
device: &mut MeshcoreDevice,
state: &Arc<MeshState>,
) {
match device.get_contacts().await {
Ok(contacts) => {
let mut peers = state.peers.write().await;
for (idx, contact) in contacts.iter().enumerate() {
let contact_id = idx as u32;
let existing = peers.get(&contact_id);
let peer = MeshPeer {
contact_id,
advert_name: contact.advert_name.clone(),
did: existing.and_then(|p| p.did.clone()),
pubkey_hex: Some(contact.public_key_hex.clone()),
x25519_pubkey: existing.and_then(|p| p.x25519_pubkey),
rssi: None,
snr: None,
last_heard: chrono::Utc::now().to_rfc3339(),
hops: 0,
};
peers.insert(contact_id, peer);
}
drop(peers);
state.update_peer_count().await;
if !contacts.is_empty() {
info!(count = contacts.len(), "Refreshed mesh contacts");
}
}
Err(e) => {
warn!("Failed to fetch contacts: {}", e);
}
}
}
+401
View File
@@ -0,0 +1,401 @@
//! 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 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;
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()));
}
}
+661
View File
@@ -0,0 +1,661 @@
//! Meshcore binary frame protocol: constants, encoding, decoding, command builders.
//!
//! Frame format (USB serial):
//! - Outbound (host -> device): `<` (0x3C) + 2-byte LE length + frame data
//! - Inbound (device -> host): `>` (0x3E) + 2-byte LE length + frame data
//! - Baud: 115200, 8N1
//! - Max message payload: 160 bytes
use anyhow::Result;
// --- Frame markers ---
pub const OUTBOUND_MARKER: u8 = 0x3C; // '<' (host -> device)
pub const INBOUND_MARKER: u8 = 0x3E; // '>' (device -> host)
// --- Commands (host -> device) ---
pub const CMD_APP_START: u8 = 0x01;
pub const CMD_SEND_TXT_MSG: u8 = 0x02;
pub const CMD_SEND_CHANNEL_TXT_MSG: u8 = 0x03;
pub const CMD_GET_CONTACTS: u8 = 0x04;
pub const CMD_GET_DEVICE_TIME: u8 = 0x05;
pub const CMD_SET_DEVICE_TIME: u8 = 0x06;
pub const CMD_SEND_SELF_ADVERT: u8 = 0x07;
pub const CMD_SET_ADVERT_NAME: u8 = 0x08;
pub const CMD_SYNC_NEXT_MESSAGE: u8 = 0x0A;
pub const CMD_SET_RADIO_PARAMS: u8 = 0x0B;
pub const CMD_SET_RADIO_TX_POWER: u8 = 0x0C;
pub const CMD_SET_TUNING_PARAMS: u8 = 0x15;
pub const CMD_DEVICE_QUERY: u8 = 0x16;
pub const CMD_GET_CHANNEL: u8 = 0x1F;
pub const CMD_SET_CHANNEL: u8 = 0x20;
pub const CMD_GET_STATS: u8 = 0x38;
// --- Response codes (device -> host, synchronous) ---
pub const RESP_OK: u8 = 0x00;
pub const RESP_ERR: u8 = 0x01;
pub const RESP_CONTACT_START: u8 = 0x02;
pub const RESP_CONTACT: u8 = 0x03;
pub const RESP_CONTACT_END: u8 = 0x04;
pub const RESP_SELF_INFO: u8 = 0x05;
pub const RESP_SENT: u8 = 0x06;
pub const RESP_CONTACT_MSG: u8 = 0x07;
pub const RESP_CHANNEL_MSG: u8 = 0x08;
pub const RESP_CURRENT_TIME: u8 = 0x09;
pub const RESP_NO_MORE_MESSAGES: u8 = 0x0A;
pub const RESP_CONTACT_URI: u8 = 0x0B;
pub const RESP_BATTERY: u8 = 0x0C;
pub const RESP_DEVICE_INFO: u8 = 0x0D;
pub const RESP_CONTACT_MSG_V3: u8 = 0x10;
pub const RESP_CHANNEL_MSG_V3: u8 = 0x11;
pub const RESP_CHANNEL_INFO: u8 = 0x12;
pub const RESP_STATS: u8 = 0x18;
// --- Push notification codes (device -> host, async, >= 0x80) ---
pub const PUSH_CONTACT_ADVERT: u8 = 0x80;
pub const PUSH_PATH_UPDATE: u8 = 0x81;
pub const PUSH_ACK: u8 = 0x82;
pub const PUSH_MESSAGES_WAITING: u8 = 0x83;
pub const PUSH_RAW_DATA: u8 = 0x84;
pub const PUSH_LOG_DATA: u8 = 0x88;
pub const PUSH_NEW_CONTACT: u8 = 0x8A;
// --- Error codes ---
pub const ERR_UNSUPPORTED_CMD: u8 = 0x01;
pub const ERR_NOT_FOUND: u8 = 0x02;
pub const ERR_TABLE_FULL: u8 = 0x03;
pub const ERR_BAD_STATE: u8 = 0x04;
pub const ERR_FILE_IO: u8 = 0x05;
pub const ERR_ILLEGAL_ARG: u8 = 0x06;
/// Maximum payload size for a single LoRa message.
pub const MAX_MESSAGE_LEN: usize = 160;
/// Minimum frame size: marker (1) + length (2) + command/response (1) = 4 bytes.
const MIN_FRAME_SIZE: usize = 4;
/// Protocol version we advertise during handshake.
const PROTOCOL_VERSION: u8 = 3;
// ─── Frame encoding ─────────────────────────────────────────────────────
/// Encode a command frame for sending to the device.
/// Returns: `>` + 2-byte LE length + data
pub fn encode_frame(data: &[u8]) -> Vec<u8> {
let len = data.len() as u16;
let mut frame = Vec::with_capacity(3 + data.len());
frame.push(OUTBOUND_MARKER);
frame.extend_from_slice(&len.to_le_bytes());
frame.extend_from_slice(data);
frame
}
/// Result of parsing one inbound frame from the device.
#[derive(Debug)]
pub struct InboundFrame {
/// Response or push notification code (first byte of payload).
pub code: u8,
/// Remaining payload after the code byte.
pub data: Vec<u8>,
/// Total bytes consumed from the buffer (for advancing read position).
pub bytes_consumed: usize,
}
/// Try to parse one inbound frame from a buffer.
/// Returns `None` if the buffer doesn't contain a complete frame yet.
pub fn decode_frame(buf: &[u8]) -> Option<InboundFrame> {
if buf.len() < MIN_FRAME_SIZE {
return None;
}
// Find the inbound marker
let start = buf.iter().position(|&b| b == INBOUND_MARKER)?;
let remaining = &buf[start..];
if remaining.len() < 3 {
return None;
}
let len = u16::from_le_bytes([remaining[1], remaining[2]]) as usize;
let total = 3 + len; // marker + 2 length bytes + payload
if remaining.len() < total {
return None; // incomplete frame
}
if len == 0 {
return None; // empty payload is invalid
}
let payload = &remaining[3..total];
let code = payload[0];
let data = payload[1..].to_vec();
Some(InboundFrame {
code,
data,
bytes_consumed: start + total,
})
}
// ─── Command builders ───────────────────────────────────────────────────
/// CMD_DEVICE_QUERY (0x16): Query device capabilities and negotiate protocol version.
pub fn build_device_query() -> Vec<u8> {
encode_frame(&[CMD_DEVICE_QUERY, PROTOCOL_VERSION])
}
/// CMD_APP_START (0x01): Initialize communication session.
/// Format matches official meshcore_py: [0x01][version][padded_name]
/// The official library sends: b"\x01\x03 mccli"
pub fn build_app_start(app_name: &str) -> Vec<u8> {
let mut data = vec![CMD_APP_START, PROTOCOL_VERSION];
// Pad name to 6 chars minimum (matching official library behavior)
let name_bytes = app_name.as_bytes();
let padded_len = name_bytes.len().max(6);
let len = padded_len.min(32);
// Pad with spaces if name is shorter than 6 chars
for i in 0..len {
if i < name_bytes.len() {
data.push(name_bytes[i]);
} else {
data.push(b' ');
}
}
encode_frame(&data)
}
/// CMD_SET_DEVICE_TIME (0x06): Sync device clock with Unix timestamp.
pub fn build_set_device_time(unix_secs: u64) -> Vec<u8> {
let mut data = vec![CMD_SET_DEVICE_TIME];
data.extend_from_slice(&(unix_secs as u32).to_le_bytes());
encode_frame(&data)
}
/// CMD_SET_ADVERT_NAME (0x08): Set the node's advertised name on the mesh.
pub fn build_set_advert_name(name: &str) -> Vec<u8> {
let mut data = vec![CMD_SET_ADVERT_NAME];
let name_bytes = name.as_bytes();
let len = name_bytes.len().min(32);
data.extend_from_slice(&name_bytes[..len]);
encode_frame(&data)
}
/// CMD_SEND_TXT_MSG (0x02): Send a text message to a specific contact.
/// Destination is the first 6 bytes of the contact's public key (hex decoded).
/// Format: 0x02 + 0x00 (txt_type) + attempt(1B) + timestamp(4B LE) + dest_prefix(6B) + text
pub fn build_send_text(dest_pubkey_prefix: &[u8; 6], msg: &[u8]) -> Result<Vec<u8>> {
if msg.len() > MAX_MESSAGE_LEN {
anyhow::bail!(
"Message too large for LoRa: {} bytes (max {})",
msg.len(),
MAX_MESSAGE_LEN
);
}
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as u32;
let mut data = vec![CMD_SEND_TXT_MSG, 0x00, 0x00]; // cmd + txt_type=0 + attempt=0
data.extend_from_slice(&timestamp.to_le_bytes());
data.extend_from_slice(dest_pubkey_prefix);
data.extend_from_slice(msg);
Ok(encode_frame(&data))
}
/// CMD_SEND_CHANNEL_TXT_MSG (0x03): Broadcast a text message on a channel.
pub fn build_send_channel_text(channel: u8, msg: &[u8]) -> Result<Vec<u8>> {
if msg.len() > MAX_MESSAGE_LEN {
anyhow::bail!(
"Message too large for LoRa: {} bytes (max {})",
msg.len(),
MAX_MESSAGE_LEN
);
}
let mut data = vec![CMD_SEND_CHANNEL_TXT_MSG, channel];
data.extend_from_slice(msg);
Ok(encode_frame(&data))
}
/// CMD_GET_CONTACTS (0x04): Request the contact list from the device.
pub fn build_get_contacts() -> Vec<u8> {
encode_frame(&[CMD_GET_CONTACTS])
}
/// CMD_SYNC_NEXT_MESSAGE (0x0A): Retrieve the next queued message.
pub fn build_sync_next_message() -> Vec<u8> {
encode_frame(&[CMD_SYNC_NEXT_MESSAGE])
}
/// CMD_SEND_SELF_ADVERT (0x07): Broadcast our advertisement to the mesh.
pub fn build_send_self_advert() -> Vec<u8> {
encode_frame(&[CMD_SEND_SELF_ADVERT])
}
/// CMD_GET_STATS (0x38): Request device statistics.
pub fn build_get_stats() -> Vec<u8> {
encode_frame(&[CMD_GET_STATS])
}
// ─── Response parsers ───────────────────────────────────────────────────
/// Parse RESP_DEVICE_INFO (0x0D) response.
/// Returns firmware version string and device capabilities.
pub fn parse_device_info(data: &[u8]) -> Result<(String, u16)> {
// Device info format varies by firmware version.
// Minimum: firmware version string (null-terminated) + max_contacts (u16 LE)
if data.is_empty() {
anyhow::bail!("Empty device info response");
}
// Find null terminator for version string, or use all data as version
let version_end = data.iter().position(|&b| b == 0).unwrap_or(data.len());
let version = String::from_utf8_lossy(&data[..version_end]).to_string();
let max_contacts = if data.len() > version_end + 2 {
u16::from_le_bytes([data[version_end + 1], data[version_end + 2]])
} else {
100 // default
};
Ok((version, max_contacts))
}
/// Parse RESP_SELF_INFO (0x05) response.
/// Returns (node_id, advert_name).
pub fn parse_self_info(data: &[u8]) -> Result<(u32, String)> {
if data.len() < 4 {
anyhow::bail!("Self info response too short: {} bytes", data.len());
}
let node_id = u32::from_le_bytes([data[0], data[1], data[2], data[3]]);
// Name follows after fixed fields — find it by scanning for printable ASCII
let name_start = 4;
let name = if data.len() > name_start {
let name_end = data[name_start..]
.iter()
.position(|&b| b == 0)
.map(|p| name_start + p)
.unwrap_or(data.len());
String::from_utf8_lossy(&data[name_start..name_end]).to_string()
} else {
String::new()
};
Ok((node_id, name))
}
/// Parsed contact from RESP_CONTACT (0x03).
pub struct ParsedContact {
pub public_key_hex: String,
pub advert_name: String,
pub last_advert: u32,
pub contact_type: u8,
}
/// Parse RESP_CONTACT (0x03) response.
/// Format: 32B pubkey + 1B type + 1B flags + 1B path_len + 64B path + 32B name + 4B last_advert + 4B lat + 4B lon + 4B lastmod
pub fn parse_contact(data: &[u8]) -> Result<ParsedContact> {
if data.len() < 34 {
anyhow::bail!("Contact response too short: {} bytes (need >= 34)", data.len());
}
let public_key_hex = hex::encode(&data[0..32]);
let contact_type = data[32];
// flags at data[33], path_len at data[34]
// path at data[35..99] (64 bytes)
// name at data[99..131] (32 bytes)
let name_start = 99.min(data.len());
let name_end = (name_start + 32).min(data.len());
let advert_name = if data.len() > name_start {
String::from_utf8_lossy(&data[name_start..name_end])
.trim_end_matches('\0')
.to_string()
} else {
format!("{}...", &public_key_hex[..8])
};
// last_advert at data[131..135]
let last_advert = if data.len() >= 135 {
u32::from_le_bytes([data[131], data[132], data[133], data[134]])
} else {
0
};
Ok(ParsedContact {
public_key_hex,
advert_name,
last_advert,
contact_type,
})
}
/// Parse RESP_CONTACT_MSG_V3 (0x10) - private message.
/// Format: SNR(1B) + reserved(2B) + pubkey_prefix(6B) + path_len(1B) + txt_type(1B) + timestamp(4B) + [sig(4B) if txt_type==2] + text
/// Returns (sender_pubkey_prefix_hex, text, snr).
pub fn parse_contact_msg_v3(data: &[u8]) -> Result<(String, String, i8)> {
if data.len() < 15 {
anyhow::bail!("Contact message too short: {} bytes", data.len());
}
let snr = data[0] as i8;
// data[1..3] reserved
let pubkey_prefix = hex::encode(&data[3..9]);
// data[9] = path_len
let txt_type = data[10];
// data[11..15] = timestamp
let text_start = if txt_type == 2 { 19 } else { 15 }; // skip 4-byte signature if txt_type==2
let text = if data.len() > text_start {
String::from_utf8_lossy(&data[text_start..]).to_string()
} else {
String::new()
};
Ok((pubkey_prefix, text, snr))
}
/// Parse RESP_CHANNEL_MSG_V3 (0x11) - channel message.
/// Format: channel_idx(1B) + path_len(1B) + txt_type(1B) + timestamp(4B) + text
/// Returns (channel_idx, text).
pub fn parse_channel_msg_v3(data: &[u8]) -> Result<(u8, String)> {
if data.len() < 7 {
anyhow::bail!("Channel message too short: {} bytes", data.len());
}
let channel_idx = data[0];
// data[1] = path_len, data[2] = txt_type
// data[3..7] = timestamp
let text = if data.len() > 7 {
String::from_utf8_lossy(&data[7..]).trim_end_matches('\0').to_string()
} else {
String::new()
};
Ok((channel_idx, text))
}
/// Parse RESP_CONTACT_MSG (0x07) - v1 private message.
/// Format: pubkey_prefix(6B) + path_len(1B) + txt_type(1B) + timestamp(4B) + [sig(4B) if txt_type==2] + text
/// Returns (sender_pubkey_prefix_hex, text).
pub fn parse_contact_msg_v1(data: &[u8]) -> Result<(String, String)> {
if data.len() < 12 {
anyhow::bail!("Contact message v1 too short: {} bytes", data.len());
}
let pubkey_prefix = hex::encode(&data[0..6]);
// data[6] = path_len, data[7] = txt_type
let txt_type = data[7];
// data[8..12] = timestamp
let text_start = if txt_type == 2 { 16 } else { 12 };
let text = if data.len() > text_start {
String::from_utf8_lossy(&data[text_start..]).to_string()
} else {
String::new()
};
Ok((pubkey_prefix, text))
}
/// Parse RESP_CHANNEL_MSG (0x08) - v1 channel message.
/// Format: channel_idx(1B) + path_len(1B) + txt_type(1B) + timestamp(4B) + text
pub fn parse_channel_msg_v1(data: &[u8]) -> Result<(u8, String)> {
if data.len() < 7 {
anyhow::bail!("Channel message v1 too short: {} bytes", data.len());
}
let channel_idx = data[0];
// data[1] = path_len, data[2] = txt_type
// data[3..7] = timestamp
let text = if data.len() > 7 {
String::from_utf8_lossy(&data[7..]).trim_end_matches('\0').to_string()
} else {
String::new()
};
Ok((channel_idx, text))
}
/// Parse RESP_ERR (0x01). Returns descriptive error string.
pub fn parse_error(data: &[u8]) -> String {
if data.is_empty() {
return "Unknown device error".to_string();
}
match data[0] {
ERR_UNSUPPORTED_CMD => "Unsupported command".to_string(),
ERR_NOT_FOUND => "Not found".to_string(),
ERR_TABLE_FULL => "Contact table full".to_string(),
ERR_BAD_STATE => "Bad device state".to_string(),
ERR_FILE_IO => "Device file I/O error".to_string(),
ERR_ILLEGAL_ARG => "Illegal argument".to_string(),
code => format!("Device error code 0x{:02x}", code),
}
}
/// Check if a response code is a push notification (async event from device).
pub fn is_push_notification(code: u8) -> bool {
code >= 0x80
}
// ─── Archipelago identity wire format ───────────────────────────────────
/// Prefix for Archipelago identity broadcasts over mesh channel.
pub const ARCHY_IDENTITY_PREFIX: &str = "ARCHY:1:";
/// Encode an Archipelago identity announcement for channel broadcast.
/// Compact format: `ARCHY:2:{ed25519_pubkey_hex}:{x25519_pubkey_hex}`
/// DID is omitted to fit within 160-byte LoRa limit — receiver reconstructs did:key from ed25519 pubkey.
/// Total: 8 + 64 + 1 + 64 = 137 bytes (fits in 160).
pub fn encode_identity_broadcast(_did: &str, ed_pubkey_hex: &str, x25519_pubkey_hex: &str) -> String {
format!("ARCHY:2:{}:{}", ed_pubkey_hex, x25519_pubkey_hex)
}
/// Try to parse an Archipelago identity from a received channel message.
/// Returns (did, ed25519_pubkey_hex, x25519_pubkey_hex) if valid.
///
/// Supports two formats:
/// - v2 (compact): `ARCHY:2:{ed25519_hex_64}:{x25519_hex_64}` — DID reconstructed from ed25519
/// - v1 (legacy): `ARCHY:1:{did}:{ed25519_hex_64}:{x25519_hex_64}`
pub fn parse_identity_broadcast(msg: &str) -> Option<(String, String, String)> {
// Try v2 compact format first
if let Some(rest) = msg.strip_prefix("ARCHY:2:") {
let parts: Vec<&str> = rest.splitn(2, ':').collect();
if parts.len() != 2 {
return None;
}
let ed_pubkey = parts[0];
let x25519_pubkey = parts[1];
if ed_pubkey.len() != 64 || x25519_pubkey.len() != 64 {
return None;
}
if !ed_pubkey.chars().all(|c| c.is_ascii_hexdigit())
|| !x25519_pubkey.chars().all(|c| c.is_ascii_hexdigit())
{
return None;
}
// Reconstruct DID from ed25519 pubkey
let did = crate::identity::did_key_from_pubkey_hex(ed_pubkey).ok()?;
return Some((did, ed_pubkey.to_string(), x25519_pubkey.to_string()));
}
// Try v1 legacy format
let rest = msg.strip_prefix(ARCHY_IDENTITY_PREFIX)?;
let last_colon = rest.rfind(':')?;
let x25519_pubkey = &rest[last_colon + 1..];
if x25519_pubkey.len() != 64 || !x25519_pubkey.chars().all(|c| c.is_ascii_hexdigit()) {
return None;
}
let before_x25519 = &rest[..last_colon];
let second_last_colon = before_x25519.rfind(':')?;
let ed_pubkey = &before_x25519[second_last_colon + 1..];
if ed_pubkey.len() != 64 || !ed_pubkey.chars().all(|c| c.is_ascii_hexdigit()) {
return None;
}
let did = &before_x25519[..second_last_colon];
if !did.starts_with("did:key:z") {
return None;
}
Some((did.to_string(), ed_pubkey.to_string(), x25519_pubkey.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_encode_frame() {
let frame = encode_frame(&[CMD_DEVICE_QUERY, PROTOCOL_VERSION]);
assert_eq!(frame[0], OUTBOUND_MARKER);
assert_eq!(u16::from_le_bytes([frame[1], frame[2]]), 2);
assert_eq!(frame[3], CMD_DEVICE_QUERY);
assert_eq!(frame[4], PROTOCOL_VERSION);
}
#[test]
fn test_decode_frame_complete() {
// Simulate an inbound frame: < + len(2) + [RESP_OK]
let buf = vec![INBOUND_MARKER, 0x01, 0x00, RESP_OK];
let frame = decode_frame(&buf).expect("should parse");
assert_eq!(frame.code, RESP_OK);
assert!(frame.data.is_empty());
assert_eq!(frame.bytes_consumed, 4);
}
#[test]
fn test_decode_frame_with_data() {
// < + len(5) + [RESP_SELF_INFO, 0x01, 0x02, 0x03, 0x04]
let buf = vec![INBOUND_MARKER, 0x05, 0x00, RESP_SELF_INFO, 0x01, 0x02, 0x03, 0x04];
let frame = decode_frame(&buf).expect("should parse");
assert_eq!(frame.code, RESP_SELF_INFO);
assert_eq!(frame.data, vec![0x01, 0x02, 0x03, 0x04]);
assert_eq!(frame.bytes_consumed, 8);
}
#[test]
fn test_decode_frame_incomplete() {
let buf = vec![INBOUND_MARKER, 0x05, 0x00, RESP_OK]; // says 5 bytes but only 1
assert!(decode_frame(&buf).is_none());
}
#[test]
fn test_decode_frame_no_marker() {
let buf = vec![0xFF, 0x01, 0x00, RESP_OK];
assert!(decode_frame(&buf).is_none());
}
#[test]
fn test_decode_frame_skips_garbage() {
// Garbage bytes before the actual frame
let buf = vec![0xFF, 0xAA, INBOUND_MARKER, 0x01, 0x00, RESP_OK];
let frame = decode_frame(&buf).expect("should skip garbage");
assert_eq!(frame.code, RESP_OK);
assert_eq!(frame.bytes_consumed, 6); // 2 garbage + 4 frame
}
#[test]
fn test_build_device_query() {
let frame = build_device_query();
assert_eq!(frame[0], OUTBOUND_MARKER);
assert_eq!(frame[3], CMD_DEVICE_QUERY);
assert_eq!(frame[4], PROTOCOL_VERSION);
}
#[test]
fn test_build_app_start() {
let frame = build_app_start("Archipelago");
assert_eq!(frame[3], CMD_APP_START);
let name = &frame[4..];
assert_eq!(std::str::from_utf8(name).unwrap(), "Archipelago");
}
#[test]
fn test_build_set_device_time() {
let ts: u64 = 1710600000;
let frame = build_set_device_time(ts);
assert_eq!(frame[3], CMD_SET_DEVICE_TIME);
let time_bytes = &frame[4..8];
assert_eq!(
u32::from_le_bytes([time_bytes[0], time_bytes[1], time_bytes[2], time_bytes[3]]),
ts as u32
);
}
#[test]
fn test_build_send_text() {
let frame = build_send_text(42, b"hello").unwrap();
assert_eq!(frame[3], CMD_SEND_TXT_MSG);
let cid = u32::from_le_bytes([frame[4], frame[5], frame[6], frame[7]]);
assert_eq!(cid, 42);
assert_eq!(&frame[8..], b"hello");
}
#[test]
fn test_build_send_text_too_large() {
let big = vec![0u8; MAX_MESSAGE_LEN + 1];
assert!(build_send_text(1, &big).is_err());
}
#[test]
fn test_build_send_channel_text() {
let frame = build_send_channel_text(0, b"test").unwrap();
assert_eq!(frame[3], CMD_SEND_CHANNEL_TXT_MSG);
assert_eq!(frame[4], 0); // channel 0
assert_eq!(&frame[5..], b"test");
}
#[test]
fn test_identity_broadcast_roundtrip() {
let did = "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK";
let ed_pub = "a".repeat(64);
let x25519_pub = "b".repeat(64);
let encoded = encode_identity_broadcast(did, &ed_pub, &x25519_pub);
assert!(encoded.starts_with(ARCHY_IDENTITY_PREFIX));
let (parsed_did, parsed_ed, parsed_x) = parse_identity_broadcast(&encoded).unwrap();
assert_eq!(parsed_did, did);
assert_eq!(parsed_ed, ed_pub);
assert_eq!(parsed_x, x25519_pub);
}
#[test]
fn test_identity_broadcast_invalid() {
assert!(parse_identity_broadcast("not an identity").is_none());
assert!(parse_identity_broadcast("ARCHY:1:bad").is_none());
assert!(parse_identity_broadcast("ARCHY:1:did:key:z123:short:short").is_none());
}
#[test]
fn test_parse_error_codes() {
assert_eq!(parse_error(&[ERR_NOT_FOUND]), "Not found");
assert_eq!(parse_error(&[ERR_TABLE_FULL]), "Contact table full");
assert_eq!(parse_error(&[]), "Unknown device error");
assert!(parse_error(&[0xFF]).contains("0xff"));
}
#[test]
fn test_is_push_notification() {
assert!(is_push_notification(PUSH_NEW_CONTACT));
assert!(is_push_notification(PUSH_ACK));
assert!(is_push_notification(0x80));
assert!(!is_push_notification(RESP_OK));
assert!(!is_push_notification(RESP_DEVICE_INFO));
}
#[test]
fn test_parse_self_info() {
let mut data = vec![0x2A, 0x00, 0x00, 0x00]; // node_id = 42
data.extend_from_slice(b"TestNode\0");
let (id, name) = parse_self_info(&data).unwrap();
assert_eq!(id, 42);
assert_eq!(name, "TestNode");
}
#[test]
fn test_parse_self_info_too_short() {
assert!(parse_self_info(&[0x01, 0x02]).is_err());
}
#[test]
fn test_parse_received_message() {
let mut data = vec![0x05, 0x00, 0x00, 0x00]; // contact_id = 5
data.extend_from_slice(&(-75i16).to_le_bytes()); // rssi = -75
data.extend_from_slice(b"hello mesh");
let (cid, payload, rssi) = parse_received_message(&data).unwrap();
assert_eq!(cid, 5);
assert_eq!(rssi, -75);
assert_eq!(payload, b"hello mesh");
}
}
+378
View File
@@ -0,0 +1,378 @@
//! Async serial driver for Meshcore devices.
//!
//! Handles opening the serial port, reading/writing frames,
//! and the initialization handshake sequence.
use super::protocol::{self, InboundFrame};
use super::types::DeviceInfo;
use anyhow::{Context, Result};
use std::time::Duration;
use tracing::{debug, info, warn};
/// Serial port configuration for Meshcore Companion USB.
const BAUD_RATE: u32 = 115200;
/// Timeout for reading a response frame from the device.
const READ_TIMEOUT: Duration = Duration::from_secs(5);
/// Timeout for writing a frame to the device.
const WRITE_TIMEOUT: Duration = Duration::from_secs(2);
/// Buffer size for serial reads.
const READ_BUF_SIZE: usize = 512;
/// Application name sent during handshake.
const APP_NAME: &str = "Archipelago";
/// Async Meshcore device handle.
pub struct MeshcoreDevice {
port: serial2_tokio::SerialPort,
read_buf: Vec<u8>,
pub node_id: Option<u32>,
pub advert_name: Option<String>,
pub device_info: Option<DeviceInfo>,
device_path: String,
}
impl MeshcoreDevice {
/// Open a serial port and verify it's a Meshcore device.
pub async fn open(path: &str) -> Result<Self> {
let port = serial2_tokio::SerialPort::open(path, BAUD_RATE)
.context(format!("Failed to open serial port {}", path))?;
info!(path = %path, baud = BAUD_RATE, "Opened serial port");
Ok(Self {
port,
read_buf: Vec::with_capacity(READ_BUF_SIZE),
node_id: None,
advert_name: None,
device_info: None,
device_path: path.to_string(),
})
}
/// Run the Meshcore initialization handshake.
/// Matches the official meshcore_py library sequence:
/// 1. CMD_APP_START -> RESP_SELF_INFO (this is the first command, not device_query)
/// 2. CMD_SET_DEVICE_TIME (sync clock)
pub async fn initialize(&mut self) -> Result<DeviceInfo> {
info!("Starting Meshcore handshake on {}", self.device_path);
// Step 1: App start (the official library sends this first)
self.send_raw(&protocol::build_app_start(APP_NAME)).await?;
let frame = self
.recv_frame_timeout(READ_TIMEOUT)
.await
.context("No response to APP_START — is this a Meshcore Companion USB device?")?;
info!(code = frame.code, data_len = frame.data.len(), "Got response to APP_START");
if frame.code == protocol::RESP_ERR {
anyhow::bail!("App start failed: {}", protocol::parse_error(&frame.data));
}
// The response could be SELF_INFO or something else depending on firmware version
let (node_id, name) = if frame.code == protocol::RESP_SELF_INFO {
protocol::parse_self_info(&frame.data)
.context("Failed to parse self info")?
} else {
// Try to parse whatever we got
info!(code = frame.code, "Unexpected response code, trying to parse as self info");
protocol::parse_self_info(&frame.data)
.unwrap_or((0, String::new()))
};
info!(node_id, name = %name, "Meshcore identity");
self.node_id = Some(node_id);
self.advert_name = Some(name.clone());
// Step 2: Sync device clock
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
self.send_raw(&protocol::build_set_device_time(now)).await?;
// Time set response is best-effort — don't fail if it times out
match self.recv_frame_timeout(Duration::from_secs(2)).await {
Ok(frame) if frame.code == protocol::RESP_OK => {
debug!("Device clock synced");
}
Ok(frame) => {
warn!(code = frame.code, "Unexpected response to SET_DEVICE_TIME");
}
Err(_) => {
warn!("No response to SET_DEVICE_TIME (continuing anyway)");
}
}
let info = DeviceInfo {
firmware_version: name.clone(),
node_id,
max_contacts: 100,
device_type: super::types::DeviceType::Meshcore,
};
self.device_info = Some(info.clone());
info!("Meshcore initialization complete on {}", self.device_path);
Ok(info)
}
/// Set the advertised name on the mesh network.
pub async fn set_advert_name(&mut self, name: &str) -> Result<()> {
self.send_raw(&protocol::build_set_advert_name(name)).await?;
let frame = self.recv_frame_timeout(READ_TIMEOUT).await?;
if frame.code == protocol::RESP_ERR {
anyhow::bail!("Set advert name failed: {}", protocol::parse_error(&frame.data));
}
self.advert_name = Some(name.to_string());
Ok(())
}
/// Broadcast our advertisement to the mesh.
pub async fn send_self_advert(&mut self) -> Result<()> {
self.send_raw(&protocol::build_send_self_advert()).await?;
// Response is RESP_OK or RESP_SENT
let frame = self.recv_frame_timeout(READ_TIMEOUT).await?;
if frame.code == protocol::RESP_ERR {
anyhow::bail!("Self advert failed: {}", protocol::parse_error(&frame.data));
}
Ok(())
}
/// Send a text message to a contact by their public key prefix (first 6 bytes).
pub async fn send_text(&mut self, dest_pubkey_prefix: &[u8; 6], msg: &[u8]) -> Result<()> {
let frame_data = protocol::build_send_text(dest_pubkey_prefix, msg)?;
self.send_raw(&frame_data).await?;
let frame = self.recv_frame_timeout(READ_TIMEOUT).await?;
if frame.code == protocol::RESP_ERR {
anyhow::bail!("Send text failed: {}", protocol::parse_error(&frame.data));
}
Ok(())
}
/// Broadcast a text message on a channel.
pub async fn send_channel_text(&mut self, channel: u8, msg: &[u8]) -> Result<()> {
let frame_data = protocol::build_send_channel_text(channel, msg)?;
self.send_raw(&frame_data).await?;
let frame = self.recv_frame_timeout(READ_TIMEOUT).await?;
if frame.code == protocol::RESP_ERR {
anyhow::bail!(
"Channel broadcast failed: {}",
protocol::parse_error(&frame.data)
);
}
Ok(())
}
/// Get the list of known contacts from the device.
/// Protocol: CMD_GET_CONTACTS -> CONTACT_START(count) -> N×CONTACT -> CONTACT_END
pub async fn get_contacts(&mut self) -> Result<Vec<protocol::ParsedContact>> {
self.send_raw(&protocol::build_get_contacts()).await?;
let mut contacts = Vec::new();
loop {
let frame = self.recv_frame_timeout(READ_TIMEOUT).await?;
match frame.code {
protocol::RESP_CONTACT_START => {
// Contains the count of contacts to follow
let count = if frame.data.len() >= 4 {
u32::from_le_bytes([frame.data[0], frame.data[1], frame.data[2], frame.data[3]])
} else {
0
};
debug!(count, "Contact list start");
}
protocol::RESP_CONTACT => {
match protocol::parse_contact(&frame.data) {
Ok(contact) => contacts.push(contact),
Err(e) => warn!("Failed to parse contact: {}", e),
}
}
protocol::RESP_CONTACT_END => {
debug!(count = contacts.len(), "Contact list complete");
break;
}
protocol::RESP_OK => break,
protocol::RESP_ERR => {
anyhow::bail!("Get contacts failed: {}", protocol::parse_error(&frame.data));
}
_ => {
debug!(code = frame.code, "Unexpected response during contact list");
// Don't break — might be a push notification interspersed
}
}
}
Ok(contacts)
}
/// Retrieve queued messages from the device.
/// Returns raw frames (code + data) for the listener to parse.
pub async fn sync_messages(&mut self) -> Result<Vec<protocol::InboundFrame>> {
self.send_raw(&protocol::build_sync_next_message()).await?;
let mut frames = Vec::new();
loop {
let frame = self.recv_frame_timeout(READ_TIMEOUT).await?;
match frame.code {
// All message types (v1 and v3)
protocol::RESP_CONTACT_MSG | protocol::RESP_CONTACT_MSG_V3
| protocol::RESP_CHANNEL_MSG | protocol::RESP_CHANNEL_MSG_V3 => {
frames.push(frame);
// Request next message
self.send_raw(&protocol::build_sync_next_message()).await?;
}
protocol::RESP_NO_MORE_MESSAGES => break,
protocol::RESP_OK => break,
protocol::RESP_ERR => {
anyhow::bail!(
"Sync messages failed: {}",
protocol::parse_error(&frame.data)
);
}
_ => {
// Push notifications can arrive during sync — skip them
if protocol::is_push_notification(frame.code) {
continue;
}
debug!(code = frame.code, "Unexpected response during message sync");
break;
}
}
}
Ok(frames)
}
/// Write raw bytes to the serial port.
pub async fn send_raw(&mut self, data: &[u8]) -> Result<()> {
tokio::time::timeout(WRITE_TIMEOUT, self.port.write_all(data))
.await
.context("Serial write timed out")?
.context("Serial write failed")?;
Ok(())
}
/// Try to read and parse one complete inbound frame.
/// Returns the frame if one is available, or reads more data from serial.
pub async fn try_recv_frame(&mut self) -> Result<Option<InboundFrame>> {
// First check if we already have a complete frame in the buffer
if let Some(frame) = protocol::decode_frame(&self.read_buf) {
let consumed = frame.bytes_consumed;
let result = frame;
self.read_buf.drain(..consumed);
return Ok(Some(result));
}
// Try to read more data (non-blocking via small timeout)
let mut tmp = [0u8; READ_BUF_SIZE];
match tokio::time::timeout(Duration::from_millis(50), self.port.read(&mut tmp)).await {
Ok(Ok(n)) if n > 0 => {
self.read_buf.extend_from_slice(&tmp[..n]);
}
_ => return Ok(None),
}
// Try parsing again with new data
if let Some(frame) = protocol::decode_frame(&self.read_buf) {
let consumed = frame.bytes_consumed;
let result = frame;
self.read_buf.drain(..consumed);
return Ok(Some(result));
}
Ok(None)
}
/// Read one complete inbound frame with timeout.
pub async fn recv_frame_timeout(&mut self, timeout: Duration) -> Result<InboundFrame> {
let deadline = tokio::time::Instant::now() + timeout;
loop {
// Check buffer for a complete frame
if let Some(frame) = protocol::decode_frame(&self.read_buf) {
let consumed = frame.bytes_consumed;
let result = frame;
self.read_buf.drain(..consumed);
return Ok(result);
}
// Read more data from serial
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
anyhow::bail!("Timeout waiting for serial frame");
}
let mut tmp = [0u8; READ_BUF_SIZE];
match tokio::time::timeout(remaining.min(Duration::from_millis(100)), self.port.read(&mut tmp))
.await
{
Ok(Ok(0)) => anyhow::bail!("Serial port closed"),
Ok(Ok(n)) => {
self.read_buf.extend_from_slice(&tmp[..n]);
}
Ok(Err(e)) => return Err(e).context("Serial read error"),
Err(_) => continue, // timeout on this read, try again if deadline not reached
}
}
}
/// Get the device path this handle is connected to.
pub fn path(&self) -> &str {
&self.device_path
}
}
// ─── Device detection ───────────────────────────────────────────────────
/// Candidate serial device paths to check on Linux.
const SERIAL_CANDIDATES: &[&str] = &[
"/dev/ttyUSB0",
"/dev/ttyUSB1",
"/dev/ttyUSB2",
"/dev/ttyACM0",
"/dev/ttyACM1",
"/dev/ttyACM2",
];
/// Scan for serial devices that could be Meshcore radios.
/// Returns paths to existing serial device files.
pub async fn detect_serial_devices() -> Vec<String> {
let mut devices = Vec::new();
for path in SERIAL_CANDIDATES {
if tokio::fs::metadata(path).await.is_ok() {
devices.push(path.to_string());
}
}
devices
}
/// Try to open and handshake with each detected serial device.
/// Returns the first device that responds as Meshcore.
pub async fn probe_for_meshcore(paths: &[String]) -> Option<(String, DeviceInfo)> {
for path in paths {
debug!(path = %path, "Probing for Meshcore device");
match MeshcoreDevice::open(path).await {
Ok(mut device) => {
match device.initialize().await {
Ok(info) => {
info!(path = %path, firmware = %info.firmware_version, "Found Meshcore device");
// Drop the device so the listener can open it
drop(device);
return Some((path.clone(), info));
}
Err(e) => {
debug!(path = %path, error = %e, "Not a Meshcore device");
}
}
}
Err(e) => {
debug!(path = %path, error = %e, "Could not open serial port");
}
}
}
None
}
+114
View File
@@ -0,0 +1,114 @@
//! Shared types for mesh networking subsystem.
use serde::{Deserialize, Serialize};
/// Device firmware type, detected via protocol handshake.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DeviceType {
Meshcore,
Meshtastic,
Unknown,
}
impl std::fmt::Display for DeviceType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Meshcore => write!(f, "meshcore"),
Self::Meshtastic => write!(f, "meshtastic"),
Self::Unknown => write!(f, "unknown"),
}
}
}
/// A peer discovered via mesh radio.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MeshPeer {
/// Meshcore contact ID (uint32).
pub contact_id: u32,
/// Advertised name on the mesh network.
pub advert_name: String,
/// Archipelago DID (did:key:z...) if identity was received.
pub did: Option<String>,
/// Ed25519 public key hex if identity was received.
pub pubkey_hex: Option<String>,
/// X25519 public key (32 bytes) for key agreement.
#[serde(skip)]
pub x25519_pubkey: Option<[u8; 32]>,
/// Last received signal strength (dBm).
pub rssi: Option<i16>,
/// Signal-to-noise ratio.
pub snr: Option<f32>,
/// When we last heard from this peer.
pub last_heard: String,
/// Number of hops to reach this peer.
pub hops: u8,
}
/// Direction of a mesh message.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MessageDirection {
Sent,
Received,
}
/// A mesh message (sent or received).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MeshMessage {
pub id: u64,
pub direction: MessageDirection,
/// Meshcore contact ID of the peer.
pub peer_contact_id: u32,
/// Peer name (for display).
pub peer_name: Option<String>,
/// Decrypted plaintext content.
pub plaintext: String,
pub timestamp: String,
/// Whether delivery was confirmed via ACK.
pub delivered: bool,
/// Whether the message was end-to-end encrypted.
pub encrypted: bool,
}
/// Overall mesh subsystem status.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MeshStatus {
pub enabled: bool,
pub device_type: DeviceType,
pub device_path: Option<String>,
pub device_connected: bool,
pub firmware_version: Option<String>,
pub self_node_id: Option<u32>,
pub self_advert_name: Option<String>,
pub peer_count: usize,
pub channel_name: String,
pub messages_sent: u64,
pub messages_received: u64,
}
/// Information returned from device during initialization.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeviceInfo {
pub firmware_version: String,
pub node_id: u32,
pub max_contacts: u16,
pub device_type: DeviceType,
}
/// Events emitted by the mesh listener for other components to consume.
#[derive(Debug, Clone)]
pub enum MeshEvent {
DeviceConnected(DeviceInfo),
DeviceDisconnected,
PeerDiscovered(MeshPeer),
PeerUpdated(MeshPeer),
MessageReceived(MeshMessage),
MessageDelivered { message_id: u64 },
IdentityReceived {
contact_id: u32,
did: String,
pubkey_hex: String,
x25519_pubkey: [u8; 32],
},
}
+74
View File
@@ -109,6 +109,80 @@ impl Server {
ApiHandler::new(config.clone(), state_manager.clone(), metrics_store).await?,
);
// Initialize mesh networking service (if config has enabled: true)
{
let data_dir = config.data_dir.clone();
let did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)
.unwrap_or_default();
let pubkey_hex = identity.pubkey_hex();
let signing_key = identity.signing_key();
match crate::mesh::MeshService::new(&data_dir, signing_key, &did, &pubkey_hex).await {
Ok(mut mesh_service) => {
let mesh_config = crate::mesh::load_config(&data_dir).await.unwrap_or_default();
if mesh_config.enabled {
if let Err(e) = mesh_service.start() {
warn!("Mesh service start failed (non-fatal): {}", e);
} else {
info!("📡 Mesh networking started");
}
}
api_handler.rpc_handler().set_mesh_service(mesh_service).await;
info!("📡 Mesh service initialized");
}
Err(e) => {
warn!("Mesh service init failed (non-fatal): {}", e);
}
}
}
// Initialize transport router (unified routing: mesh > lan > tor)
{
let data_dir = config.data_dir.clone();
let did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)
.unwrap_or_default();
let pubkey_hex = identity.pubkey_hex();
let mesh_config = crate::mesh::load_config(&data_dir).await.unwrap_or_default();
let mesh_only = mesh_config.mesh_only_mode.unwrap_or(false);
match crate::transport::PeerRegistry::load(&data_dir).await {
Ok(registry) => {
let registry = std::sync::Arc::new(registry);
let mut transports: Vec<Box<dyn crate::transport::NodeTransport>> = Vec::new();
// Tor transport (always register — availability checked dynamically)
transports.push(Box::new(
crate::transport::tor::TorTransport::new(&pubkey_hex),
));
// Mesh transport (wraps the mesh service)
transports.push(Box::new(
crate::transport::mesh_transport::MeshTransport::new(
api_handler.rpc_handler().mesh_service_arc(),
),
));
// LAN transport (mDNS discovery)
let mut lan = crate::transport::lan::LanTransport::new(&did, &pubkey_hex, 5678);
match lan.start(registry.clone()) {
Ok(()) => info!("📡 LAN transport (mDNS) started"),
Err(e) => debug!("LAN transport init (non-fatal): {}", e),
}
transports.push(Box::new(lan));
let router = std::sync::Arc::new(crate::transport::TransportRouter::new(
transports,
registry,
mesh_only,
));
api_handler.rpc_handler().set_transport_router(router).await;
info!("📡 Transport router initialized (mesh_only={})", mesh_only);
}
Err(e) => {
warn!("Transport router init failed (non-fatal): {}", e);
}
}
}
// Register Archipelago DWN protocols (background, non-blocking)
{
let data_dir = config.data_dir.clone();
+408
View File
@@ -0,0 +1,408 @@
//! Chunked message protocol with Reed-Solomon FEC for LoRa transport.
//!
//! Splits payloads larger than a single LoRa frame (160 bytes) into
//! numbered chunks with forward error correction, enabling reliable
//! transfer over lossy radio links.
//!
//! Chunk wire format (8 bytes header + payload):
//! ```text
//! [0x01: type] [msg_id: u32 LE] [chunk_idx: u8] [total: u8] [is_parity: u8] [payload...]
//! ```
use anyhow::{Context, Result};
use reed_solomon_erasure::galois_8::ReedSolomon;
use std::collections::HashMap;
use std::time::Instant;
/// Header size for each chunk frame.
const CHUNK_HEADER_SIZE: usize = 8;
/// Maximum payload per chunk after header.
/// 132 bytes available after ChaCha20-Poly1305 encryption overhead (12 nonce + 16 tag),
/// minus 8 byte chunk header = 124 bytes of user data per chunk.
pub const MAX_CHUNK_PAYLOAD: usize = 124;
/// Chunk type marker in the wire format.
const CHUNK_TYPE_MARKER: u8 = 0x01;
/// FEC redundancy ratio: 25% parity shards.
const FEC_RATIO_DENOMINATOR: usize = 4;
/// Maximum age of pending reassembly entries before garbage collection.
const REASSEMBLY_TIMEOUT_SECS: u64 = 60;
/// Maximum practical chunks for LoRa (airtime budget).
pub const MAX_PRACTICAL_CHUNKS: usize = 20;
/// A single chunk ready for transmission.
#[derive(Debug, Clone)]
pub struct Chunk {
pub message_id: u32,
pub chunk_index: u8,
pub total_chunks: u8,
pub is_parity: bool,
pub payload: Vec<u8>,
}
impl Chunk {
/// Serialize chunk to wire format.
pub fn to_bytes(&self) -> Vec<u8> {
let mut buf = Vec::with_capacity(CHUNK_HEADER_SIZE + self.payload.len());
buf.push(CHUNK_TYPE_MARKER);
buf.extend_from_slice(&self.message_id.to_le_bytes());
buf.push(self.chunk_index);
buf.push(self.total_chunks);
buf.push(if self.is_parity { 1 } else { 0 });
buf.extend_from_slice(&self.payload);
buf
}
/// Parse chunk from wire format.
pub fn from_bytes(data: &[u8]) -> Result<Self> {
if data.len() < CHUNK_HEADER_SIZE {
anyhow::bail!("Chunk too small: {} bytes", data.len());
}
if data[0] != CHUNK_TYPE_MARKER {
anyhow::bail!("Not a chunked message (marker: 0x{:02x})", data[0]);
}
let message_id = u32::from_le_bytes([data[1], data[2], data[3], data[4]]);
let chunk_index = data[5];
let total_chunks = data[6];
let is_parity = data[7] != 0;
let payload = data[CHUNK_HEADER_SIZE..].to_vec();
Ok(Self {
message_id,
chunk_index,
total_chunks,
is_parity,
payload,
})
}
/// Check if a raw byte slice starts with the chunk type marker.
pub fn is_chunked_message(data: &[u8]) -> bool {
!data.is_empty() && data[0] == CHUNK_TYPE_MARKER
}
}
/// Encode a payload into chunks with Reed-Solomon FEC parity.
///
/// Returns a vector of chunks ready for sequential transmission.
/// Each chunk's payload is exactly `shard_size` bytes (padded if needed).
pub fn encode_chunked(data: &[u8]) -> Result<Vec<Chunk>> {
if data.is_empty() {
anyhow::bail!("Cannot chunk empty data");
}
let shard_size = MAX_CHUNK_PAYLOAD;
let data_shard_count = (data.len() + shard_size - 1) / shard_size;
if data_shard_count > MAX_PRACTICAL_CHUNKS {
anyhow::bail!(
"Payload too large for LoRa chunking: {} bytes ({} chunks, max {})",
data.len(),
data_shard_count,
MAX_PRACTICAL_CHUNKS
);
}
let parity_shard_count = (data_shard_count + FEC_RATIO_DENOMINATOR - 1) / FEC_RATIO_DENOMINATOR;
let total_shards = data_shard_count + parity_shard_count;
if total_shards > 255 {
anyhow::bail!("Too many shards: {}", total_shards);
}
// Split data into equal-size shards
let mut shards: Vec<Vec<u8>> = Vec::with_capacity(total_shards);
for i in 0..data_shard_count {
let start = i * shard_size;
let end = (start + shard_size).min(data.len());
let mut shard = vec![0u8; shard_size];
shard[..end - start].copy_from_slice(&data[start..end]);
shards.push(shard);
}
// Add empty parity shards
for _ in 0..parity_shard_count {
shards.push(vec![0u8; shard_size]);
}
// Generate parity
let rs = ReedSolomon::new(data_shard_count, parity_shard_count)
.context("Failed to create Reed-Solomon codec")?;
rs.encode(&mut shards)
.context("Reed-Solomon encoding failed")?;
// Build chunk frames
let message_id: u32 = rand::random();
let total = total_shards as u8;
let mut chunks = Vec::with_capacity(total_shards);
for (i, shard) in shards.into_iter().enumerate() {
chunks.push(Chunk {
message_id,
chunk_index: i as u8,
total_chunks: total,
is_parity: i >= data_shard_count,
payload: shard,
});
}
// Encode the original data length in the first chunk's first 4 bytes
// so the receiver can trim padding after reconstruction.
let data_len = data.len() as u32;
chunks[0].payload[..4].copy_from_slice(&data_len.to_le_bytes());
// Re-encode FEC to reflect the length header change
let mut shard_data: Vec<Vec<u8>> = chunks.iter().map(|c| c.payload.clone()).collect();
rs.encode(&mut shard_data)
.context("Reed-Solomon re-encoding failed")?;
for (i, shard) in shard_data.into_iter().enumerate() {
chunks[i].payload = shard;
}
Ok(chunks)
}
/// In-progress reassembly of a chunked message.
struct PendingMessage {
shards: Vec<Option<Vec<u8>>>,
data_shard_count: usize,
parity_shard_count: usize,
received_count: usize,
created_at: Instant,
}
/// Reassembles chunked messages from incoming chunks.
pub struct ChunkReassembler {
pending: HashMap<u32, PendingMessage>,
}
impl ChunkReassembler {
pub fn new() -> Self {
Self {
pending: HashMap::new(),
}
}
/// Feed a chunk into the reassembler.
/// Returns `Some(data)` if the message is fully reconstructed.
pub fn feed(&mut self, chunk: &Chunk) -> Result<Option<Vec<u8>>> {
// Garbage collect stale entries
self.pending.retain(|_, pm| {
pm.created_at.elapsed().as_secs() < REASSEMBLY_TIMEOUT_SECS
});
let total = chunk.total_chunks as usize;
let entry = self.pending.entry(chunk.message_id).or_insert_with(|| {
// Infer data vs parity count from chunks we've seen
// The first non-parity chunk tells us the split point
let data_count = if chunk.is_parity {
// Best guess: 80% data, 20% parity
(total * FEC_RATIO_DENOMINATOR) / (FEC_RATIO_DENOMINATOR + 1)
} else {
// We know this index is data — parity starts after all data
// Exact split point: smallest i where chunk_index >= data_count AND is_parity
total - (total + FEC_RATIO_DENOMINATOR) / (FEC_RATIO_DENOMINATOR + 1)
};
let parity_count = total - data_count;
PendingMessage {
shards: vec![None; total],
data_shard_count: data_count,
parity_shard_count: parity_count,
received_count: 0,
created_at: Instant::now(),
}
});
let idx = chunk.chunk_index as usize;
if idx >= total {
anyhow::bail!("Chunk index {} out of range (total {})", idx, total);
}
if entry.shards[idx].is_none() {
entry.shards[idx] = Some(chunk.payload.clone());
entry.received_count += 1;
}
// Need at least data_shard_count shards to reconstruct
if entry.received_count >= entry.data_shard_count {
self.try_reconstruct(chunk.message_id)
} else {
Ok(None)
}
}
fn try_reconstruct(&mut self, message_id: u32) -> Result<Option<Vec<u8>>> {
let entry = match self.pending.get_mut(&message_id) {
Some(e) => e,
None => return Ok(None),
};
let rs = ReedSolomon::new(entry.data_shard_count, entry.parity_shard_count)
.context("Failed to create Reed-Solomon codec for reconstruction")?;
let mut shards: Vec<Option<Vec<u8>>> = entry.shards.clone();
match rs.reconstruct(&mut shards) {
Ok(()) => {
// Concatenate data shards (not parity)
let mut result = Vec::new();
for shard in shards.iter().take(entry.data_shard_count) {
if let Some(data) = shard {
result.extend_from_slice(data);
}
}
// Extract original length from first 4 bytes
if result.len() < 4 {
anyhow::bail!("Reconstructed data too small for length header");
}
let original_len =
u32::from_le_bytes([result[0], result[1], result[2], result[3]]) as usize;
// The actual data starts at byte 4 of the first shard
// But wait — the length is embedded in shard 0 bytes 0..4, and the
// actual payload starts at byte 4 of shard 0, then continues in subsequent shards.
// Actually, encode_chunked puts the length in the first 4 bytes of shard 0,
// and the rest of shard 0 + all other shards contain the original data.
// So we need to skip 4 bytes from the beginning.
if 4 + original_len > result.len() {
anyhow::bail!(
"Original length {} exceeds reconstructed data ({})",
original_len,
result.len() - 4
);
}
let data = result[4..4 + original_len].to_vec();
self.pending.remove(&message_id);
Ok(Some(data))
}
Err(_) => {
// Not enough shards yet
Ok(None)
}
}
}
}
impl Default for ChunkReassembler {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_chunk_roundtrip_small() {
// Small payload fits in 1 data chunk + 1 parity chunk
let data = b"Hello, mesh network!";
let chunks = encode_chunked(data).unwrap();
// 1 data + 1 parity = 2 chunks
assert_eq!(chunks.len(), 2);
assert!(!chunks[0].is_parity);
assert!(chunks[1].is_parity);
let mut reassembler = ChunkReassembler::new();
// Feed data chunk — should reconstruct immediately (1 data shard needed)
let result = reassembler.feed(&chunks[0]).unwrap();
assert!(result.is_some());
assert_eq!(result.unwrap(), data);
}
#[test]
fn test_chunk_roundtrip_medium() {
// ~500 bytes: 4 data chunks + 1 parity
let data: Vec<u8> = (0..500).map(|i| (i % 256) as u8).collect();
let chunks = encode_chunked(&data).unwrap();
let data_chunks: Vec<_> = chunks.iter().filter(|c| !c.is_parity).collect();
let parity_chunks: Vec<_> = chunks.iter().filter(|c| c.is_parity).collect();
assert_eq!(data_chunks.len(), 4); // ceil(500/124) = 5... wait
// Actually: ceil(500/124) = ceil(4.03) = 5 data shards
// But the first shard has 4 bytes of length header embedded, so
// the actual data capacity is 124 * N - 0 (length is IN the shard data).
// Let's just check it roundtrips.
let mut reassembler = ChunkReassembler::new();
let mut result = None;
for chunk in &chunks {
if let Some(data) = reassembler.feed(chunk).unwrap() {
result = Some(data);
break;
}
}
assert!(result.is_some());
assert_eq!(result.unwrap(), data);
}
#[test]
fn test_chunk_wire_format() {
let chunk = Chunk {
message_id: 0x12345678,
chunk_index: 2,
total_chunks: 5,
is_parity: false,
payload: vec![0xAA, 0xBB],
};
let bytes = chunk.to_bytes();
assert_eq!(bytes[0], CHUNK_TYPE_MARKER);
assert_eq!(&bytes[1..5], &0x12345678u32.to_le_bytes());
assert_eq!(bytes[5], 2);
assert_eq!(bytes[6], 5);
assert_eq!(bytes[7], 0);
assert_eq!(&bytes[8..], &[0xAA, 0xBB]);
let parsed = Chunk::from_bytes(&bytes).unwrap();
assert_eq!(parsed.message_id, 0x12345678);
assert_eq!(parsed.chunk_index, 2);
assert_eq!(parsed.total_chunks, 5);
assert!(!parsed.is_parity);
assert_eq!(parsed.payload, vec![0xAA, 0xBB]);
}
#[test]
fn test_chunk_is_chunked_message() {
assert!(Chunk::is_chunked_message(&[0x01, 0x00]));
assert!(!Chunk::is_chunked_message(&[0x02, 0x00]));
assert!(!Chunk::is_chunked_message(&[]));
}
#[test]
fn test_chunk_with_missing_chunk() {
// Verify FEC can recover from a missing data chunk
let data: Vec<u8> = (0..300).map(|i| (i % 256) as u8).collect();
let chunks = encode_chunked(&data).unwrap();
let mut reassembler = ChunkReassembler::new();
// Skip chunk index 1 (simulate loss)
for chunk in &chunks {
if chunk.chunk_index == 1 {
continue;
}
if let Some(recovered) = reassembler.feed(chunk).unwrap() {
assert_eq!(recovered, data);
return;
}
}
panic!("Failed to reconstruct with one missing chunk");
}
#[test]
fn test_empty_data_rejected() {
assert!(encode_chunked(&[]).is_err());
}
#[test]
fn test_too_large_rejected() {
let data = vec![0u8; MAX_CHUNK_PAYLOAD * (MAX_PRACTICAL_CHUNKS + 1)];
assert!(encode_chunked(&data).is_err());
}
}
+399
View File
@@ -0,0 +1,399 @@
//! CBOR delta encoding for federation state sync.
//!
//! Instead of sending a full NodeStateSnapshot (~500-2000 bytes JSON) on every
//! sync cycle, we compute a delta of only changed fields and encode it as CBOR.
//! A typical delta (CPU + memory change) is ~30-50 bytes — small enough to fit
//! in a single LoRa chunk after encryption.
use crate::federation::{AppStatus, NodeStateSnapshot};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
/// Delta format version. Increment when fields change.
const DELTA_VERSION: u8 = 1;
/// Compact state delta — only changed fields, with short field names.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct StateDelta {
/// Timestamp of the snapshot this delta represents.
pub ts: String,
/// Delta format version for forward compatibility.
pub v: u8,
/// Apps that changed status (full entry for each changed app).
#[serde(skip_serializing_if = "Option::is_none")]
pub apps: Option<Vec<AppStatus>>,
/// App IDs that were removed since last sync.
#[serde(skip_serializing_if = "Option::is_none")]
pub apps_rm: Option<Vec<String>>,
/// CPU usage percent (only if changed).
#[serde(skip_serializing_if = "Option::is_none")]
pub cpu: Option<f64>,
/// Memory used bytes (only if changed).
#[serde(skip_serializing_if = "Option::is_none")]
pub mem_u: Option<u64>,
/// Memory total bytes (only if changed).
#[serde(skip_serializing_if = "Option::is_none")]
pub mem_t: Option<u64>,
/// Disk used bytes (only if changed).
#[serde(skip_serializing_if = "Option::is_none")]
pub dsk_u: Option<u64>,
/// Disk total bytes (only if changed).
#[serde(skip_serializing_if = "Option::is_none")]
pub dsk_t: Option<u64>,
/// Uptime seconds (only if changed).
#[serde(skip_serializing_if = "Option::is_none")]
pub up: Option<u64>,
/// Tor active flag (only if changed).
#[serde(skip_serializing_if = "Option::is_none")]
pub tor: Option<bool>,
}
/// Compute the delta between two state snapshots.
/// Returns only the fields that differ.
pub fn compute_delta(prev: &NodeStateSnapshot, curr: &NodeStateSnapshot) -> StateDelta {
let mut delta = StateDelta {
ts: curr.timestamp.clone(),
v: DELTA_VERSION,
..Default::default()
};
// Compare apps
let prev_apps: std::collections::HashMap<&str, &AppStatus> =
prev.apps.iter().map(|a| (a.id.as_str(), a)).collect();
let curr_apps: std::collections::HashMap<&str, &AppStatus> =
curr.apps.iter().map(|a| (a.id.as_str(), a)).collect();
let mut changed_apps = Vec::new();
let mut removed_apps = Vec::new();
for (id, curr_app) in &curr_apps {
match prev_apps.get(id) {
Some(prev_app) => {
if prev_app.status != curr_app.status || prev_app.version != curr_app.version {
changed_apps.push((*curr_app).clone());
}
}
None => changed_apps.push((*curr_app).clone()),
}
}
for id in prev_apps.keys() {
if !curr_apps.contains_key(id) {
removed_apps.push(id.to_string());
}
}
if !changed_apps.is_empty() {
delta.apps = Some(changed_apps);
}
if !removed_apps.is_empty() {
delta.apps_rm = Some(removed_apps);
}
// Compare scalar fields
if curr.cpu_usage_percent != prev.cpu_usage_percent {
delta.cpu = curr.cpu_usage_percent;
}
if curr.mem_used_bytes != prev.mem_used_bytes {
delta.mem_u = curr.mem_used_bytes;
}
if curr.mem_total_bytes != prev.mem_total_bytes {
delta.mem_t = curr.mem_total_bytes;
}
if curr.disk_used_bytes != prev.disk_used_bytes {
delta.dsk_u = curr.disk_used_bytes;
}
if curr.disk_total_bytes != prev.disk_total_bytes {
delta.dsk_t = curr.disk_total_bytes;
}
if curr.uptime_secs != prev.uptime_secs {
delta.up = curr.uptime_secs;
}
if curr.tor_active != prev.tor_active {
delta.tor = curr.tor_active;
}
delta
}
/// Apply a delta to a base snapshot, producing an updated snapshot.
pub fn apply_delta(base: &NodeStateSnapshot, delta: &StateDelta) -> NodeStateSnapshot {
let mut result = base.clone();
result.timestamp = delta.ts.clone();
// Apply app changes
if let Some(changed) = &delta.apps {
for app in changed {
if let Some(existing) = result.apps.iter_mut().find(|a| a.id == app.id) {
existing.status = app.status.clone();
existing.version = app.version.clone();
} else {
result.apps.push(app.clone());
}
}
}
// Apply app removals
if let Some(removed) = &delta.apps_rm {
result.apps.retain(|a| !removed.contains(&a.id));
}
// Apply scalar fields
if let Some(cpu) = delta.cpu {
result.cpu_usage_percent = Some(cpu);
}
if let Some(mem_u) = delta.mem_u {
result.mem_used_bytes = Some(mem_u);
}
if let Some(mem_t) = delta.mem_t {
result.mem_total_bytes = Some(mem_t);
}
if let Some(dsk_u) = delta.dsk_u {
result.disk_used_bytes = Some(dsk_u);
}
if let Some(dsk_t) = delta.dsk_t {
result.disk_total_bytes = Some(dsk_t);
}
if let Some(up) = delta.up {
result.uptime_secs = Some(up);
}
if let Some(tor) = delta.tor {
result.tor_active = Some(tor);
}
result
}
/// Encode a delta as CBOR bytes.
pub fn encode_cbor(delta: &StateDelta) -> Result<Vec<u8>> {
let mut buf = Vec::new();
ciborium::into_writer(delta, &mut buf).context("CBOR encode failed")?;
Ok(buf)
}
/// Decode a delta from CBOR bytes.
pub fn decode_cbor(data: &[u8]) -> Result<StateDelta> {
ciborium::from_reader(data).context("CBOR decode failed")
}
/// Encode a full state snapshot as CBOR (for initial sync or Tor transport).
pub fn encode_snapshot_cbor(snapshot: &NodeStateSnapshot) -> Result<Vec<u8>> {
let mut buf = Vec::new();
ciborium::into_writer(snapshot, &mut buf).context("CBOR snapshot encode failed")?;
Ok(buf)
}
/// Decode a full state snapshot from CBOR.
pub fn decode_snapshot_cbor(data: &[u8]) -> Result<NodeStateSnapshot> {
ciborium::from_reader(data).context("CBOR snapshot decode failed")
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_snapshot_a() -> NodeStateSnapshot {
NodeStateSnapshot {
timestamp: "2026-03-16T12:00:00Z".to_string(),
apps: vec![
AppStatus {
id: "bitcoin-knots".to_string(),
status: "running".to_string(),
version: Some("27.1".to_string()),
},
AppStatus {
id: "lnd".to_string(),
status: "running".to_string(),
version: Some("0.18.0".to_string()),
},
AppStatus {
id: "mempool".to_string(),
status: "stopped".to_string(),
version: Some("3.0".to_string()),
},
],
cpu_usage_percent: Some(23.5),
mem_used_bytes: Some(4_000_000_000),
mem_total_bytes: Some(16_000_000_000),
disk_used_bytes: Some(500_000_000_000),
disk_total_bytes: Some(1_800_000_000_000),
uptime_secs: Some(86400),
tor_active: Some(true),
}
}
fn sample_snapshot_b() -> NodeStateSnapshot {
NodeStateSnapshot {
timestamp: "2026-03-16T12:05:00Z".to_string(),
apps: vec![
AppStatus {
id: "bitcoin-knots".to_string(),
status: "running".to_string(),
version: Some("27.1".to_string()),
},
AppStatus {
id: "lnd".to_string(),
status: "running".to_string(),
version: Some("0.18.0".to_string()),
},
AppStatus {
id: "mempool".to_string(),
status: "running".to_string(), // Changed: stopped -> running
version: Some("3.0".to_string()),
},
],
cpu_usage_percent: Some(35.2), // Changed
mem_used_bytes: Some(4_500_000_000), // Changed
mem_total_bytes: Some(16_000_000_000),
disk_used_bytes: Some(500_000_000_000),
disk_total_bytes: Some(1_800_000_000_000),
uptime_secs: Some(86700), // Changed
tor_active: Some(true),
}
}
#[test]
fn test_compute_delta_detects_changes() {
let a = sample_snapshot_a();
let b = sample_snapshot_b();
let delta = compute_delta(&a, &b);
assert_eq!(delta.v, DELTA_VERSION);
assert_eq!(delta.ts, "2026-03-16T12:05:00Z");
// Mempool status changed
assert!(delta.apps.is_some());
let apps = delta.apps.as_ref().unwrap();
assert_eq!(apps.len(), 1);
assert_eq!(apps[0].id, "mempool");
assert_eq!(apps[0].status, "running");
// No apps removed
assert!(delta.apps_rm.is_none());
// Scalar changes
assert_eq!(delta.cpu, Some(35.2));
assert_eq!(delta.mem_u, Some(4_500_000_000));
assert_eq!(delta.up, Some(86700));
// Unchanged fields should be None
assert!(delta.mem_t.is_none());
assert!(delta.dsk_u.is_none());
assert!(delta.dsk_t.is_none());
assert!(delta.tor.is_none());
}
#[test]
fn test_apply_delta_reconstructs() {
let a = sample_snapshot_a();
let b = sample_snapshot_b();
let delta = compute_delta(&a, &b);
let reconstructed = apply_delta(&a, &delta);
assert_eq!(reconstructed.timestamp, b.timestamp);
assert_eq!(reconstructed.cpu_usage_percent, b.cpu_usage_percent);
assert_eq!(reconstructed.mem_used_bytes, b.mem_used_bytes);
assert_eq!(reconstructed.uptime_secs, b.uptime_secs);
// Check mempool status was updated
let mempool = reconstructed.apps.iter().find(|a| a.id == "mempool").unwrap();
assert_eq!(mempool.status, "running");
}
#[test]
fn test_delta_with_app_removal() {
let a = sample_snapshot_a();
let mut b = sample_snapshot_b();
// Remove mempool from b
b.apps.retain(|app| app.id != "mempool");
let delta = compute_delta(&a, &b);
assert!(delta.apps_rm.is_some());
assert_eq!(delta.apps_rm.as_ref().unwrap(), &["mempool".to_string()]);
let reconstructed = apply_delta(&a, &delta);
assert!(reconstructed.apps.iter().all(|a| a.id != "mempool"));
}
#[test]
fn test_delta_with_new_app() {
let a = sample_snapshot_a();
let mut b = sample_snapshot_b();
b.apps.push(AppStatus {
id: "electrs".to_string(),
status: "running".to_string(),
version: Some("0.10.0".to_string()),
});
let delta = compute_delta(&a, &b);
let apps = delta.apps.as_ref().unwrap();
assert!(apps.iter().any(|a| a.id == "electrs"));
let reconstructed = apply_delta(&a, &delta);
assert!(reconstructed.apps.iter().any(|a| a.id == "electrs"));
}
#[test]
fn test_cbor_roundtrip() {
let a = sample_snapshot_a();
let b = sample_snapshot_b();
let delta = compute_delta(&a, &b);
let encoded = encode_cbor(&delta).unwrap();
let decoded = decode_cbor(&encoded).unwrap();
assert_eq!(decoded.ts, delta.ts);
assert_eq!(decoded.cpu, delta.cpu);
assert_eq!(decoded.mem_u, delta.mem_u);
assert_eq!(decoded.up, delta.up);
}
#[test]
fn test_cbor_size_vs_json() {
let a = sample_snapshot_a();
let b = sample_snapshot_b();
let delta = compute_delta(&a, &b);
let cbor_bytes = encode_cbor(&delta).unwrap();
let json_bytes = serde_json::to_vec(&b).unwrap();
// CBOR delta should be significantly smaller than full JSON snapshot
assert!(
cbor_bytes.len() < json_bytes.len(),
"CBOR delta ({} bytes) should be smaller than full JSON ({} bytes)",
cbor_bytes.len(),
json_bytes.len()
);
}
#[test]
fn test_snapshot_cbor_roundtrip() {
let snapshot = sample_snapshot_a();
let encoded = encode_snapshot_cbor(&snapshot).unwrap();
let decoded = decode_snapshot_cbor(&encoded).unwrap();
assert_eq!(decoded.timestamp, snapshot.timestamp);
assert_eq!(decoded.apps.len(), snapshot.apps.len());
assert_eq!(decoded.cpu_usage_percent, snapshot.cpu_usage_percent);
}
#[test]
fn test_no_changes_produces_minimal_delta() {
let a = sample_snapshot_a();
let mut b = a.clone();
b.timestamp = "2026-03-16T12:01:00Z".to_string();
let delta = compute_delta(&a, &b);
// Only timestamp should differ
assert!(delta.apps.is_none());
assert!(delta.apps_rm.is_none());
assert!(delta.cpu.is_none());
assert!(delta.mem_u.is_none());
assert!(delta.tor.is_none());
let cbor_bytes = encode_cbor(&delta).unwrap();
// Minimal delta should be very small (just timestamp + version)
assert!(cbor_bytes.len() < 50, "Minimal delta should be <50 bytes, got {}", cbor_bytes.len());
}
}
+170
View File
@@ -0,0 +1,170 @@
//! LAN transport — peer discovery via mDNS and direct HTTP messaging.
//!
//! Advertises this node as `_archipelago._tcp.local.` with TXT records
//! containing the node's DID and public key. Discovers other Archipelago
//! nodes on the same LAN segment. Sends messages via direct HTTP POST
//! to the discovered IP:port — same endpoint as Tor transport but without
//! the SOCKS5 proxy, for near-zero latency on local networks.
use super::{NodeTransport, PeerRegistry, PeerSource, TransportKind, TransportMessage};
use anyhow::{Context, Result};
use mdns_sd::{ServiceDaemon, ServiceEvent, ServiceInfo};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::RwLock;
use tracing::{debug, info, warn};
const SERVICE_TYPE: &str = "_archipelago._tcp.local.";
const DEFAULT_PORT: u16 = 5678;
const LAN_TIMEOUT: Duration = Duration::from_secs(10);
pub struct LanTransport {
our_did: String,
our_pubkey_hex: String,
our_port: u16,
daemon: Option<ServiceDaemon>,
available: AtomicBool,
}
impl LanTransport {
/// Create a new LAN transport. Does not start discovery yet.
pub fn new(our_did: &str, our_pubkey_hex: &str, port: u16) -> Self {
Self {
our_did: our_did.to_string(),
our_pubkey_hex: our_pubkey_hex.to_string(),
our_port: port,
daemon: None,
available: AtomicBool::new(false),
}
}
/// Start the mDNS daemon, advertise our service, and begin browsing.
/// Non-blocking — spawns background tasks for discovery.
pub fn start(&mut self, registry: Arc<PeerRegistry>) -> Result<()> {
let daemon = ServiceDaemon::new()
.context("Failed to create mDNS daemon")?;
// Advertise our service
let hostname = format!("archy-{}.local.", &self.our_pubkey_hex[..8]);
let properties = vec![
("did".to_string(), self.our_did.clone()),
("pubkey".to_string(), self.our_pubkey_hex.clone()),
("version".to_string(), "0.1.0".to_string()),
];
let service_info = ServiceInfo::new(
SERVICE_TYPE,
&format!("archy-{}", &self.our_pubkey_hex[..8]),
&hostname,
"",
self.our_port,
Some(properties.into_iter().collect()),
)
.context("Failed to create mDNS service info")?;
daemon
.register(service_info)
.context("Failed to register mDNS service")?;
// Browse for other Archipelago nodes
let receiver = daemon
.browse(SERVICE_TYPE)
.context("Failed to browse mDNS services")?;
self.daemon = Some(daemon);
self.available.store(true, Ordering::Relaxed);
info!("LAN transport started — advertising {}", SERVICE_TYPE);
// Spawn background discovery listener
let registry_clone = registry;
tokio::spawn(async move {
while let Ok(event) = receiver.recv() {
match event {
ServiceEvent::ServiceResolved(info) => {
let did = info.get_properties().get("did").map(|v| v.val_str().to_string());
let pubkey = info.get_properties().get("pubkey").map(|v| v.val_str().to_string());
let addresses = info.get_addresses();
if let (Some(did), Some(pubkey)) = (did, pubkey) {
if let Some(scoped_ip) = addresses.iter().next() {
let ip: std::net::IpAddr = (*scoped_ip).into();
let socket_addr = std::net::SocketAddr::new(ip, info.get_port());
info!(did = %did, addr = %socket_addr, "Discovered LAN peer via mDNS");
registry_clone
.register_peer(&did, &pubkey, PeerSource::LanDiscovery)
.await;
registry_clone
.set_lan_address(&did, socket_addr)
.await;
registry_clone
.set_name(&did, info.get_fullname())
.await;
}
}
}
ServiceEvent::ServiceRemoved(_, name) => {
debug!(name = %name, "LAN peer removed");
}
_ => {}
}
}
});
Ok(())
}
async fn send_impl(&self, address: &str, message: &TransportMessage) -> Result<()> {
// address is "ip:port" format
let url = format!("http://{}/archipelago/node-message", address);
let encoded_payload = {
use base64::Engine;
base64::engine::general_purpose::STANDARD.encode(&message.payload)
};
let body = serde_json::json!({
"from_pubkey": self.our_pubkey_hex,
"from_did": message.from_did,
"message": encoded_payload,
"message_type": message.message_type,
"timestamp": chrono::Utc::now().to_rfc3339(),
"transport": "lan",
});
let client = reqwest::Client::builder()
.timeout(LAN_TIMEOUT)
.build()
.context("Failed to build LAN HTTP client")?;
let resp = client
.post(&url)
.json(&body)
.send()
.await
.map_err(|e| anyhow::anyhow!("LAN send to {} failed: {}", address, e))?;
if !resp.status().is_success() {
anyhow::bail!("LAN peer at {} returned {}", address, resp.status().as_u16());
}
Ok(())
}
}
impl NodeTransport for LanTransport {
fn kind(&self) -> TransportKind {
TransportKind::Lan
}
fn is_available(&self) -> bool {
self.available.load(Ordering::Relaxed)
}
fn send<'a>(
&'a self,
address: &'a str,
message: &'a TransportMessage,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + 'a>> {
Box::pin(async move { self.send_impl(address, message).await })
}
}
@@ -0,0 +1,114 @@
//! Mesh transport — sends messages via LoRa radio through the MeshService.
//!
//! Bridges the transport abstraction to the existing mesh serial listener.
//! For payloads exceeding the LoRa frame limit (160 bytes), uses the chunking
//! protocol with Reed-Solomon FEC for reliable delivery.
use super::chunking::{self, ChunkReassembler, MAX_CHUNK_PAYLOAD};
use super::{NodeTransport, TransportKind, TransportMessage};
use crate::mesh::MeshService;
use anyhow::{Context, Result};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::RwLock;
/// Inter-chunk delay for LoRa airtime fairness.
const CHUNK_DELAY: Duration = Duration::from_millis(200);
/// Maximum single-frame payload (before chunking kicks in).
/// After ChaCha20-Poly1305 overhead: 160 - 12 (nonce) - 16 (tag) = 132 bytes.
const MAX_SINGLE_FRAME: usize = 132;
pub struct MeshTransport {
mesh_service: Arc<RwLock<Option<MeshService>>>,
reassembler: Arc<RwLock<ChunkReassembler>>,
}
impl MeshTransport {
pub fn new(mesh_service: Arc<RwLock<Option<MeshService>>>) -> Self {
Self {
mesh_service,
reassembler: Arc::new(RwLock::new(ChunkReassembler::new())),
}
}
/// Get a reference to the chunk reassembler (for incoming message processing).
pub fn reassembler(&self) -> Arc<RwLock<ChunkReassembler>> {
Arc::clone(&self.reassembler)
}
async fn send_impl(&self, contact_id_str: &str, message: &TransportMessage) -> Result<()> {
let contact_id: u32 = contact_id_str
.parse()
.context("Invalid mesh contact ID")?;
let service = self.mesh_service.read().await;
let service = service
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
// Serialize the transport message as CBOR for compact encoding
let mut payload = Vec::new();
ciborium::into_writer(message, &mut payload)
.context("Failed to CBOR-encode transport message")?;
if payload.len() <= MAX_SINGLE_FRAME {
// Fits in a single LoRa frame — send directly as text
let text = {
use base64::Engine;
base64::engine::general_purpose::STANDARD.encode(&payload)
};
service
.send_message(contact_id, &text)
.await
.context("Mesh single-frame send failed")?;
} else {
// Chunk with FEC
let chunks = chunking::encode_chunked(&payload)?;
tracing::info!(
chunks = chunks.len(),
payload_bytes = payload.len(),
"Sending chunked message over mesh"
);
for chunk in &chunks {
let chunk_bytes = chunk.to_bytes();
let text = {
use base64::Engine;
base64::engine::general_purpose::STANDARD.encode(&chunk_bytes)
};
service
.send_message(contact_id, &text)
.await
.context("Mesh chunk send failed")?;
tokio::time::sleep(CHUNK_DELAY).await;
}
}
Ok(())
}
}
impl NodeTransport for MeshTransport {
fn kind(&self) -> TransportKind {
TransportKind::Mesh
}
fn is_available(&self) -> bool {
// Check synchronously — we can't await here, so use try_read
match self.mesh_service.try_read() {
Ok(guard) => match guard.as_ref() {
Some(_service) => true, // Service exists
None => false,
},
Err(_) => false, // Lock contention — assume unavailable
}
}
fn send<'a>(
&'a self,
address: &'a str,
message: &'a TransportMessage,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + 'a>> {
Box::pin(async move { self.send_impl(address, message).await })
}
}
+568
View File
@@ -0,0 +1,568 @@
//! Transport abstraction layer for Archipelago node-to-node communication.
//!
//! Unifies mesh radio (LoRa), LAN (mDNS), and Tor under a common trait.
//! Routes messages to peers via the best available transport with automatic
//! fallback: Mesh (priority 1) > LAN (2) > Tor (3).
pub mod chunking;
pub mod delta;
pub mod lan;
pub mod mesh_transport;
pub mod tor;
use crate::federation::TrustLevel;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::fs;
use tokio::sync::RwLock;
use tracing::{info, warn};
// ─── Transport Kind ─────────────────────────────────────────────────────
/// Transport backend type, ordered by priority (lower = preferred).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TransportKind {
Mesh = 1,
Lan = 2,
Tor = 3,
}
impl std::fmt::Display for TransportKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Mesh => write!(f, "mesh"),
Self::Lan => write!(f, "lan"),
Self::Tor => write!(f, "tor"),
}
}
}
// ─── Message Types ──────────────────────────────────────────────────────
/// Type of transport-level message.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MessageType {
StateSync,
PeerMessage,
FederationRpc,
}
/// A message sent between nodes via any transport.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TransportMessage {
pub from_did: String,
pub payload: Vec<u8>,
pub message_type: MessageType,
}
// ─── NodeTransport Trait ────────────────────────────────────────────────
/// Trait implemented by each transport backend (Tor, Mesh, LAN).
pub trait NodeTransport: Send + Sync {
/// Which transport this is.
fn kind(&self) -> TransportKind;
/// Whether this transport is currently operational.
fn is_available(&self) -> bool;
/// Send raw bytes to a peer at their transport-specific address.
/// For Tor: address is an onion hostname.
/// For Mesh: address is a contact_id as string.
/// For LAN: address is "ip:port".
fn send<'a>(
&'a self,
address: &'a str,
message: &'a TransportMessage,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + 'a>>;
}
// ─── Peer Registry ──────────────────────────────────────────────────────
/// How we discovered this peer.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PeerSource {
Federation,
MeshDiscovery,
LanDiscovery,
NostrHandshake,
Manual,
}
/// Unified peer record with per-transport capabilities.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PeerRecord {
pub did: String,
pub pubkey_hex: String,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub trust_level: Option<TrustLevel>,
#[serde(default)]
pub source: Option<PeerSource>,
// Transport-specific addresses
#[serde(default)]
pub mesh_contact_id: Option<u32>,
#[serde(default)]
pub lan_address: Option<String>,
#[serde(default)]
pub onion_address: Option<String>,
// Freshness timestamps (RFC 3339)
#[serde(default)]
pub last_mesh: Option<String>,
#[serde(default)]
pub last_lan: Option<String>,
#[serde(default)]
pub last_tor: Option<String>,
}
impl PeerRecord {
/// Get the transport-specific address for a given transport kind.
pub fn address_for(&self, kind: TransportKind) -> Option<String> {
match kind {
TransportKind::Mesh => self.mesh_contact_id.map(|id| id.to_string()),
TransportKind::Lan => self.lan_address.clone(),
TransportKind::Tor => self.onion_address.clone(),
}
}
/// Check if the last-seen timestamp for a transport is fresh enough.
/// Mesh/LAN: 5 minutes. Tor: 1 hour.
pub fn is_fresh(&self, kind: TransportKind) -> bool {
let timestamp = match kind {
TransportKind::Mesh => self.last_mesh.as_deref(),
TransportKind::Lan => self.last_lan.as_deref(),
TransportKind::Tor => self.last_tor.as_deref(),
};
let Some(ts) = timestamp else {
// No timestamp means we haven't confirmed it, but the address exists.
// Allow it — the send will fail if unreachable.
return true;
};
let Ok(parsed) = chrono::DateTime::parse_from_rfc3339(ts) else {
return false;
};
let age = chrono::Utc::now().signed_duration_since(parsed);
let max_age = match kind {
TransportKind::Mesh | TransportKind::Lan => chrono::Duration::minutes(5),
TransportKind::Tor => chrono::Duration::hours(1),
};
age < max_age
}
/// List available transport kinds for this peer, in priority order.
pub fn available_transports(&self) -> Vec<TransportKind> {
let mut result = Vec::new();
if self.mesh_contact_id.is_some() {
result.push(TransportKind::Mesh);
}
if self.lan_address.is_some() {
result.push(TransportKind::Lan);
}
if self.onion_address.is_some() {
result.push(TransportKind::Tor);
}
result
}
}
const PEERS_FILE: &str = "transport-peers.json";
/// Thread-safe registry of all known peers with their transport capabilities.
pub struct PeerRegistry {
peers: RwLock<HashMap<String, PeerRecord>>,
data_dir: PathBuf,
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct PeersFile {
peers: Vec<PeerRecord>,
}
impl PeerRegistry {
/// Load peer registry from disk (or create empty).
pub async fn load(data_dir: &Path) -> Result<Self> {
let path = data_dir.join(PEERS_FILE);
let peers = if path.exists() {
let content = fs::read_to_string(&path)
.await
.context("Failed to read transport peers")?;
let file: PeersFile = serde_json::from_str(&content).unwrap_or_default();
file.peers
.into_iter()
.map(|p| (p.did.clone(), p))
.collect()
} else {
HashMap::new()
};
Ok(Self {
peers: RwLock::new(peers),
data_dir: data_dir.to_path_buf(),
})
}
/// Persist current state to disk.
pub async fn save(&self) -> Result<()> {
let peers = self.peers.read().await;
let file = PeersFile {
peers: peers.values().cloned().collect(),
};
let content =
serde_json::to_string_pretty(&file).context("Failed to serialize transport peers")?;
fs::write(self.data_dir.join(PEERS_FILE), content)
.await
.context("Failed to write transport peers")?;
Ok(())
}
/// Register or update a peer.
pub async fn register_peer(
&self,
did: &str,
pubkey_hex: &str,
source: PeerSource,
) -> PeerRecord {
let mut peers = self.peers.write().await;
let record = peers.entry(did.to_string()).or_insert_with(|| PeerRecord {
did: did.to_string(),
pubkey_hex: pubkey_hex.to_string(),
name: None,
trust_level: None,
source: Some(source.clone()),
mesh_contact_id: None,
lan_address: None,
onion_address: None,
last_mesh: None,
last_lan: None,
last_tor: None,
});
// Update pubkey if it changed
if record.pubkey_hex != pubkey_hex {
record.pubkey_hex = pubkey_hex.to_string();
}
record.clone()
}
/// Set the mesh contact ID for a peer.
pub async fn set_mesh_id(&self, did: &str, contact_id: u32) {
let mut peers = self.peers.write().await;
if let Some(peer) = peers.get_mut(did) {
peer.mesh_contact_id = Some(contact_id);
peer.last_mesh = Some(chrono::Utc::now().to_rfc3339());
}
}
/// Set the LAN address for a peer.
pub async fn set_lan_address(&self, did: &str, addr: SocketAddr) {
let mut peers = self.peers.write().await;
if let Some(peer) = peers.get_mut(did) {
peer.lan_address = Some(addr.to_string());
peer.last_lan = Some(chrono::Utc::now().to_rfc3339());
}
}
/// Set the onion address for a peer.
pub async fn set_onion(&self, did: &str, onion: &str) {
let mut peers = self.peers.write().await;
if let Some(peer) = peers.get_mut(did) {
peer.onion_address = Some(onion.to_string());
peer.last_tor = Some(chrono::Utc::now().to_rfc3339());
}
}
/// Set the display name for a peer.
pub async fn set_name(&self, did: &str, name: &str) {
let mut peers = self.peers.write().await;
if let Some(peer) = peers.get_mut(did) {
peer.name = Some(name.to_string());
}
}
/// Get a peer by DID.
pub async fn get_peer(&self, did: &str) -> Option<PeerRecord> {
self.peers.read().await.get(did).cloned()
}
/// Get all peers.
pub async fn all_peers(&self) -> Vec<PeerRecord> {
self.peers.read().await.values().cloned().collect()
}
/// Count of registered peers.
pub async fn count(&self) -> usize {
self.peers.read().await.len()
}
}
// ─── Transport Router ───────────────────────────────────────────────────
/// Routes messages to the best available transport per peer.
pub struct TransportRouter {
transports: Vec<Box<dyn NodeTransport>>,
pub registry: Arc<PeerRegistry>,
mesh_only: RwLock<bool>,
}
impl TransportRouter {
pub fn new(
transports: Vec<Box<dyn NodeTransport>>,
registry: Arc<PeerRegistry>,
mesh_only: bool,
) -> Self {
Self {
transports,
registry,
mesh_only: RwLock::new(mesh_only),
}
}
/// Send a message to a peer by DID, using the best available transport.
pub async fn send_to_peer(
&self,
did: &str,
message: &TransportMessage,
) -> Result<TransportKind> {
let peer = self
.registry
.get_peer(did)
.await
.ok_or_else(|| anyhow::anyhow!("Unknown peer: {}", did))?;
let candidates = self.route(&peer).await;
if candidates.is_empty() {
anyhow::bail!("No available transport for peer {}", did);
}
let mut last_err = None;
for kind in &candidates {
let transport = match self.transports.iter().find(|t| t.kind() == *kind) {
Some(t) => t,
None => continue,
};
let address = match peer.address_for(*kind) {
Some(a) => a,
None => continue,
};
match transport.send(&address, message).await {
Ok(()) => {
info!(transport = %kind, peer = %did, "Message sent");
return Ok(*kind);
}
Err(e) => {
warn!(transport = %kind, peer = %did, error = %e, "Transport failed, trying next");
last_err = Some(e);
}
}
}
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("All transports failed for peer {}", did)))
}
/// Determine transport priority for a peer.
async fn route(&self, peer: &PeerRecord) -> Vec<TransportKind> {
let mesh_only = *self.mesh_only.read().await;
let mut available = Vec::new();
if mesh_only {
// Off-grid mode: only mesh
if peer.mesh_contact_id.is_some() {
available.push(TransportKind::Mesh);
}
} else {
// Normal mode: priority order, check freshness
if peer.mesh_contact_id.is_some() && peer.is_fresh(TransportKind::Mesh) {
if let Some(t) = self.transports.iter().find(|t| t.kind() == TransportKind::Mesh) {
if t.is_available() {
available.push(TransportKind::Mesh);
}
}
}
if peer.lan_address.is_some() && peer.is_fresh(TransportKind::Lan) {
if let Some(t) = self.transports.iter().find(|t| t.kind() == TransportKind::Lan) {
if t.is_available() {
available.push(TransportKind::Lan);
}
}
}
if peer.onion_address.is_some() {
if let Some(t) = self.transports.iter().find(|t| t.kind() == TransportKind::Tor) {
if t.is_available() {
available.push(TransportKind::Tor);
}
}
}
}
available
}
/// Set mesh-only (off-grid) mode.
pub async fn set_mesh_only(&self, enabled: bool) {
*self.mesh_only.write().await = enabled;
}
/// Get current mesh-only mode status.
pub async fn is_mesh_only(&self) -> bool {
*self.mesh_only.read().await
}
/// Get status of all transports.
pub fn transport_status(&self) -> Vec<(TransportKind, bool)> {
self.transports
.iter()
.map(|t| (t.kind(), t.is_available()))
.collect()
}
}
// ─── Tests ──────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_transport_kind_ordering() {
assert!(TransportKind::Mesh < TransportKind::Lan);
assert!(TransportKind::Lan < TransportKind::Tor);
}
#[test]
fn test_peer_record_address_for() {
let peer = PeerRecord {
did: "did:key:z6MkTest".to_string(),
pubkey_hex: "aabb".to_string(),
name: Some("test-node".to_string()),
trust_level: None,
source: None,
mesh_contact_id: Some(42),
lan_address: Some("192.168.1.100:5678".to_string()),
onion_address: Some("abc123.onion".to_string()),
last_mesh: None,
last_lan: None,
last_tor: None,
};
assert_eq!(peer.address_for(TransportKind::Mesh), Some("42".to_string()));
assert_eq!(
peer.address_for(TransportKind::Lan),
Some("192.168.1.100:5678".to_string())
);
assert_eq!(
peer.address_for(TransportKind::Tor),
Some("abc123.onion".to_string())
);
}
#[test]
fn test_peer_record_available_transports() {
let peer = PeerRecord {
did: "did:key:z6MkTest".to_string(),
pubkey_hex: "aabb".to_string(),
name: None,
trust_level: None,
source: None,
mesh_contact_id: Some(1),
lan_address: None,
onion_address: Some("test.onion".to_string()),
last_mesh: None,
last_lan: None,
last_tor: None,
};
let transports = peer.available_transports();
assert_eq!(transports, vec![TransportKind::Mesh, TransportKind::Tor]);
}
#[test]
fn test_freshness_no_timestamp() {
let peer = PeerRecord {
did: "did:key:z6MkTest".to_string(),
pubkey_hex: "aabb".to_string(),
name: None,
trust_level: None,
source: None,
mesh_contact_id: Some(1),
lan_address: None,
onion_address: None,
last_mesh: None,
last_lan: None,
last_tor: None,
};
// No timestamp = considered fresh (allows first attempt)
assert!(peer.is_fresh(TransportKind::Mesh));
}
#[test]
fn test_freshness_recent_timestamp() {
let peer = PeerRecord {
did: "did:key:z6MkTest".to_string(),
pubkey_hex: "aabb".to_string(),
name: None,
trust_level: None,
source: None,
mesh_contact_id: Some(1),
lan_address: None,
onion_address: None,
last_mesh: Some(chrono::Utc::now().to_rfc3339()),
last_lan: None,
last_tor: None,
};
assert!(peer.is_fresh(TransportKind::Mesh));
}
#[test]
fn test_freshness_stale_timestamp() {
let stale = chrono::Utc::now() - chrono::Duration::minutes(10);
let peer = PeerRecord {
did: "did:key:z6MkTest".to_string(),
pubkey_hex: "aabb".to_string(),
name: None,
trust_level: None,
source: None,
mesh_contact_id: Some(1),
lan_address: None,
onion_address: None,
last_mesh: Some(stale.to_rfc3339()),
last_lan: None,
last_tor: None,
};
// 10 minutes old > 5 minute mesh freshness threshold
assert!(!peer.is_fresh(TransportKind::Mesh));
}
#[tokio::test]
async fn test_peer_registry_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let registry = PeerRegistry::load(dir.path()).await.unwrap();
registry
.register_peer(
"did:key:z6MkTest",
"aabbccdd",
PeerSource::MeshDiscovery,
)
.await;
registry.set_mesh_id("did:key:z6MkTest", 42).await;
registry
.set_onion("did:key:z6MkTest", "test123.onion")
.await;
registry.save().await.unwrap();
// Reload from disk
let registry2 = PeerRegistry::load(dir.path()).await.unwrap();
let peer = registry2.get_peer("did:key:z6MkTest").await.unwrap();
assert_eq!(peer.mesh_contact_id, Some(42));
assert_eq!(peer.onion_address, Some("test123.onion".to_string()));
assert_eq!(peer.pubkey_hex, "aabbccdd");
}
}
+102
View File
@@ -0,0 +1,102 @@
//! Tor transport — sends messages via HTTP POST through SOCKS5 proxy.
//!
//! Wraps the existing `node_message.rs` Tor messaging logic behind
//! the `NodeTransport` trait.
use super::{MessageType, NodeTransport, TransportKind, TransportMessage};
use anyhow::{Context, Result};
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
const TOR_SOCKS: &str = "socks5h://127.0.0.1:9050";
const TOR_TIMEOUT: Duration = Duration::from_secs(60);
pub struct TorTransport {
our_pubkey_hex: String,
available: AtomicBool,
}
impl TorTransport {
pub fn new(our_pubkey_hex: &str) -> Self {
Self {
our_pubkey_hex: our_pubkey_hex.to_string(),
available: AtomicBool::new(true), // Assume available, checked lazily
}
}
/// Update availability (call periodically from health check).
pub fn set_available(&self, avail: bool) {
self.available.store(avail, Ordering::Relaxed);
}
async fn send_impl(&self, onion_address: &str, message: &TransportMessage) -> Result<()> {
let host = if onion_address.ends_with(".onion") {
onion_address.to_string()
} else {
format!("{}.onion", onion_address)
};
let url = format!("http://{}/archipelago/node-message", host);
let encoded_payload = {
use base64::Engine;
base64::engine::general_purpose::STANDARD.encode(&message.payload)
};
let body = serde_json::json!({
"from_pubkey": self.our_pubkey_hex,
"message": encoded_payload,
"message_type": message.message_type,
"from_did": message.from_did,
"timestamp": chrono::Utc::now().to_rfc3339(),
});
let proxy = reqwest::Proxy::all(TOR_SOCKS).context("Invalid Tor proxy")?;
let client = reqwest::Client::builder()
.proxy(proxy)
.timeout(TOR_TIMEOUT)
.build()
.context("Failed to build Tor HTTP client")?;
let resp = client
.post(&url)
.json(&body)
.send()
.await
.map_err(|e| {
let msg = e.to_string();
if msg.contains("connection refused") || msg.contains("Connection refused") {
self.available.store(false, Ordering::Relaxed);
anyhow::anyhow!("Tor not reachable at 127.0.0.1:9050")
} else if msg.contains("timeout") || msg.contains("timed out") {
anyhow::anyhow!("Tor connection timed out — peer may be offline")
} else {
anyhow::anyhow!("Tor send failed: {}", msg)
}
})?;
if !resp.status().is_success() {
anyhow::bail!(
"Peer returned {} over Tor",
resp.status().as_u16()
);
}
Ok(())
}
}
impl NodeTransport for TorTransport {
fn kind(&self) -> TransportKind {
TransportKind::Tor
}
fn is_available(&self) -> bool {
self.available.load(Ordering::Relaxed)
}
fn send<'a>(
&'a self,
address: &'a str,
message: &'a TransportMessage,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + 'a>> {
Box::pin(async move { self.send_impl(address, message).await })
}
}