backup commit
This commit is contained in:
@@ -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"
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.peers — List 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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -25,6 +25,7 @@ mod identity_manager;
|
||||
mod marketplace;
|
||||
mod mesh;
|
||||
mod monitoring;
|
||||
mod transport;
|
||||
mod node_message;
|
||||
mod nostr_discovery;
|
||||
mod nostr_handshake;
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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(×tamp.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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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],
|
||||
},
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user