Archipelago — open-source initial import
This commit is contained in:
@@ -0,0 +1,304 @@
|
||||
// WIP mesh/transport protocol — suppress dead code warnings
|
||||
#![allow(dead_code)]
|
||||
//! Emergency alert system and dead man's switch for mesh networking.
|
||||
//!
|
||||
//! The dead man's switch automatically broadcasts a signed alert with GPS
|
||||
//! coordinates if the node operator hasn't interacted with the system for
|
||||
//! a configurable interval (default 6 hours). Useful for remote/off-grid
|
||||
//! deployments where physical safety is a concern.
|
||||
|
||||
use super::message_types::{
|
||||
self, AlertPayload, AlertType, Coordinate, MeshMessageType, TypedEnvelope,
|
||||
};
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
/// Default dead man's switch interval: 6 hours.
|
||||
const DEFAULT_INTERVAL_SECS: u64 = 21600;
|
||||
|
||||
/// How often the background task checks the switch (60 seconds).
|
||||
const CHECK_INTERVAL_SECS: u64 = 60;
|
||||
|
||||
const ALERT_CONFIG_FILE: &str = "alert-config.json";
|
||||
|
||||
/// Alert system configuration (persisted to disk).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AlertConfig {
|
||||
/// Whether the dead man's switch is enabled.
|
||||
pub dead_man_enabled: bool,
|
||||
/// Interval in seconds before the switch triggers.
|
||||
pub dead_man_interval_secs: u64,
|
||||
/// Last known GPS coordinates (for inclusion in alerts).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub last_gps: Option<Coordinate>,
|
||||
/// DIDs of peers to alert directly (in addition to mesh broadcast).
|
||||
#[serde(default)]
|
||||
pub emergency_contacts: Vec<String>,
|
||||
/// Whether to automatically include GPS in dead man alerts.
|
||||
pub auto_include_gps: bool,
|
||||
/// Custom message to include in dead man alert.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub custom_message: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for AlertConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
dead_man_enabled: false,
|
||||
dead_man_interval_secs: DEFAULT_INTERVAL_SECS,
|
||||
last_gps: None,
|
||||
emergency_contacts: Vec::new(),
|
||||
auto_include_gps: false,
|
||||
custom_message: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Load alert config from disk.
|
||||
pub async fn load_config(data_dir: &Path) -> Result<AlertConfig> {
|
||||
let path = data_dir.join(ALERT_CONFIG_FILE);
|
||||
if !path.exists() {
|
||||
return Ok(AlertConfig::default());
|
||||
}
|
||||
let content = tokio::fs::read_to_string(&path)
|
||||
.await
|
||||
.context("Failed to read alert config")?;
|
||||
let config: AlertConfig = serde_json::from_str(&content).unwrap_or_default();
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Save alert config to disk.
|
||||
pub async fn save_config(data_dir: &Path, config: &AlertConfig) -> Result<()> {
|
||||
let content =
|
||||
serde_json::to_string_pretty(config).context("Failed to serialize alert config")?;
|
||||
tokio::fs::write(data_dir.join(ALERT_CONFIG_FILE), content)
|
||||
.await
|
||||
.context("Failed to write alert config")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Dead man's switch state.
|
||||
pub struct DeadManSwitch {
|
||||
config: RwLock<AlertConfig>,
|
||||
last_activity: RwLock<Instant>,
|
||||
triggered: RwLock<bool>,
|
||||
data_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl DeadManSwitch {
|
||||
/// Create a new dead man's switch.
|
||||
pub async fn new(data_dir: &Path) -> Result<Self> {
|
||||
let config = load_config(data_dir).await?;
|
||||
Ok(Self {
|
||||
config: RwLock::new(config),
|
||||
last_activity: RwLock::new(Instant::now()),
|
||||
triggered: RwLock::new(false),
|
||||
data_dir: data_dir.to_path_buf(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Record user activity (resets the timer).
|
||||
pub async fn check_in(&self) {
|
||||
*self.last_activity.write().await = Instant::now();
|
||||
*self.triggered.write().await = false;
|
||||
}
|
||||
|
||||
/// Check if the switch has been triggered.
|
||||
pub async fn is_triggered(&self) -> bool {
|
||||
let config = self.config.read().await;
|
||||
if !config.dead_man_enabled {
|
||||
return false;
|
||||
}
|
||||
let last = *self.last_activity.read().await;
|
||||
let interval = Duration::from_secs(config.dead_man_interval_secs);
|
||||
last.elapsed() > interval
|
||||
}
|
||||
|
||||
/// Update configuration.
|
||||
pub async fn configure(&self, config: AlertConfig) -> Result<()> {
|
||||
save_config(&self.data_dir, &config).await?;
|
||||
*self.config.write().await = config;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get current configuration.
|
||||
pub async fn get_config(&self) -> AlertConfig {
|
||||
self.config.read().await.clone()
|
||||
}
|
||||
|
||||
/// Get time remaining before trigger (in seconds), or 0 if triggered.
|
||||
pub async fn time_remaining_secs(&self) -> u64 {
|
||||
let config = self.config.read().await;
|
||||
if !config.dead_man_enabled {
|
||||
return u64::MAX;
|
||||
}
|
||||
let last = *self.last_activity.read().await;
|
||||
let interval = Duration::from_secs(config.dead_man_interval_secs);
|
||||
let elapsed = last.elapsed();
|
||||
if elapsed > interval {
|
||||
0
|
||||
} else {
|
||||
(interval - elapsed).as_secs()
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the dead man alert payload.
|
||||
pub async fn build_alert(&self) -> AlertPayload {
|
||||
let config = self.config.read().await;
|
||||
let message = config.custom_message.clone().unwrap_or_else(|| {
|
||||
"Dead man's switch triggered — node operator unresponsive".to_string()
|
||||
});
|
||||
|
||||
AlertPayload {
|
||||
alert_type: AlertType::DeadMan,
|
||||
message,
|
||||
coordinate: if config.auto_include_gps {
|
||||
config.last_gps.clone()
|
||||
} else {
|
||||
None
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a signed alert envelope ready for mesh transmission.
|
||||
pub async fn build_signed_alert(
|
||||
&self,
|
||||
signing_key: &ed25519_dalek::SigningKey,
|
||||
) -> Result<Vec<u8>> {
|
||||
let alert = self.build_alert().await;
|
||||
let payload = message_types::encode_payload(&alert)?;
|
||||
let envelope = TypedEnvelope::new_signed(MeshMessageType::Alert, payload, signing_key);
|
||||
envelope.to_wire()
|
||||
}
|
||||
|
||||
/// Check if the alert has already been sent (prevents re-broadcasting every 60s).
|
||||
pub async fn triggered_flag(&self) -> tokio::sync::RwLockReadGuard<'_, bool> {
|
||||
self.triggered.read().await
|
||||
}
|
||||
|
||||
/// Mark the switch as having fired (alert already sent).
|
||||
pub async fn mark_triggered(&self) {
|
||||
*self.triggered.write().await = true;
|
||||
}
|
||||
|
||||
/// Get the list of emergency contact DIDs.
|
||||
pub async fn emergency_contacts(&self) -> Vec<String> {
|
||||
self.config.read().await.emergency_contacts.clone()
|
||||
}
|
||||
|
||||
/// Update GPS coordinates.
|
||||
pub async fn update_gps(&self, coord: Coordinate) -> Result<()> {
|
||||
let mut config = self.config.write().await;
|
||||
config.last_gps = Some(coord);
|
||||
save_config(&self.data_dir, &config).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get status info for RPC.
|
||||
pub async fn status(&self) -> AlertStatus {
|
||||
let config = self.config.read().await;
|
||||
let triggered = self.is_triggered().await;
|
||||
let remaining = self.time_remaining_secs().await;
|
||||
|
||||
AlertStatus {
|
||||
dead_man_enabled: config.dead_man_enabled,
|
||||
dead_man_interval_secs: config.dead_man_interval_secs,
|
||||
triggered,
|
||||
time_remaining_secs: remaining,
|
||||
has_gps: config.last_gps.is_some(),
|
||||
emergency_contacts: config.emergency_contacts.len(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Status info returned via RPC.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct AlertStatus {
|
||||
pub dead_man_enabled: bool,
|
||||
pub dead_man_interval_secs: u64,
|
||||
pub triggered: bool,
|
||||
pub time_remaining_secs: u64,
|
||||
pub has_gps: bool,
|
||||
pub emergency_contacts: usize,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_config_roundtrip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let config = AlertConfig {
|
||||
dead_man_enabled: true,
|
||||
dead_man_interval_secs: 3600,
|
||||
last_gps: Some(Coordinate::from_degrees(
|
||||
30.2672,
|
||||
-97.7431,
|
||||
Some("Austin".into()),
|
||||
)),
|
||||
emergency_contacts: vec!["did:key:z6MkContact1".into()],
|
||||
auto_include_gps: true,
|
||||
custom_message: Some("Help!".into()),
|
||||
};
|
||||
save_config(dir.path(), &config).await.unwrap();
|
||||
let loaded = load_config(dir.path()).await.unwrap();
|
||||
assert!(loaded.dead_man_enabled);
|
||||
assert_eq!(loaded.dead_man_interval_secs, 3600);
|
||||
assert_eq!(loaded.emergency_contacts.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_dead_man_not_triggered_when_disabled() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let switch = DeadManSwitch::new(dir.path()).await.unwrap();
|
||||
// Default config has dead_man_enabled = false
|
||||
assert!(!switch.is_triggered().await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_check_in_resets_timer() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let switch = DeadManSwitch::new(dir.path()).await.unwrap();
|
||||
switch
|
||||
.configure(AlertConfig {
|
||||
dead_man_enabled: true,
|
||||
dead_man_interval_secs: 1, // 1 second for test
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Wait for trigger
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
assert!(switch.is_triggered().await);
|
||||
|
||||
// Check in
|
||||
switch.check_in().await;
|
||||
assert!(!switch.is_triggered().await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_build_alert_payload() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let switch = DeadManSwitch::new(dir.path()).await.unwrap();
|
||||
switch
|
||||
.configure(AlertConfig {
|
||||
dead_man_enabled: true,
|
||||
last_gps: Some(Coordinate::from_degrees(51.5074, -0.1278, None)),
|
||||
auto_include_gps: true,
|
||||
custom_message: Some("SOS".into()),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let alert = switch.build_alert().await;
|
||||
assert_eq!(alert.alert_type, AlertType::DeadMan);
|
||||
assert_eq!(alert.message, "SOS");
|
||||
assert!(alert.coordinate.is_some());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,512 @@
|
||||
// WIP mesh/transport protocol — suppress dead code warnings
|
||||
#![allow(dead_code)]
|
||||
//! Off-grid Bitcoin operations over mesh radio.
|
||||
//!
|
||||
//! Enables mesh-only nodes (no internet) to:
|
||||
//! - Receive compact block header announcements from internet-connected peers
|
||||
//! - Relay raw transactions to internet-connected peers for broadcast
|
||||
//! - Send/receive Lightning invoices and proof-of-payment via mesh
|
||||
//!
|
||||
//! All amounts in satoshis (u64), never floating point.
|
||||
|
||||
use super::message_types::{
|
||||
self, BlockHeaderPayload, LightningRelayPayload, LightningRelayResponsePayload,
|
||||
MeshMessageType, TxRelayPayload, TxRelayResponsePayload, TypedEnvelope,
|
||||
};
|
||||
use anyhow::{Context, Result};
|
||||
use std::collections::HashMap;
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::warn;
|
||||
|
||||
// ─── Block Header Cache ─────────────────────────────────────────────────
|
||||
|
||||
/// Stores the latest block headers received via mesh (for mesh-only SPV).
|
||||
pub struct BlockHeaderCache {
|
||||
/// Latest known block height.
|
||||
latest_height: RwLock<u64>,
|
||||
/// Recent headers (height -> header).
|
||||
headers: RwLock<HashMap<u64, BlockHeaderPayload>>,
|
||||
/// Maximum headers to cache.
|
||||
max_cached: usize,
|
||||
}
|
||||
|
||||
impl BlockHeaderCache {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
latest_height: RwLock::new(0),
|
||||
headers: RwLock::new(HashMap::new()),
|
||||
max_cached: 100,
|
||||
}
|
||||
}
|
||||
|
||||
/// Store a received block header.
|
||||
pub async fn store_header(&self, header: BlockHeaderPayload) -> Result<()> {
|
||||
let mut latest = self.latest_height.write().await;
|
||||
let mut headers = self.headers.write().await;
|
||||
|
||||
if header.height > *latest {
|
||||
*latest = header.height;
|
||||
}
|
||||
|
||||
headers.insert(header.height, header);
|
||||
|
||||
// Evict oldest if over limit
|
||||
if headers.len() > self.max_cached {
|
||||
let min_height = *latest - self.max_cached as u64;
|
||||
headers.retain(|h, _| *h > min_height);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the latest block height received via mesh.
|
||||
pub async fn latest_height(&self) -> u64 {
|
||||
*self.latest_height.read().await
|
||||
}
|
||||
|
||||
/// Get a specific header by height.
|
||||
pub async fn get_header(&self, height: u64) -> Option<BlockHeaderPayload> {
|
||||
self.headers.read().await.get(&height).cloned()
|
||||
}
|
||||
|
||||
/// Get the N most recent headers.
|
||||
pub async fn recent_headers(&self, count: usize) -> Vec<BlockHeaderPayload> {
|
||||
let headers = self.headers.read().await;
|
||||
let mut sorted: Vec<_> = headers.values().cloned().collect();
|
||||
sorted.sort_by(|a, b| b.height.cmp(&a.height));
|
||||
sorted.truncate(count);
|
||||
sorted
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for BlockHeaderCache {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Pending Relay Requests ─────────────────────────────────────────────
|
||||
|
||||
/// Tracks in-flight relay requests awaiting responses.
|
||||
pub struct RelayTracker {
|
||||
/// Pending TX relay requests (request_id -> original requester DID).
|
||||
tx_requests: RwLock<HashMap<u64, PendingRelay>>,
|
||||
/// Pending Lightning relay requests.
|
||||
lightning_requests: RwLock<HashMap<u64, PendingRelay>>,
|
||||
/// Completed relay results (kept for 5 minutes for frontend polling).
|
||||
completed_results: RwLock<Vec<RelayResult>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct PendingRelay {
|
||||
requester_did: String,
|
||||
created_at: String,
|
||||
}
|
||||
|
||||
/// Result of a completed relay attempt, stored for frontend polling.
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct RelayResult {
|
||||
pub request_id: u64,
|
||||
pub txid: Option<String>,
|
||||
pub error: Option<String>,
|
||||
pub error_code: Option<String>,
|
||||
pub completed_at: String,
|
||||
}
|
||||
|
||||
impl RelayTracker {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
tx_requests: RwLock::new(HashMap::new()),
|
||||
lightning_requests: RwLock::new(HashMap::new()),
|
||||
completed_results: RwLock::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a pending TX relay request.
|
||||
pub async fn track_tx_relay(&self, request_id: u64, requester_did: &str) {
|
||||
self.tx_requests.write().await.insert(
|
||||
request_id,
|
||||
PendingRelay {
|
||||
requester_did: requester_did.to_string(),
|
||||
created_at: chrono::Utc::now().to_rfc3339(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Complete a TX relay request and return the original requester's DID.
|
||||
pub async fn complete_tx_relay(&self, request_id: u64) -> Option<String> {
|
||||
self.tx_requests
|
||||
.write()
|
||||
.await
|
||||
.remove(&request_id)
|
||||
.map(|r| r.requester_did)
|
||||
}
|
||||
|
||||
/// Register a pending Lightning relay request.
|
||||
pub async fn track_lightning_relay(&self, request_id: u64, requester_did: &str) {
|
||||
self.lightning_requests.write().await.insert(
|
||||
request_id,
|
||||
PendingRelay {
|
||||
requester_did: requester_did.to_string(),
|
||||
created_at: chrono::Utc::now().to_rfc3339(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Complete a Lightning relay request.
|
||||
pub async fn complete_lightning_relay(&self, request_id: u64) -> Option<String> {
|
||||
self.lightning_requests
|
||||
.write()
|
||||
.await
|
||||
.remove(&request_id)
|
||||
.map(|r| r.requester_did)
|
||||
}
|
||||
|
||||
/// Count pending requests.
|
||||
pub async fn pending_count(&self) -> (usize, usize) {
|
||||
let tx = self.tx_requests.read().await.len();
|
||||
let ln = self.lightning_requests.read().await.len();
|
||||
(tx, ln)
|
||||
}
|
||||
|
||||
/// Store a completed relay result for frontend polling.
|
||||
pub async fn store_result(&self, result: RelayResult) {
|
||||
let mut results = self.completed_results.write().await;
|
||||
// Evict results older than 5 minutes
|
||||
let cutoff = chrono::Utc::now() - chrono::Duration::minutes(5);
|
||||
let cutoff_str = cutoff.to_rfc3339();
|
||||
results.retain(|r| r.completed_at > cutoff_str);
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
/// Get relay result by request_id (returns None if not yet completed or expired).
|
||||
pub async fn get_result(&self, request_id: u64) -> Option<RelayResult> {
|
||||
self.completed_results
|
||||
.read()
|
||||
.await
|
||||
.iter()
|
||||
.find(|r| r.request_id == request_id)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// Check if a TX relay request is still pending.
|
||||
pub async fn is_pending(&self, request_id: u64) -> bool {
|
||||
self.tx_requests.read().await.contains_key(&request_id)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for RelayTracker {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Block Header Announcement Builder ──────────────────────────────────
|
||||
|
||||
/// Build a compact block header announcement for mesh broadcast.
|
||||
/// Uses raw binary (not CBOR) to fit within the 160-byte LoRa limit:
|
||||
/// height(8 LE) + hash_raw(32) + timestamp(4 LE) = 44 bytes payload
|
||||
/// Wrapped in unsigned TypedEnvelope (~25 bytes overhead) = ~69 total.
|
||||
pub fn build_block_header_announcement(
|
||||
height: u64,
|
||||
hash: &str,
|
||||
_prev_hash: &str,
|
||||
timestamp: u32,
|
||||
_our_did: &str,
|
||||
_signing_key: &ed25519_dalek::SigningKey,
|
||||
) -> Result<Vec<u8>> {
|
||||
let hash_bytes = hex::decode(hash).context("Invalid block hash hex")?;
|
||||
if hash_bytes.len() != 32 {
|
||||
anyhow::bail!("Block hash must be 32 bytes, got {}", hash_bytes.len());
|
||||
}
|
||||
|
||||
// Compact binary: height(8) + hash(32) + timestamp(4) = 44 bytes
|
||||
let mut payload = Vec::with_capacity(44);
|
||||
payload.extend_from_slice(&height.to_le_bytes());
|
||||
payload.extend_from_slice(&hash_bytes);
|
||||
payload.extend_from_slice(×tamp.to_le_bytes());
|
||||
|
||||
// Use unsigned envelope to save 64 bytes (no Ed25519 signature)
|
||||
let envelope = TypedEnvelope::new(MeshMessageType::BlockHeader, payload);
|
||||
envelope.to_wire()
|
||||
}
|
||||
|
||||
/// Decode a compact block header from raw binary payload.
|
||||
/// Returns (height, hash_hex, timestamp).
|
||||
pub fn decode_compact_block_header(payload: &[u8]) -> Result<(u64, String, u32)> {
|
||||
if payload.len() < 44 {
|
||||
anyhow::bail!("Compact block header too short: {} bytes", payload.len());
|
||||
}
|
||||
let height = u64::from_le_bytes(
|
||||
payload[0..8]
|
||||
.try_into()
|
||||
.map_err(|_| anyhow::anyhow!("Invalid height bytes in block header"))?,
|
||||
);
|
||||
let hash_hex = hex::encode(&payload[8..40]);
|
||||
let timestamp = u32::from_le_bytes(
|
||||
payload[40..44]
|
||||
.try_into()
|
||||
.map_err(|_| anyhow::anyhow!("Invalid timestamp bytes in block header"))?,
|
||||
);
|
||||
Ok((height, hash_hex, timestamp))
|
||||
}
|
||||
|
||||
/// Build a TX relay request envelope.
|
||||
pub fn build_tx_relay_request(tx_hex: &str, request_id: u64) -> Result<Vec<u8>> {
|
||||
let payload = message_types::encode_payload(&TxRelayPayload {
|
||||
tx_hex: tx_hex.to_string(),
|
||||
request_id,
|
||||
})?;
|
||||
let envelope = TypedEnvelope::new(MeshMessageType::TxRelay, payload);
|
||||
envelope.to_wire()
|
||||
}
|
||||
|
||||
/// Build a TX relay response envelope.
|
||||
pub fn build_tx_relay_response(
|
||||
request_id: u64,
|
||||
txid: Option<&str>,
|
||||
error: Option<&str>,
|
||||
error_code: Option<&str>,
|
||||
) -> Result<Vec<u8>> {
|
||||
let payload = message_types::encode_payload(&TxRelayResponsePayload {
|
||||
request_id,
|
||||
txid: txid.map(|s| s.to_string()),
|
||||
error: error.map(|s| s.to_string()),
|
||||
error_code: error_code.map(|s| s.to_string()),
|
||||
})?;
|
||||
let envelope = TypedEnvelope::new(MeshMessageType::TxRelayResponse, payload);
|
||||
envelope.to_wire()
|
||||
}
|
||||
|
||||
/// Build a Lightning invoice relay request.
|
||||
pub fn build_lightning_relay_request(
|
||||
bolt11: &str,
|
||||
amount_sats: u64,
|
||||
request_id: u64,
|
||||
) -> Result<Vec<u8>> {
|
||||
let payload = message_types::encode_payload(&LightningRelayPayload {
|
||||
bolt11: bolt11.to_string(),
|
||||
amount_sats,
|
||||
request_id,
|
||||
})?;
|
||||
let envelope = TypedEnvelope::new(MeshMessageType::LightningRelay, payload);
|
||||
envelope.to_wire()
|
||||
}
|
||||
|
||||
/// Build a Lightning relay response (proof of payment).
|
||||
pub fn build_lightning_relay_response(
|
||||
request_id: u64,
|
||||
payment_hash: Option<&str>,
|
||||
preimage: Option<&str>,
|
||||
error: Option<&str>,
|
||||
) -> Result<Vec<u8>> {
|
||||
let payload = message_types::encode_payload(&LightningRelayResponsePayload {
|
||||
request_id,
|
||||
payment_hash: payment_hash.map(|s| s.to_string()),
|
||||
preimage: preimage.map(|s| s.to_string()),
|
||||
error: error.map(|s| s.to_string()),
|
||||
})?;
|
||||
let envelope = TypedEnvelope::new(MeshMessageType::LightningRelayResponse, payload);
|
||||
envelope.to_wire()
|
||||
}
|
||||
|
||||
// ─── Validation Functions ─────────────────────────────────────────────
|
||||
|
||||
/// Validate a received block header before storing/relaying.
|
||||
/// Rejects obviously invalid headers (bad version, impossibly far-ahead height).
|
||||
pub fn validate_block_header(
|
||||
height: u64,
|
||||
hash_hex: &str,
|
||||
timestamp: u32,
|
||||
last_known_height: u64,
|
||||
) -> bool {
|
||||
// Hash must be 64 hex chars (32 bytes)
|
||||
if hash_hex.len() != 64 {
|
||||
warn!(
|
||||
"Block header rejected: hash length {} != 64",
|
||||
hash_hex.len()
|
||||
);
|
||||
return false;
|
||||
}
|
||||
// Height must not be impossibly far ahead (allow 100 blocks gap for mesh delays)
|
||||
if last_known_height > 0 && height > last_known_height + 100 {
|
||||
warn!(
|
||||
"Block header height {} is too far ahead of known height {}",
|
||||
height, last_known_height
|
||||
);
|
||||
return false;
|
||||
}
|
||||
// Timestamp sanity: must not be before Bitcoin genesis (2009-01-03) or far in the future
|
||||
if timestamp < 1_231_006_505 {
|
||||
warn!(
|
||||
"Block header rejected: timestamp {} before Bitcoin genesis",
|
||||
timestamp
|
||||
);
|
||||
return false;
|
||||
}
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() as u32;
|
||||
if timestamp > now + 7200 {
|
||||
warn!(
|
||||
"Block header rejected: timestamp {} is more than 2 hours in the future",
|
||||
timestamp
|
||||
);
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Validate a raw transaction hex string before relaying to Bitcoin Core.
|
||||
/// Checks basic syntax constraints only (full validation is done by Bitcoin Core).
|
||||
pub fn validate_raw_transaction(tx_hex: &str) -> bool {
|
||||
// Must be valid hex
|
||||
let tx_bytes = match hex::decode(tx_hex) {
|
||||
Ok(b) => b,
|
||||
Err(_) => {
|
||||
warn!("TX relay rejected: invalid hex");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
// Minimum valid transaction size is ~60 bytes, max 400KB
|
||||
if tx_bytes.len() < 60 || tx_bytes.len() > 400_000 {
|
||||
warn!(
|
||||
"TX relay rejected: size {} out of range [60, 400000]",
|
||||
tx_bytes.len()
|
||||
);
|
||||
return false;
|
||||
}
|
||||
// Check version bytes (first 4 bytes, little-endian) — valid versions: 1, 2, 3
|
||||
if tx_bytes.len() >= 4 {
|
||||
let version = u32::from_le_bytes([tx_bytes[0], tx_bytes[1], tx_bytes[2], tx_bytes[3]]);
|
||||
if !(1..=3).contains(&version) {
|
||||
warn!("TX relay rejected: version {} not in [1,3]", version);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Simple per-peer rate limiter for mesh relay operations.
|
||||
pub struct RelayRateLimiter {
|
||||
/// (peer_id, message_type) -> list of timestamps
|
||||
windows: RwLock<HashMap<(u32, &'static str), Vec<std::time::Instant>>>,
|
||||
}
|
||||
|
||||
impl RelayRateLimiter {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
windows: RwLock::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a relay operation is allowed. Returns true if within rate limits.
|
||||
/// max_per_minute: maximum operations per 60-second window.
|
||||
pub async fn check(&self, peer_id: u32, msg_type: &'static str, max_per_minute: usize) -> bool {
|
||||
let now = std::time::Instant::now();
|
||||
let cutoff = now - std::time::Duration::from_secs(60);
|
||||
let mut windows = self.windows.write().await;
|
||||
let key = (peer_id, msg_type);
|
||||
let timestamps = windows.entry(key).or_insert_with(Vec::new);
|
||||
|
||||
// Remove entries older than 60 seconds
|
||||
timestamps.retain(|t| *t > cutoff);
|
||||
|
||||
if timestamps.len() >= max_per_minute {
|
||||
warn!(
|
||||
peer_id,
|
||||
msg_type,
|
||||
"Rate limit exceeded: {} in last minute",
|
||||
timestamps.len()
|
||||
);
|
||||
return false;
|
||||
}
|
||||
timestamps.push(now);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ed25519_dalek::SigningKey;
|
||||
use rand::rngs::OsRng;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_block_header_cache() {
|
||||
let cache = BlockHeaderCache::new();
|
||||
cache
|
||||
.store_header(BlockHeaderPayload {
|
||||
height: 890412,
|
||||
hash: "0000000000000000000abc".to_string(),
|
||||
prev_hash: "0000000000000000000aab".to_string(),
|
||||
timestamp: 1710633600,
|
||||
announced_by: "did:key:z6MkTest".to_string(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(cache.latest_height().await, 890412);
|
||||
let header = cache.get_header(890412).await.unwrap();
|
||||
assert_eq!(header.hash, "0000000000000000000abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_block_header_announcement() {
|
||||
let key = SigningKey::generate(&mut OsRng);
|
||||
let wire = build_block_header_announcement(
|
||||
890412,
|
||||
// Block hashes must be 32 bytes (64 hex chars). Use realistic-shaped placeholders.
|
||||
"0000000000000000000abc00000000000000000000000000000000000000abcd",
|
||||
"0000000000000000000aab0000000000000000000000000000000000000aabcd",
|
||||
1710633600,
|
||||
"did:key:z6MkTest",
|
||||
&key,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Should start with typed message marker
|
||||
assert_eq!(wire[0], 0x02);
|
||||
let envelope = TypedEnvelope::from_wire(&wire).unwrap();
|
||||
assert_eq!(envelope.t, MeshMessageType::BlockHeader as u8);
|
||||
// Block header announcements are intentionally unsigned to save 64 bytes
|
||||
// on the 160-byte LoRa payload (see builder comment).
|
||||
assert!(envelope.sig.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tx_relay_roundtrip() {
|
||||
let wire = build_tx_relay_request("0200000001abc...", 42).unwrap();
|
||||
let envelope = TypedEnvelope::from_wire(&wire).unwrap();
|
||||
assert_eq!(envelope.t, MeshMessageType::TxRelay as u8);
|
||||
|
||||
let payload: TxRelayPayload = message_types::decode_payload(&envelope.v).unwrap();
|
||||
assert_eq!(payload.request_id, 42);
|
||||
assert_eq!(payload.tx_hex, "0200000001abc...");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lightning_relay_roundtrip() {
|
||||
let wire = build_lightning_relay_request("lnbc50000n1pjtest...", 50000, 99).unwrap();
|
||||
let envelope = TypedEnvelope::from_wire(&wire).unwrap();
|
||||
|
||||
let payload: LightningRelayPayload = message_types::decode_payload(&envelope.v).unwrap();
|
||||
assert_eq!(payload.amount_sats, 50000);
|
||||
assert_eq!(payload.request_id, 99);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_relay_tracker() {
|
||||
let tracker = RelayTracker::new();
|
||||
tracker.track_tx_relay(42, "did:key:z6MkRequester").await;
|
||||
|
||||
let (tx_count, ln_count) = tracker.pending_count().await;
|
||||
assert_eq!(tx_count, 1);
|
||||
assert_eq!(ln_count, 0);
|
||||
|
||||
let requester = tracker.complete_tx_relay(42).await;
|
||||
assert_eq!(requester, Some("did:key:z6MkRequester".to_string()));
|
||||
assert_eq!(tracker.pending_count().await, (0, 0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
// WIP mesh/transport protocol — suppress dead code warnings
|
||||
#![allow(dead_code)]
|
||||
//! 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;
|
||||
|
||||
// ─── Phase 3: HKDF + Ephemeral Key Generation ─────────────────────────
|
||||
|
||||
/// HKDF-SHA256 key derivation.
|
||||
/// Derives `okm_len` bytes from input key material with optional salt and info.
|
||||
pub fn hkdf_sha256(salt: &[u8], ikm: &[u8], info: &[u8], okm_len: usize) -> Result<Vec<u8>> {
|
||||
use hkdf::Hkdf;
|
||||
use sha2::Sha256;
|
||||
|
||||
let hk = Hkdf::<Sha256>::new(Some(salt), ikm);
|
||||
let mut okm = vec![0u8; okm_len];
|
||||
hk.expand(info, &mut okm)
|
||||
.map_err(|_| anyhow::anyhow!("HKDF expand failed (output too long)"))?;
|
||||
Ok(okm)
|
||||
}
|
||||
|
||||
/// HKDF-SHA256 that returns exactly 32 bytes (one key).
|
||||
pub fn hkdf_sha256_32(salt: &[u8], ikm: &[u8], info: &[u8]) -> Result<[u8; 32]> {
|
||||
let okm = hkdf_sha256(salt, ikm, info, 32)?;
|
||||
let mut key = [0u8; 32];
|
||||
key.copy_from_slice(&okm);
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
/// HKDF-SHA256 that returns exactly 64 bytes (two keys).
|
||||
/// Used for Double Ratchet root key + chain key derivation.
|
||||
pub fn hkdf_sha256_64(salt: &[u8], ikm: &[u8], info: &[u8]) -> Result<([u8; 32], [u8; 32])> {
|
||||
let okm = hkdf_sha256(salt, ikm, info, 64)?;
|
||||
let mut k1 = [0u8; 32];
|
||||
let mut k2 = [0u8; 32];
|
||||
k1.copy_from_slice(&okm[..32]);
|
||||
k2.copy_from_slice(&okm[32..]);
|
||||
Ok((k1, k2))
|
||||
}
|
||||
|
||||
/// Generate an ephemeral X25519 keypair for DH ratchet steps.
|
||||
/// Returns (secret, public) where both are 32 bytes.
|
||||
pub fn generate_x25519_ephemeral() -> ([u8; 32], [u8; 32]) {
|
||||
let mut secret = [0u8; 32];
|
||||
rand::rngs::OsRng.fill_bytes(&mut secret);
|
||||
// Clamp per RFC 7748
|
||||
secret[0] &= 248;
|
||||
secret[31] &= 127;
|
||||
secret[31] |= 64;
|
||||
|
||||
// Derive public key: secret * basepoint
|
||||
use curve25519_dalek::montgomery::MontgomeryPoint;
|
||||
use curve25519_dalek::scalar::Scalar;
|
||||
let scalar = Scalar::from_bytes_mod_order(secret);
|
||||
let public = MontgomeryPoint::mul_base(&scalar);
|
||||
(secret, *public.as_bytes())
|
||||
}
|
||||
|
||||
#[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,992 @@
|
||||
// WIP mesh/transport protocol — suppress dead code warnings
|
||||
#![allow(dead_code)]
|
||||
//! Firmware flashing for LoRa mesh radios — Heltec V3/V4 in v1, across all
|
||||
//! three firmware families the mesh module already knows how to detect (see
|
||||
//! `mesh::types::DeviceType`). Firmware is always fetched from upstream at
|
||||
//! flash time (never bundled/pinned in the repo), and every flash defaults
|
||||
//! to a full chip erase before write.
|
||||
//!
|
||||
//! MeshCore and Meshtastic are flashed the same way: download a released
|
||||
//! image, `esptool erase_flash`, then `esptool write_flash 0x0 <image>`.
|
||||
//! Reticulum/RNode is different: `archy-rnodeconf --autoinstall` owns the
|
||||
//! whole fetch+erase+flash+EEPROM-bootstrap sequence itself (confirmed live
|
||||
//! via `archy-rnodeconf --help` — there is no raw esptool path exposed for
|
||||
//! this family, so we deliberately don't resolve a firmware URL ourselves
|
||||
//! for Reticulum; rnodeconf already knows how).
|
||||
|
||||
use super::serial::DetectedDeviceInfo;
|
||||
use super::types::DeviceType;
|
||||
use super::MeshService;
|
||||
use anyhow::{Context, Result};
|
||||
use regex::Regex;
|
||||
use serde::Serialize;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::process::Command;
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::{info, warn};
|
||||
|
||||
/// Boards supported for v1. Both are ESP32-S3 (a single `--chip esp32s3`
|
||||
/// esptool target covers both), but ship different USB identities and
|
||||
/// different per-board firmware assets upstream.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum FlashBoard {
|
||||
HeltecV3,
|
||||
HeltecV4,
|
||||
}
|
||||
|
||||
impl FlashBoard {
|
||||
/// Meshtastic's board id (matches the release manifest's `board` field
|
||||
/// and its per-board asset naming, e.g. `firmware-heltec-v3-<ver>.factory.bin`).
|
||||
fn meshtastic_id(self) -> &'static str {
|
||||
match self {
|
||||
Self::HeltecV3 => "heltec-v3",
|
||||
Self::HeltecV4 => "heltec-v4",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a detected USB vid:pid to a known flashable board, using the same
|
||||
/// table as `image-recipe/configs/99-mesh-radio.rules`. CP2102 (10c4:ea60)
|
||||
/// is confirmed there as Heltec V3's USB-UART bridge chip, and is safe to
|
||||
/// auto-match since that vid:pid is bridge-chip-specific.
|
||||
///
|
||||
/// Heltec V4 is NOT auto-matchable and deliberately has no entry here: it
|
||||
/// was confirmed live (real hardware, 2026-07-23) to use the ESP32-S3's
|
||||
/// built-in native-USB JTAG/serial peripheral, reporting vid:pid 303a:1001
|
||||
/// with product string "USB JTAG/serial debug unit" — that descriptor is
|
||||
/// baked into the chip's ROM and is IDENTICAL across every ESP32-S3 board
|
||||
/// with native USB enabled, not just Heltec V4. Adding `303a:1001 =>
|
||||
/// HeltecV4` here would silently misidentify any other native-USB ESP32-S3
|
||||
/// board (a T3-S3, a bare devkit, etc.) as a V4 and risk writing the wrong
|
||||
/// board's image. Callers (the RPC layer / frontend) must let the user pick
|
||||
/// the board manually whenever this returns `None`.
|
||||
pub fn resolve_flash_board(info: &DetectedDeviceInfo) -> Option<FlashBoard> {
|
||||
match (info.vid.as_deref(), info.pid.as_deref()) {
|
||||
(Some("10c4"), Some("ea60")) => Some(FlashBoard::HeltecV3),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum FlashStage {
|
||||
Downloading,
|
||||
Erasing,
|
||||
Writing,
|
||||
Autoinstalling,
|
||||
Done,
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct FlashJobStatus {
|
||||
pub board: FlashBoard,
|
||||
pub family: DeviceType,
|
||||
pub path: String,
|
||||
pub stage: FlashStage,
|
||||
pub percent: Option<u8>,
|
||||
pub log_tail: Vec<String>,
|
||||
pub done: bool,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
const LOG_TAIL_MAX: usize = 200;
|
||||
|
||||
/// How long to wait after a successful flash before resuming the mesh
|
||||
/// listener, so the board finishes its own post-flash boot/reset before we
|
||||
/// start opening the port (which itself toggles DTR/RTS) again.
|
||||
const POST_FLASH_SETTLE_DELAY: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
|
||||
/// Absolute ceiling on a whole flash job (download + erase + write, or
|
||||
/// autoinstall), regardless of what it's doing internally. Last-resort
|
||||
/// safety net so a hang anywhere can't wedge the single-flash-job guard
|
||||
/// forever — generous enough to never trigger on a legitimately slow
|
||||
/// multi-hundred-MB transfer.
|
||||
const MAX_JOB_DURATION: std::time::Duration = std::time::Duration::from_secs(15 * 60);
|
||||
|
||||
/// How long to wait for MeshService::stop() to release the serial port
|
||||
/// before giving up. Confirmed live 2026-07-23: the listener's own
|
||||
/// reconnect/multi-candidate-probe loop doesn't check its shutdown signal
|
||||
/// between candidates, so stop() can take a while (or, if the loop is
|
||||
/// wedged, never return) — 20s comfortably covers a normal handshake-probe
|
||||
/// cycle without leaving a flash request hanging indefinitely if the
|
||||
/// listener genuinely won't let go.
|
||||
const STOP_LISTENER_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20);
|
||||
|
||||
/// How long to keep retrying the port-free check before giving up.
|
||||
const PORT_FREE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
|
||||
/// Confirm nothing else has `path` open by actually opening (and immediately
|
||||
/// closing) it ourselves. Retries across the timeout since a just-stopped
|
||||
/// listener's fd can take a moment to actually release even after `stop()`
|
||||
/// returns (task abort is a request, not an instant guarantee the OS-level
|
||||
/// resource is gone yet).
|
||||
async fn wait_for_port_free(path: &str) -> Result<()> {
|
||||
let deadline = tokio::time::Instant::now() + PORT_FREE_TIMEOUT;
|
||||
let mut last_err = None;
|
||||
loop {
|
||||
match serial2_tokio::SerialPort::open(path, 115200) {
|
||||
Ok(_) => return Ok(()),
|
||||
Err(e) => last_err = Some(e),
|
||||
}
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||
}
|
||||
Err(anyhow::anyhow!(
|
||||
"{path} is still held open by something else after {}s (last error: {}) — refusing to start the flasher against a contended port",
|
||||
PORT_FREE_TIMEOUT.as_secs(),
|
||||
last_err.map(|e| e.to_string()).unwrap_or_default()
|
||||
))
|
||||
}
|
||||
|
||||
/// Live state for the one flash job that can run at a time. A single global
|
||||
/// slot is sufficient because flashing needs exclusive serial access to the
|
||||
/// one port being flashed — there is no meaningful concept of two concurrent
|
||||
/// flash jobs on this node.
|
||||
pub struct FlashJob {
|
||||
status: RwLock<FlashJobStatus>,
|
||||
/// Set once the background task is spawned. Only used while `stage` is
|
||||
/// still `Downloading` — an interrupted erase/write can leave the chip
|
||||
/// in a worse state than either finished or unstarted, so cancellation
|
||||
/// is refused once erase begins (see `cancel()`).
|
||||
abort_handle: RwLock<Option<tokio::task::AbortHandle>>,
|
||||
}
|
||||
|
||||
impl FlashJob {
|
||||
fn new(board: FlashBoard, family: DeviceType, path: String) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
abort_handle: RwLock::new(None),
|
||||
status: RwLock::new(FlashJobStatus {
|
||||
board,
|
||||
family,
|
||||
path,
|
||||
stage: FlashStage::Downloading,
|
||||
percent: None,
|
||||
log_tail: Vec::new(),
|
||||
done: false,
|
||||
error: None,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn snapshot(&self) -> FlashJobStatus {
|
||||
self.status.read().await.clone()
|
||||
}
|
||||
|
||||
async fn set_stage(&self, stage: FlashStage) {
|
||||
let mut s = self.status.write().await;
|
||||
s.stage = stage;
|
||||
s.percent = None;
|
||||
}
|
||||
|
||||
async fn set_percent(&self, percent: u8) {
|
||||
self.status.write().await.percent = Some(percent.min(100));
|
||||
}
|
||||
|
||||
async fn push_log(&self, line: impl Into<String>) {
|
||||
let mut s = self.status.write().await;
|
||||
s.log_tail.push(line.into());
|
||||
let overflow = s.log_tail.len().saturating_sub(LOG_TAIL_MAX);
|
||||
if overflow > 0 {
|
||||
s.log_tail.drain(0..overflow);
|
||||
}
|
||||
}
|
||||
|
||||
async fn fail(&self, err: &anyhow::Error) {
|
||||
let mut s = self.status.write().await;
|
||||
s.stage = FlashStage::Failed;
|
||||
s.error = Some(format!("{err:#}"));
|
||||
s.done = true;
|
||||
}
|
||||
|
||||
async fn finish(&self) {
|
||||
let mut s = self.status.write().await;
|
||||
s.stage = FlashStage::Done;
|
||||
s.done = true;
|
||||
}
|
||||
|
||||
/// Best-effort cancel: only honored before erase/write/autoinstall has
|
||||
/// started (i.e. still in `Downloading`). Once a stage that touches the
|
||||
/// chip begins, this refuses — interrupting an erase or write can leave
|
||||
/// the flash in a state worse than either finished or unstarted.
|
||||
pub async fn cancel(&self) -> Result<()> {
|
||||
let mut s = self.status.write().await;
|
||||
if s.done {
|
||||
anyhow::bail!("Flash job already finished");
|
||||
}
|
||||
if s.stage != FlashStage::Downloading {
|
||||
anyhow::bail!(
|
||||
"Cannot cancel once {:?} has started — let it finish or fail on its own",
|
||||
s.stage
|
||||
);
|
||||
}
|
||||
if let Some(handle) = self.abort_handle.write().await.take() {
|
||||
handle.abort();
|
||||
}
|
||||
s.stage = FlashStage::Failed;
|
||||
s.error = Some("Cancelled by user".to_string());
|
||||
s.done = true;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared handle held by `RpcHandler`, sibling to `mesh_service`.
|
||||
pub type FlashJobHandle = Arc<RwLock<Option<Arc<FlashJob>>>>;
|
||||
|
||||
pub fn new_job_handle() -> FlashJobHandle {
|
||||
Arc::new(RwLock::new(None))
|
||||
}
|
||||
|
||||
fn firmware_cache_dir(data_dir: &Path) -> PathBuf {
|
||||
data_dir.join("mesh").join("firmware-cache")
|
||||
}
|
||||
|
||||
/// No blanket `.timeout()` here on purpose: reqwest's request timeout covers
|
||||
/// the *entire* request including streaming the response body, which would
|
||||
/// kill a legitimate large download partway through (Meshtastic's esp32s3
|
||||
/// zip is ~170MB) — not just a hung connection. `download_to_file` instead
|
||||
/// applies a per-chunk stall timeout, and metadata calls (small JSON
|
||||
/// responses) get their own short timeout at the call site.
|
||||
fn github_client() -> Result<reqwest::Client> {
|
||||
reqwest::Client::builder()
|
||||
.user_agent("archipelago-mesh-flash")
|
||||
.connect_timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.context("Failed to build HTTP client")
|
||||
}
|
||||
|
||||
/// Applied per-chunk while streaming a firmware download — if the transfer
|
||||
/// stalls (no bytes for this long) it's treated as a failure, but a slow
|
||||
/// download that's still making progress is never killed just for taking a
|
||||
/// while.
|
||||
const DOWNLOAD_STALL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
|
||||
|
||||
/// Applied to metadata calls (GitHub release JSON) — these are small
|
||||
/// responses with no reason to ever take this long.
|
||||
const METADATA_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20);
|
||||
|
||||
/// Resolve what firmware is available for a board+family. v1 only ever
|
||||
/// offers "latest" — MeshCore/Meshtastic latest GitHub release, or, for
|
||||
/// Reticulum, "latest" meaning "whatever archy-rnodeconf --autoinstall
|
||||
/// resolves on its own" (it does its own version checking upstream).
|
||||
pub async fn list_firmware(family: DeviceType) -> Result<Vec<String>> {
|
||||
match family {
|
||||
DeviceType::Reticulum => Ok(vec!["latest".to_string()]),
|
||||
DeviceType::Meshtastic => {
|
||||
let client = github_client()?;
|
||||
let release: GithubRelease = client
|
||||
.get("https://api.github.com/repos/meshtastic/firmware/releases/latest")
|
||||
.send()
|
||||
.await
|
||||
.context("Fetching Meshtastic release list")?
|
||||
.error_for_status()
|
||||
.context("Meshtastic releases API error")?
|
||||
.json()
|
||||
.await
|
||||
.context("Parsing Meshtastic release JSON")?;
|
||||
Ok(vec![release.tag_name])
|
||||
}
|
||||
DeviceType::Meshcore => {
|
||||
let client = github_client()?;
|
||||
let release: GithubRelease = client
|
||||
.get("https://api.github.com/repos/meshcore-dev/MeshCore/releases/latest")
|
||||
.send()
|
||||
.await
|
||||
.context("Fetching MeshCore release list")?
|
||||
.error_for_status()
|
||||
.context("MeshCore releases API error")?
|
||||
.json()
|
||||
.await
|
||||
.context("Parsing MeshCore release JSON")?;
|
||||
Ok(vec![release.tag_name])
|
||||
}
|
||||
DeviceType::Unknown => anyhow::bail!("Pick a firmware family before listing versions"),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct GithubAsset {
|
||||
name: String,
|
||||
browser_download_url: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct GithubRelease {
|
||||
tag_name: String,
|
||||
assets: Vec<GithubAsset>,
|
||||
}
|
||||
|
||||
/// Start a flash job in the background. Returns as soon as the job has been
|
||||
/// registered and the listener released — callers poll `FlashJobHandle` via
|
||||
/// `mesh.flash-status` for progress. Only one job may be in flight at a time.
|
||||
pub async fn start_flash_job(
|
||||
handle: &FlashJobHandle,
|
||||
mesh_service: &Arc<RwLock<Option<MeshService>>>,
|
||||
data_dir: PathBuf,
|
||||
path: String,
|
||||
board: FlashBoard,
|
||||
family: DeviceType,
|
||||
) -> Result<()> {
|
||||
{
|
||||
let existing = handle.read().await;
|
||||
if let Some(job) = existing.as_ref() {
|
||||
if !job.snapshot().await.done {
|
||||
anyhow::bail!("A firmware flash is already in progress on this node");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let job = FlashJob::new(board, family, path.clone());
|
||||
*handle.write().await = Some(Arc::clone(&job));
|
||||
|
||||
let bg_job = Arc::clone(&job);
|
||||
let bg_service = Arc::clone(mesh_service);
|
||||
let task = tokio::spawn(async move {
|
||||
// esptool/archy-rnodeconf need exclusive serial access — release
|
||||
// the listener's hold on the port before touching it. This USED
|
||||
// TO run synchronously in start_flash_job before the job was even
|
||||
// spawned, blocking the RPC call itself on s.stop().await — a real
|
||||
// 2026-07-23 incident: the mesh listener was mid a multi-candidate
|
||||
// reconnect/probe sequence that doesn't check its shutdown signal
|
||||
// between candidates, so stop() never returned. The HTTP request
|
||||
// timed out client-side ("Operation failed"), while the job
|
||||
// (already inserted into `handle`) was permanently wedged — nothing
|
||||
// had been spawned yet to ever mark it done, so every later flash
|
||||
// attempt failed with "already in progress" until a full restart.
|
||||
// Now this runs inside the spawned task with its own bounded
|
||||
// timeout, so the RPC call always returns immediately regardless,
|
||||
// and a slow-to-stop listener fails the job cleanly instead of
|
||||
// hanging everything downstream of it forever.
|
||||
let stop_result = tokio::time::timeout(STOP_LISTENER_TIMEOUT, async {
|
||||
let mut svc = bg_service.write().await;
|
||||
if let Some(s) = svc.as_mut() {
|
||||
s.stop().await;
|
||||
}
|
||||
})
|
||||
.await;
|
||||
if stop_result.is_err() {
|
||||
let err = anyhow::anyhow!(
|
||||
"Mesh listener did not release the serial port within {}s — it may still be mid a reconnect attempt. Try again once mesh.status shows the device idle, or restart the archipelago service if this persists.",
|
||||
STOP_LISTENER_TIMEOUT.as_secs()
|
||||
);
|
||||
bg_job.push_log(format!("ERROR: {err:#}")).await;
|
||||
bg_job.fail(&err).await;
|
||||
return;
|
||||
}
|
||||
|
||||
// Belt-and-suspenders port-free check. `stop()` above should have
|
||||
// fully released the port, but esptool/rnodeconf run as external
|
||||
// subprocesses for minutes outside our own async runtime — if
|
||||
// ANYTHING else still has it open (a racing probe, a not-yet-dropped
|
||||
// fd from an aborted task, anything we haven't anticipated), handing
|
||||
// the port to the flasher anyway risks exactly the corruption
|
||||
// confirmed live 2026-07-23: esptool's "device disconnected or
|
||||
// multiple access on port?" and rnodeconf's raw `OSError: [Errno 71]
|
||||
// Protocol error` on an RTS ioctl are both textbook two-openers-on-
|
||||
// one-fd symptoms. Verify by actually opening it ourselves — cheap,
|
||||
// and definitive — before ever starting the flasher.
|
||||
if let Err(e) = wait_for_port_free(&path).await {
|
||||
bg_job.push_log(format!("ERROR: {e:#}")).await;
|
||||
bg_job.fail(&e).await;
|
||||
return;
|
||||
}
|
||||
|
||||
// Outer ceiling on top of run_flash's own internal timeouts —
|
||||
// belt-and-suspenders so that no future hang (network, subprocess,
|
||||
// anything) can ever wedge the single-flash-job guard permanently
|
||||
// again the way a stuck download did on 2026-07-23 (every
|
||||
// subsequent mesh.flash-device call failed with "already in
|
||||
// progress" until the service was restarted). Generous enough that
|
||||
// a legitimately slow multi-hundred-MB transfer still completes.
|
||||
let result = match tokio::time::timeout(
|
||||
MAX_JOB_DURATION,
|
||||
run_flash(board, family, &data_dir, &path, &bg_job),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(inner) => inner,
|
||||
Err(_) => Err(anyhow::anyhow!(
|
||||
"Flash job exceeded the {}-minute ceiling — aborted",
|
||||
MAX_JOB_DURATION.as_secs() / 60
|
||||
)),
|
||||
};
|
||||
let succeeded = result.is_ok();
|
||||
|
||||
match &result {
|
||||
Ok(()) => {
|
||||
bg_job
|
||||
.push_log("Flash completed successfully".to_string())
|
||||
.await;
|
||||
bg_job.finish().await;
|
||||
info!(path = %path, board = ?board, family = %family, "LoRa firmware flash succeeded");
|
||||
}
|
||||
Err(e) => {
|
||||
// {:#} (alternate Display) walks the full anyhow context
|
||||
// chain — plain {} / %e only prints the outermost .context()
|
||||
// message, which made a real 2026-07-23 esptool failure
|
||||
// undiagnosable from journalctl alone (just "esptool
|
||||
// erase_flash failed", no actual esptool stderr).
|
||||
warn!(path = %path, error = %format!("{e:#}"), "LoRa firmware flash failed");
|
||||
bg_job.push_log(format!("ERROR: {e:#}")).await;
|
||||
bg_job.fail(e).await;
|
||||
}
|
||||
}
|
||||
|
||||
// The board's firmware may now differ from whatever was pinned
|
||||
// before — clear the pin either way so a later reconnect's strict
|
||||
// auto-detect order picks up reality instead of getting wedged
|
||||
// trying the old protocol first.
|
||||
if let Ok(mut config) = super::load_config(&data_dir).await {
|
||||
config.device_kind = None;
|
||||
if let Err(e) = super::save_config(&data_dir, &config).await {
|
||||
warn!(error = %e, "Failed to clear device_kind pin after flash");
|
||||
}
|
||||
}
|
||||
|
||||
if !succeeded {
|
||||
// Deliberately do NOT auto-restart the listener here. A failed
|
||||
// flash means we can't vouch for the board's state — reopening
|
||||
// the port immediately (esptool/rnodeconf's own reset sequence
|
||||
// plus our open() toggling DTR/RTS again right after) risks
|
||||
// hammering a marginal device with reconnect attempts. Confirmed
|
||||
// live 2026-07-23: exactly this sequence left a real Heltec V3
|
||||
// boot-looping for 5+ minutes after a failed flash. Leave mesh
|
||||
// stopped; the user reconnects explicitly via the hot-swap
|
||||
// modal/Mesh page once they've confirmed the board is alive.
|
||||
warn!(
|
||||
path = %path,
|
||||
"Leaving mesh listener stopped after failed flash — reconnect manually once the board is confirmed responsive"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// On success, give the board a moment to finish booting after the
|
||||
// flash tool's own reset sequence before we start hammering it with
|
||||
// connection attempts — same reasoning as above, just the
|
||||
// lower-risk (successful-flash) side of it.
|
||||
tokio::time::sleep(POST_FLASH_SETTLE_DELAY).await;
|
||||
|
||||
let mut svc = bg_service.write().await;
|
||||
if let Some(s) = svc.as_mut() {
|
||||
match super::load_config(&data_dir).await {
|
||||
Ok(config) => {
|
||||
// Only resume if mesh is actually still enabled per the
|
||||
// CURRENT persisted config — confirmed live 2026-07-23:
|
||||
// unconditionally forcing a restart here, regardless of
|
||||
// `enabled`, overrode a user's own concurrent "disable
|
||||
// mesh" toggle and left the listener running while
|
||||
// config said disabled. That inconsistent state is what
|
||||
// made a later legitimate "Keep As Is" click (which
|
||||
// correctly tries to start on a false→true transition)
|
||||
// fail with "already running" — the listener had already
|
||||
// been force-started behind the config's back.
|
||||
let should_run = config.enabled;
|
||||
if let Err(e) = s.configure(config).await {
|
||||
warn!(error = %e, "Failed to resume mesh listener after flash");
|
||||
}
|
||||
if should_run {
|
||||
if let Err(e) = s.start() {
|
||||
warn!(error = %e, "Failed to restart mesh listener after flash");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => warn!(error = %e, "Failed to load mesh config after flash"),
|
||||
}
|
||||
}
|
||||
});
|
||||
*job.abort_handle.write().await = Some(task.abort_handle());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_flash(
|
||||
board: FlashBoard,
|
||||
family: DeviceType,
|
||||
data_dir: &Path,
|
||||
path: &str,
|
||||
job: &Arc<FlashJob>,
|
||||
) -> Result<()> {
|
||||
match family {
|
||||
DeviceType::Meshtastic | DeviceType::Meshcore => {
|
||||
let image = fetch_esptool_image(board, family, data_dir, job).await?;
|
||||
esptool_erase_and_write(path, &image, job).await
|
||||
}
|
||||
DeviceType::Reticulum => {
|
||||
let lora_region = super::load_config(data_dir)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|c| c.lora_region);
|
||||
rnodeconf_autoinstall(path, board, lora_region.as_deref(), job).await
|
||||
}
|
||||
DeviceType::Unknown => anyhow::bail!("Pick a firmware family before flashing"),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── MeshCore / Meshtastic: esptool ─────────────────────────────────────
|
||||
|
||||
async fn fetch_esptool_image(
|
||||
board: FlashBoard,
|
||||
family: DeviceType,
|
||||
data_dir: &Path,
|
||||
job: &Arc<FlashJob>,
|
||||
) -> Result<PathBuf> {
|
||||
let cache = firmware_cache_dir(data_dir);
|
||||
tokio::fs::create_dir_all(&cache)
|
||||
.await
|
||||
.context("Creating firmware cache dir")?;
|
||||
let client = github_client()?;
|
||||
|
||||
match family {
|
||||
DeviceType::Meshtastic => fetch_meshtastic_image(&client, board, &cache, job).await,
|
||||
DeviceType::Meshcore => fetch_meshcore_image(&client, board, &cache, job).await,
|
||||
_ => anyhow::bail!("{family} is not flashed via esptool"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_meshtastic_image(
|
||||
client: &reqwest::Client,
|
||||
board: FlashBoard,
|
||||
cache: &Path,
|
||||
job: &Arc<FlashJob>,
|
||||
) -> Result<PathBuf> {
|
||||
let release: GithubRelease = client
|
||||
.get("https://api.github.com/repos/meshtastic/firmware/releases/latest")
|
||||
.timeout(METADATA_TIMEOUT)
|
||||
.send()
|
||||
.await
|
||||
.context("Fetching Meshtastic release list")?
|
||||
.error_for_status()
|
||||
.context("Meshtastic releases API error")?
|
||||
.json()
|
||||
.await
|
||||
.context("Parsing Meshtastic release JSON")?;
|
||||
|
||||
// Meshtastic bundles all esp32s3 boards' images inside one per-platform
|
||||
// zip rather than shipping per-board top-level assets — both Heltec V3
|
||||
// and V4 are esp32s3, so this is the right zip for both (confirmed live
|
||||
// against v2.7.26.54e0d8d).
|
||||
let zip_asset = release
|
||||
.assets
|
||||
.iter()
|
||||
.find(|a| a.name.starts_with("firmware-esp32s3-") && a.name.ends_with(".zip"))
|
||||
.ok_or_else(|| anyhow::anyhow!("No esp32s3 firmware zip in latest Meshtastic release"))?;
|
||||
|
||||
let version = zip_asset
|
||||
.name
|
||||
.strip_prefix("firmware-esp32s3-")
|
||||
.and_then(|s| s.strip_suffix(".zip"))
|
||||
.ok_or_else(|| anyhow::anyhow!("Unexpected Meshtastic asset name: {}", zip_asset.name))?
|
||||
.to_string();
|
||||
|
||||
let zip_path = cache.join(&zip_asset.name);
|
||||
if tokio::fs::metadata(&zip_path).await.is_err() {
|
||||
download_to_file(client, &zip_asset.browser_download_url, &zip_path, job).await?;
|
||||
} else {
|
||||
job.push_log(format!("Using cached {}", zip_asset.name))
|
||||
.await;
|
||||
}
|
||||
|
||||
// "*.factory.bin" is Meshtastic's full merged image (bootloader +
|
||||
// partition table + app) meant to be written at offset 0x0 on a freshly
|
||||
// erased chip — confirmed by inspecting the real zip's contents, as
|
||||
// opposed to the plain "*.bin" OTA-update image which assumes an
|
||||
// existing bootloader/partition table already on the chip.
|
||||
let entry_name = format!("firmware-{}-{}.factory.bin", board.meshtastic_id(), version);
|
||||
let out_path = cache.join(&entry_name);
|
||||
if tokio::fs::metadata(&out_path).await.is_ok() {
|
||||
return Ok(out_path);
|
||||
}
|
||||
|
||||
job.push_log(format!("Extracting {entry_name} from {}", zip_asset.name))
|
||||
.await;
|
||||
let zip_path_owned = zip_path.clone();
|
||||
let entry_name_owned = entry_name.clone();
|
||||
let out_path_owned = out_path.clone();
|
||||
tokio::task::spawn_blocking(move || -> Result<()> {
|
||||
let file =
|
||||
std::fs::File::open(&zip_path_owned).context("Opening downloaded firmware zip")?;
|
||||
let mut archive = zip::ZipArchive::new(file).context("Reading firmware zip")?;
|
||||
let mut entry = archive
|
||||
.by_name(&entry_name_owned)
|
||||
.with_context(|| format!("{entry_name_owned} not found in firmware zip"))?;
|
||||
let mut out =
|
||||
std::fs::File::create(&out_path_owned).context("Creating extracted firmware file")?;
|
||||
std::io::copy(&mut entry, &mut out).context("Extracting firmware image")?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.context("Firmware extraction task panicked")??;
|
||||
|
||||
Ok(out_path)
|
||||
}
|
||||
|
||||
async fn fetch_meshcore_image(
|
||||
client: &reqwest::Client,
|
||||
board: FlashBoard,
|
||||
cache: &Path,
|
||||
job: &Arc<FlashJob>,
|
||||
) -> Result<PathBuf> {
|
||||
let release: GithubRelease = client
|
||||
.get("https://api.github.com/repos/meshcore-dev/MeshCore/releases/latest")
|
||||
.timeout(METADATA_TIMEOUT)
|
||||
.send()
|
||||
.await
|
||||
.context("Fetching MeshCore release list")?
|
||||
.error_for_status()
|
||||
.context("MeshCore releases API error")?
|
||||
.json()
|
||||
.await
|
||||
.context("Parsing MeshCore release JSON")?;
|
||||
|
||||
// Upstream's casing differs between boards (Heltec_v3_... vs
|
||||
// heltec_v4_...) — match case-insensitively on the exact per-board
|
||||
// substring so V4 isn't accidentally matched by "heltec_v4_tft_..."
|
||||
// variants (there's a "_tft_" in between, so a straight substring match
|
||||
// on "heltec_v4_companion_radio_usb" is already safe).
|
||||
let needle = match board {
|
||||
FlashBoard::HeltecV3 => "heltec_v3_companion_radio_usb",
|
||||
FlashBoard::HeltecV4 => "heltec_v4_companion_radio_usb",
|
||||
};
|
||||
let asset = release
|
||||
.assets
|
||||
.iter()
|
||||
.find(|a| {
|
||||
let lower = a.name.to_lowercase();
|
||||
lower.contains(needle) && lower.ends_with("-merged.bin")
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!("No matching MeshCore image in release {}", release.tag_name)
|
||||
})?;
|
||||
|
||||
let out_path = cache.join(&asset.name);
|
||||
if tokio::fs::metadata(&out_path).await.is_ok() {
|
||||
job.push_log(format!("Using cached {}", asset.name)).await;
|
||||
return Ok(out_path);
|
||||
}
|
||||
download_to_file(client, &asset.browser_download_url, &out_path, job).await?;
|
||||
Ok(out_path)
|
||||
}
|
||||
|
||||
async fn download_to_file(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
dest: &Path,
|
||||
job: &Arc<FlashJob>,
|
||||
) -> Result<()> {
|
||||
job.set_stage(FlashStage::Downloading).await;
|
||||
// Bound only the wait for the response to *start* (headers) — NOT a
|
||||
// request-level `.timeout()`, which would cap the whole body transfer
|
||||
// again (the bug this replaced: a blanket 30s client timeout killed
|
||||
// large downloads mid-stream). If the server never responds at all,
|
||||
// this is what stops the job from hanging forever; the per-chunk stall
|
||||
// timeout below is what guards the body once streaming starts. Without
|
||||
// this, a server that accepts the TCP connection but never sends
|
||||
// headers back hangs this call indefinitely — confirmed live
|
||||
// 2026-07-23: a stuck `.send()` here wedged the single-flash-job guard
|
||||
// for good, permanently blocking every subsequent flash attempt with
|
||||
// "already in progress" until the service was restarted.
|
||||
let resp = tokio::time::timeout(METADATA_TIMEOUT, client.get(url).send())
|
||||
.await
|
||||
.context("Firmware download server did not respond")?
|
||||
.context("Starting firmware download")?
|
||||
.error_for_status()
|
||||
.context("Firmware download returned an error status")?;
|
||||
let total = resp.content_length();
|
||||
let tmp = dest.with_extension("part");
|
||||
let mut file = tokio::fs::File::create(&tmp)
|
||||
.await
|
||||
.context("Creating firmware download file")?;
|
||||
let mut stream = resp.bytes_stream();
|
||||
let mut downloaded: u64 = 0;
|
||||
use futures_util::StreamExt;
|
||||
loop {
|
||||
let next = tokio::time::timeout(DOWNLOAD_STALL_TIMEOUT, stream.next())
|
||||
.await
|
||||
.context("Firmware download stalled")?;
|
||||
let Some(chunk) = next else { break };
|
||||
let chunk = chunk.context("Reading firmware download stream")?;
|
||||
file.write_all(&chunk)
|
||||
.await
|
||||
.context("Writing firmware download")?;
|
||||
downloaded += chunk.len() as u64;
|
||||
if let Some(total) = total {
|
||||
if total > 0 {
|
||||
job.set_percent(((downloaded.saturating_mul(100)) / total) as u8)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
file.flush().await.ok();
|
||||
tokio::fs::rename(&tmp, dest)
|
||||
.await
|
||||
.context("Finalizing firmware download")?;
|
||||
job.push_log(format!(
|
||||
"Downloaded {} ({downloaded} bytes)",
|
||||
dest.display()
|
||||
))
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Both Heltec V3 and V4 are ESP32-S3 boards.
|
||||
const ESPTOOL_CHIP: &str = "esp32s3";
|
||||
|
||||
/// esptool's auto-reset-into-bootloader handshake (toggling DTR/RTS in a
|
||||
/// specific timed pattern) is well-known to be flaky on some CP2102/CH340
|
||||
/// board+adapter combinations — esptool's own docs recommend retrying at a
|
||||
/// lower baud rate when this happens. Rather than fail the whole job on the
|
||||
/// first hiccup, retry once at a conservative baud before giving up.
|
||||
const ESPTOOL_FALLBACK_BAUD: &str = "115200";
|
||||
|
||||
/// `write_flash --erase-all` erases the whole chip before writing, in one
|
||||
/// esptool invocation. This needs the esp32s3 stub flasher loaded (see
|
||||
/// esptool_global_args' doc comment) — without it, --erase-all hits the
|
||||
/// exact same ROM limitation a standalone `erase_flash` does ("ESP32-S3 ROM
|
||||
/// does not support function erase_flash", confirmed live 2026-07-23), since
|
||||
/// esptool's --erase-all is implemented as the same full-chip-erase command,
|
||||
/// not a per-sector loop.
|
||||
async fn esptool_erase_and_write(path: &str, image: &Path, job: &Arc<FlashJob>) -> Result<()> {
|
||||
job.set_stage(FlashStage::Writing).await;
|
||||
let image_str = image.to_string_lossy().to_string();
|
||||
esptool_with_retry(
|
||||
path,
|
||||
&["write_flash", "--erase-all", "0x0", &image_str],
|
||||
job,
|
||||
)
|
||||
.await
|
||||
.context("esptool write_flash failed")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// esptool's global flags (--chip/--port/--baud) MUST precede the subcommand
|
||||
/// token (erase_flash/write_flash/...) — confirmed live 2026-07-23:
|
||||
/// appending `--baud 115200` after the subcommand on the retry path
|
||||
/// produced "esptool: error: unrecognized arguments: --baud 115200" every
|
||||
/// time, so the fallback-baud retry never actually got a chance to run.
|
||||
/// Building global args separately from subcommand args keeps this correct
|
||||
/// by construction instead of relying on call-site ordering.
|
||||
///
|
||||
/// Normal stub-loader mode (no --no-stub) needs the esp32s3 stub flasher
|
||||
/// blob at /usr/lib/python3/dist-packages/esptool/targets/stub_flasher/
|
||||
/// stub_flasher_32s3.json — Debian's `esptool` package (4.7.0+dfsg-0.1)
|
||||
/// ships without it (stripped for DFSG compliance: the prebuilt blob has no
|
||||
/// buildable-from-source path Debian could verify), so scripts/self-update.sh
|
||||
/// fetches the exact same file from the matching upstream esptool release
|
||||
/// tag and installs it alongside the apt package (see the esptool install
|
||||
/// step there). --no-stub (talk directly to the ROM bootloader, skip the
|
||||
/// stub) was tried first and works for connecting, but the ROM bootloader
|
||||
/// doesn't implement a full-chip-erase opcode at all — only the stub does —
|
||||
/// so --no-stub broke our "always erase before write" default outright
|
||||
/// rather than just being slower. Restoring the real stub file is the
|
||||
/// correct fix, not routing around its absence.
|
||||
fn esptool_global_args<'a>(path: &'a str, baud: Option<&'a str>) -> Vec<&'a str> {
|
||||
let mut args = vec!["--chip", ESPTOOL_CHIP, "--port", path];
|
||||
if let Some(b) = baud {
|
||||
args.push("--baud");
|
||||
args.push(b);
|
||||
}
|
||||
args
|
||||
}
|
||||
|
||||
async fn esptool_with_retry(path: &str, subcommand: &[&str], job: &Arc<FlashJob>) -> Result<()> {
|
||||
let mut cmd = Command::new("esptool");
|
||||
cmd.args(esptool_global_args(path, None));
|
||||
cmd.args(subcommand);
|
||||
match run_streamed(cmd, None, job).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(first_err) => {
|
||||
job.push_log(format!(
|
||||
"First attempt failed ({first_err:#}); retrying once at {ESPTOOL_FALLBACK_BAUD} baud"
|
||||
))
|
||||
.await;
|
||||
let mut retry = Command::new("esptool");
|
||||
retry.args(esptool_global_args(path, Some(ESPTOOL_FALLBACK_BAUD)));
|
||||
retry.args(subcommand);
|
||||
run_streamed(retry, None, job)
|
||||
.await
|
||||
.context(format!("retry also failed (first attempt: {first_err:#})"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Reticulum/RNode: archy-rnodeconf ───────────────────────────────────
|
||||
|
||||
fn rnodeconf_bin() -> String {
|
||||
std::env::var("ARCHY_RNODECONF_BIN")
|
||||
.unwrap_or_else(|_| "/usr/local/bin/archy-rnodeconf".to_string())
|
||||
}
|
||||
|
||||
/// True when `name` resolves to an executable on PATH.
|
||||
fn which_on_path(name: &str) -> bool {
|
||||
std::env::var_os("PATH")
|
||||
.map(|paths| {
|
||||
std::env::split_paths(&paths).any(|dir| {
|
||||
let candidate = dir.join(name);
|
||||
candidate.is_file()
|
||||
})
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// `--autoinstall`'s "which board is this" step is interactive by design —
|
||||
/// confirmed live against a real Heltec V4 (2026-07-23): even with a board
|
||||
/// given on the command line, rnodeconf can't always tell V3 from V4 apart
|
||||
/// (their bootstrap-time USB identity is often generic, same root cause as
|
||||
/// `resolve_flash_board`'s doc comment), so it always asks. The full prompt
|
||||
/// sequence observed for a Heltec board that already has *some* RNode
|
||||
/// firmware installed (the common case — a truly blank chip likely skips
|
||||
/// straight to the same "Device Selection" menu):
|
||||
/// 1. numbered device-type menu → answer with the menu number
|
||||
/// 2. "Hit enter to continue" → answer with a blank line
|
||||
/// 3. numbered band menu → answer with the menu number
|
||||
/// 4. "Is the above correct? [y/N]" → answer "y"
|
||||
/// Feeding all four answers up front (rather than watching stdout for each
|
||||
/// prompt text) works because the menu is always asked in this fixed order
|
||||
/// for every board that needs (re)provisioning — verified by driving it
|
||||
/// through an unprovisioned real V4 end-to-end (erase → flash → EEPROM
|
||||
/// bootstrap → "Device signature validated" on the next probe).
|
||||
fn rnodeconf_device_menu_number(board: FlashBoard) -> &'static str {
|
||||
match board {
|
||||
FlashBoard::HeltecV3 => "8",
|
||||
FlashBoard::HeltecV4 => "9",
|
||||
}
|
||||
}
|
||||
|
||||
/// rnodeconf's band choice is a coarse RF-frontend bootstrap parameter
|
||||
/// (868/915/923 MHz), not the final operating frequency — that's still
|
||||
/// configured later via the daemon's interface config, same as today. This
|
||||
/// is a best-effort mapping from the node's persisted Meshtastic-style
|
||||
/// region code (see `mesh::meshtastic::region_name_to_code`) down to
|
||||
/// rnodeconf's 3-way menu; regions with no exact 868/923 match fall back to
|
||||
/// 915 MHz as the broadest-compatibility default.
|
||||
fn rnodeconf_band_menu_number(lora_region: Option<&str>) -> &'static str {
|
||||
match lora_region.map(|s| s.trim().to_uppercase()) {
|
||||
Some(r) if r.contains("868") => "1",
|
||||
Some(r) if r.contains("923") => "3",
|
||||
_ => "2",
|
||||
}
|
||||
}
|
||||
|
||||
/// `--autoinstall` fetches, erases, flashes, and bootstraps the EEPROM for
|
||||
/// a detected board as one atomic step (confirmed via `archy-rnodeconf
|
||||
/// --help` AND a real end-to-end flash on real hardware) — this is the
|
||||
/// RNode-side equivalent of our "always erase before write" default, since
|
||||
/// autoinstall doesn't try to preserve any existing on-device state.
|
||||
async fn rnodeconf_autoinstall(
|
||||
path: &str,
|
||||
board: FlashBoard,
|
||||
lora_region: Option<&str>,
|
||||
job: &Arc<FlashJob>,
|
||||
) -> Result<()> {
|
||||
job.set_stage(FlashStage::Autoinstalling).await;
|
||||
let bin = rnodeconf_bin();
|
||||
let mut cmd = if Path::new(&bin).exists() {
|
||||
Command::new(bin)
|
||||
} else if which_on_path("rnodeconf") {
|
||||
// Dev fallback if only a plain venv/system rnodeconf is on PATH.
|
||||
Command::new("rnodeconf")
|
||||
} else {
|
||||
// Older ISOs/OTAs never shipped the tool — say so instead of the
|
||||
// bare "No such file or directory" the spawn would produce.
|
||||
anyhow::bail!(
|
||||
"{bin} is not installed on this node — RNode flashing needs the packaged \
|
||||
archy-rnodeconf tool, which ships with the v1.7.118+ update (or can be \
|
||||
sideloaded from a dev box). Update the node, then retry."
|
||||
);
|
||||
};
|
||||
cmd.args(["--autoinstall", path]);
|
||||
let stdin = format!(
|
||||
"{}\n\n{}\ny\n",
|
||||
rnodeconf_device_menu_number(board),
|
||||
rnodeconf_band_menu_number(lora_region)
|
||||
);
|
||||
run_streamed(cmd, Some(stdin.into_bytes()), job)
|
||||
.await
|
||||
.context("archy-rnodeconf --autoinstall failed")
|
||||
}
|
||||
|
||||
// ─── Subprocess streaming ────────────────────────────────────────────────
|
||||
|
||||
fn percent_regex() -> &'static Regex {
|
||||
static RE: OnceLock<Regex> = OnceLock::new();
|
||||
RE.get_or_init(|| Regex::new(r"\((\d{1,3})\s*%\)").expect("valid regex"))
|
||||
}
|
||||
|
||||
async fn run_streamed(mut cmd: Command, stdin: Option<Vec<u8>>, job: &Arc<FlashJob>) -> Result<()> {
|
||||
cmd.stdout(Stdio::piped());
|
||||
cmd.stderr(Stdio::piped());
|
||||
if stdin.is_some() {
|
||||
cmd.stdin(Stdio::piped());
|
||||
}
|
||||
// Deliberately NOT kill_on_drop: an interrupted erase/write can leave
|
||||
// the chip in a worse state than either finished or unstarted (see the
|
||||
// cancellation-safety note in mesh flashing docs). The job is expected
|
||||
// to run to completion or fail on its own.
|
||||
let mut child = cmd.spawn().context("Failed to start subprocess")?;
|
||||
|
||||
if let Some(bytes) = stdin {
|
||||
if let Some(mut child_stdin) = child.stdin.take() {
|
||||
child_stdin
|
||||
.write_all(&bytes)
|
||||
.await
|
||||
.context("Writing to subprocess stdin")?;
|
||||
}
|
||||
}
|
||||
|
||||
let mut tasks = Vec::new();
|
||||
if let Some(stdout) = child.stdout.take() {
|
||||
let job = Arc::clone(job);
|
||||
tasks.push(tokio::spawn(async move {
|
||||
let mut lines = BufReader::new(stdout).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
if let Some(cap) = percent_regex().captures(&line) {
|
||||
if let Ok(pct) = cap[1].parse::<u8>() {
|
||||
job.set_percent(pct).await;
|
||||
}
|
||||
}
|
||||
job.push_log(line).await;
|
||||
}
|
||||
}));
|
||||
}
|
||||
if let Some(stderr) = child.stderr.take() {
|
||||
let job = Arc::clone(job);
|
||||
tasks.push(tokio::spawn(async move {
|
||||
let mut lines = BufReader::new(stderr).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
job.push_log(line).await;
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
let status = child.wait().await.context("Waiting for subprocess")?;
|
||||
for t in tasks {
|
||||
let _ = t.await;
|
||||
}
|
||||
if !status.success() {
|
||||
// Exit status alone isn't diagnosable — the actual esptool/rnodeconf
|
||||
// stderr (already captured into job.log_tail by the reader tasks
|
||||
// above) is what actually explains a failure. Confirmed live
|
||||
// 2026-07-23: a bare "Command exited with exit status: 1" told us
|
||||
// nothing when esptool's real error was sitting in the log tail the
|
||||
// whole time, only visible via the UI's live poll, not journald.
|
||||
let tail: Vec<String> = job
|
||||
.snapshot()
|
||||
.await
|
||||
.log_tail
|
||||
.iter()
|
||||
.rev()
|
||||
.take(10)
|
||||
.rev()
|
||||
.cloned()
|
||||
.collect();
|
||||
anyhow::bail!("Command exited with {status}\n{}", tail.join("\n"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,522 @@
|
||||
//! Mesh-AI assistant (issue #50) — answers `AssistQuery` messages with this
|
||||
//! node's local LLM and sends the reply back over the mesh.
|
||||
//!
|
||||
//! This is the Rust-native lift of Meshroller's "LLM bridge": a trusted peer
|
||||
//! asks a question over meshcore, an internet/compute-bearing node runs it
|
||||
//! through a local model (Ollama) and streams the answer back in capped,
|
||||
//! ordered chunks. Airtime is scarce, so the reply is length-capped and each
|
||||
//! asker is limited to one in-flight query.
|
||||
|
||||
use super::super::message_types::{self, AssistResponsePayload, MeshMessageType};
|
||||
use super::super::types::MeshEvent;
|
||||
use super::bitcoin::send_to_peer;
|
||||
use super::{MeshCommand, MeshState};
|
||||
use crate::federation::TrustLevel;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tracing::{info, warn};
|
||||
|
||||
/// Local Ollama generate endpoint (same host the Ollama app binds).
|
||||
const OLLAMA_URL: &str = "http://localhost:11434/api/generate";
|
||||
/// Default model when the node hasn't configured one (matches Meshroller).
|
||||
const DEFAULT_MODEL: &str = "qwen2.5-coder";
|
||||
/// Anthropic Messages API (called with the shared proxy token).
|
||||
const CLAUDE_URL: &str = "https://api.anthropic.com/v1/messages";
|
||||
/// Default Claude model — Haiku 4.5: fast + cheap, ideal for short mesh answers.
|
||||
const CLAUDE_DEFAULT_MODEL: &str = "claude-haiku-4-5-20251001";
|
||||
/// Max time to wait on the model before giving up.
|
||||
const OLLAMA_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
/// Hard cap on answer length sent over the radio — keeps airtime sane.
|
||||
const MAX_REPLY_CHARS: usize = 480;
|
||||
/// Characters of answer text per `AssistResponse` chunk.
|
||||
const CHUNK_CHARS: usize = 160;
|
||||
/// Tighter cap for plain-text channel replies (bare `!ai` clients) — these
|
||||
/// aren't reassembled by an archipelago UI, so keep them to a couple frames.
|
||||
const CHANNEL_REPLY_CHARS: usize = 200;
|
||||
|
||||
/// Where an answer should go.
|
||||
pub(super) enum AssistReply {
|
||||
/// Typed `AssistResponse` chunks addressed to one peer — the archipelago
|
||||
/// UI path (rich, reassembled, correlated by `req_id`).
|
||||
Typed { contact_id: u32 },
|
||||
/// Plain-text broadcast on a mesh channel — the bare `!ai` path, so any
|
||||
/// client (including non-archipelago meshcore/Meshtastic nodes) sees it.
|
||||
ChannelText { channel: u8 },
|
||||
/// Normal `Text` chat bubble sent back into the 1:1 thread — the
|
||||
/// archipelago `!ai`-in-chat path. The asker typed `!ai …` as a regular
|
||||
/// direct message, so the answer lands inline in that same conversation
|
||||
/// (encrypted, peer-addressed) rather than as a separate widget.
|
||||
ChatText { contact_id: u32 },
|
||||
/// Plain-text NATIVE direct message back to the asker's radio contact —
|
||||
/// the bare `!ai` path for a stock meshcore client (e.g. a phone). The
|
||||
/// answer goes as a real unicast DM (not a public-channel broadcast), so
|
||||
/// only the asker sees it and a stock client can read it.
|
||||
RadioDm { dest_prefix: [u8; 6] },
|
||||
}
|
||||
|
||||
/// Entry point: gate the query, run the model, send the answer back via the
|
||||
/// requested reply path. Spawned off the radio loop so it never blocks.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) async fn run_assist(
|
||||
prompt: String,
|
||||
model_override: Option<String>,
|
||||
req_id: u64,
|
||||
asker_contact_id: u32,
|
||||
sender_name: String,
|
||||
// Whether the asker's message was cryptographically authenticated (a
|
||||
// verified signature, or arrival over the federation transport). Required
|
||||
// for any identity-based allow under `trusted_only`/the allowlist.
|
||||
authenticated: bool,
|
||||
reply: AssistReply,
|
||||
state: Arc<MeshState>,
|
||||
) {
|
||||
let asker = asker_contact_id;
|
||||
|
||||
// Trust + block gate.
|
||||
if !is_sender_allowed(&state, asker, authenticated).await {
|
||||
warn!(
|
||||
from = asker,
|
||||
name = %sender_name,
|
||||
"AssistQuery denied — sender not permitted by assistant policy"
|
||||
);
|
||||
// Record who was turned away so the operator can find + allow them from
|
||||
// the UI (the silent-on-wire denial otherwise only shows in the journal).
|
||||
record_denied(&state, asker, &sender_name).await;
|
||||
// Silent on the wire (no airtime spent on denials); surface to the UI.
|
||||
let _ = state
|
||||
.event_tx
|
||||
.send(super::super::types::MeshEvent::AssistResponseReady {
|
||||
req_id,
|
||||
to_contact_id: asker,
|
||||
error: Some("denied".to_string()),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// One in-flight query per asker.
|
||||
{
|
||||
let mut inflight = state.assist_inflight.write().await;
|
||||
if !inflight.insert(asker) {
|
||||
warn!(
|
||||
from = asker,
|
||||
"AssistQuery dropped — asker already has one in flight"
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let _ = state
|
||||
.event_tx
|
||||
.send(super::super::types::MeshEvent::AssistQueryReceived {
|
||||
from_contact_id: asker,
|
||||
prompt: prompt.clone(),
|
||||
});
|
||||
|
||||
let (backend, configured_model) = {
|
||||
let a = state.assistant.read().await;
|
||||
(a.backend.clone(), a.model.clone())
|
||||
};
|
||||
let is_claude = backend == "claude";
|
||||
let default_model = if is_claude {
|
||||
CLAUDE_DEFAULT_MODEL
|
||||
} else {
|
||||
DEFAULT_MODEL
|
||||
};
|
||||
let model = model_override
|
||||
.or(configured_model)
|
||||
.unwrap_or_else(|| default_model.to_string());
|
||||
|
||||
info!(from = asker, req_id, backend = %backend, model = %model, "Answering AI query over mesh");
|
||||
|
||||
// Same tool surface as the embedded assistant (task: mesh `!ai` must
|
||||
// ACTION, not just chat): when the server has wired the shared loop in,
|
||||
// the asker's prompt runs through `assistant::chat` with a Mesh caller
|
||||
// scope — the operator's persisted grants gate what the model may touch,
|
||||
// and any write suspends on the node's confirm gate for the operator to
|
||||
// approve. The asker passed `is_sender_allowed` above, so `authorized`
|
||||
// is true here. Without the handler (early boot), the legacy bare-LLM
|
||||
// path still answers.
|
||||
let shared = state.assistant_handler.read().await.clone();
|
||||
let result = match shared {
|
||||
Some(handler) => {
|
||||
let caller = crate::assistant::CallerScope::Mesh {
|
||||
peer_id: asker.to_string(),
|
||||
authorized: true,
|
||||
};
|
||||
crate::assistant::chat(handler, caller, prompt.clone()).await
|
||||
}
|
||||
None => {
|
||||
if is_claude {
|
||||
call_claude(&state.data_dir, &model, &prompt).await
|
||||
} else {
|
||||
call_ollama(&model, &prompt).await
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(answer) => {
|
||||
send_reply(&state, &reply, req_id, &answer).await;
|
||||
let _ = state
|
||||
.event_tx
|
||||
.send(super::super::types::MeshEvent::AssistResponseReady {
|
||||
req_id,
|
||||
to_contact_id: asker,
|
||||
error: None,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(req_id, "AI query failed: {}", e);
|
||||
send_failure(&state, &reply, req_id, "AI unavailable").await;
|
||||
let _ = state
|
||||
.event_tx
|
||||
.send(super::super::types::MeshEvent::AssistResponseReady {
|
||||
req_id,
|
||||
to_contact_id: asker,
|
||||
error: Some(e.to_string()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
state.assist_inflight.write().await.remove(&asker);
|
||||
}
|
||||
|
||||
/// Whether `sender_contact_id` may invoke the assistant under the node's policy.
|
||||
///
|
||||
/// Always denies user-blocked contacts. Identity-based allows (the per-contact
|
||||
/// allowlist and the federation-Trusted match) require `authenticated == true` —
|
||||
/// i.e. the asker's message carried a signature that verified against its known
|
||||
/// key (or it arrived over the federation transport, which verifies upstream).
|
||||
/// A bare radio packet can CLAIM any key or DID, so without that proof the
|
||||
/// allowlist and trust list are spoofable; only the explicit "anyone on the
|
||||
/// mesh" policy (`trusted_only == false`) admits an unauthenticated asker.
|
||||
pub(super) async fn is_sender_allowed(
|
||||
state: &Arc<MeshState>,
|
||||
sender_contact_id: u32,
|
||||
authenticated: bool,
|
||||
) -> bool {
|
||||
let (pubkey_hex, did) = {
|
||||
let peers = state.peers.read().await;
|
||||
match peers.get(&sender_contact_id) {
|
||||
// Match identity on the bound archipelago key (stable, advert/
|
||||
// federation-verified), not the firmware routing key.
|
||||
Some(p) => (
|
||||
p.identity_pubkey_hex().map(|s| s.to_string()),
|
||||
p.did.clone(),
|
||||
),
|
||||
None => (None, None),
|
||||
}
|
||||
};
|
||||
|
||||
// Never answer a user-blocked contact, regardless of policy.
|
||||
if let Some(ref pk) = pubkey_hex {
|
||||
if state
|
||||
.contacts
|
||||
.read()
|
||||
.await
|
||||
.get(pk)
|
||||
.map(|c| c.blocked)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit per-contact allowlist: the operator deliberately ticked THIS
|
||||
// contact, so honour it even for an unauthenticated radio asker. A stock
|
||||
// meshcore client (e.g. a phone) can't sign our typed envelopes, so it can
|
||||
// never be `authenticated` — gating the allowlist on authentication made
|
||||
// ticking such a contact have no effect. We match the asker's resolved
|
||||
// identity key: the bound archipelago key if we know it, else the firmware
|
||||
// routing key (`pubkey_hex`), which is how meshcore addresses the contact
|
||||
// and what the UI adds to the allowlist for a keyless radio peer. This is a
|
||||
// narrow, explicit opt-in for a specific key — the spoofable federation-
|
||||
// trust-list match below still requires authentication.
|
||||
if let Some(ref pk) = pubkey_hex {
|
||||
let allowed = state.assistant.read().await.allowed_contacts.clone();
|
||||
if allowed.iter().any(|a| a.eq_ignore_ascii_case(pk)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if !state.assistant.read().await.trusted_only {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Trusted-only from here: an unauthenticated asker can never match the trust
|
||||
// list (it could otherwise just claim a trusted node's public key/DID).
|
||||
if !authenticated {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Match against the federation trust list by the asker's verified archipelago
|
||||
// pubkey or DID (a radio peer gets these from its signed identity advert).
|
||||
let nodes = crate::federation::load_nodes(&state.data_dir)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
nodes.iter().any(|n| {
|
||||
n.trust_level == TrustLevel::Trusted
|
||||
&& (Some(&n.pubkey) == pubkey_hex.as_ref() || Some(&n.did) == did.as_ref())
|
||||
})
|
||||
}
|
||||
|
||||
/// Newest-first cap on the denied-asker buffer — enough to surface the people
|
||||
/// who recently tried, without unbounded growth from a spammer.
|
||||
const MAX_DENIED_ASKERS: usize = 25;
|
||||
|
||||
/// Record a turned-away `!ai` asker so the UI can offer a one-click "Allow".
|
||||
/// Dedupes by contact id (moves an existing entry to the front and refreshes its
|
||||
/// timestamp/name) so repeated denials from one device don't flood the list.
|
||||
async fn record_denied(state: &Arc<MeshState>, asker_contact_id: u32, sender_name: &str) {
|
||||
// Capture the bound archipelago identity key (NOT the firmware routing key):
|
||||
// one-click "Allow" adds this to the allowlist, which the gate matches on the
|
||||
// archipelago key. A peer with no advert has no arch key → None → the UI shows
|
||||
// "no key" (only the "anyone on the mesh" policy can admit it).
|
||||
let pubkey_hex = {
|
||||
let peers = state.peers.read().await;
|
||||
peers
|
||||
.get(&asker_contact_id)
|
||||
.and_then(|p| p.arch_pubkey_hex.clone())
|
||||
};
|
||||
let entry = super::DeniedAsker {
|
||||
contact_id: asker_contact_id,
|
||||
name: sender_name.to_string(),
|
||||
pubkey_hex,
|
||||
at: chrono::Utc::now().to_rfc3339(),
|
||||
};
|
||||
let mut denied = state.assist_denied.write().await;
|
||||
denied.retain(|d| d.contact_id != asker_contact_id);
|
||||
denied.push_front(entry);
|
||||
denied.truncate(MAX_DENIED_ASKERS);
|
||||
}
|
||||
|
||||
/// Cap the answer to `MAX_REPLY_CHARS`, appending a marker when truncated.
|
||||
/// Returns (text_to_send, was_truncated).
|
||||
fn cap_reply(answer: &str) -> (String, bool) {
|
||||
let trimmed = answer.trim();
|
||||
if trimmed.chars().count() <= MAX_REPLY_CHARS {
|
||||
return (trimmed.to_string(), false);
|
||||
}
|
||||
let capped: String = trimmed.chars().take(MAX_REPLY_CHARS).collect();
|
||||
(format!("{capped}…(truncated)"), true)
|
||||
}
|
||||
|
||||
/// Send a successful answer via the requested reply path.
|
||||
pub(super) async fn send_reply(
|
||||
state: &Arc<MeshState>,
|
||||
reply: &AssistReply,
|
||||
req_id: u64,
|
||||
answer: &str,
|
||||
) {
|
||||
match reply {
|
||||
AssistReply::Typed { contact_id } => {
|
||||
let (text, _) = cap_reply(answer);
|
||||
send_typed_chunks(state, *contact_id, req_id, &text).await;
|
||||
}
|
||||
AssistReply::ChannelText { channel } => {
|
||||
let text = cap_channel(answer);
|
||||
send_channel_text(state, *channel, &text).await;
|
||||
}
|
||||
AssistReply::ChatText { contact_id } => {
|
||||
let (text, _) = cap_reply(answer);
|
||||
send_chat_text(state, *contact_id, &text).await;
|
||||
}
|
||||
AssistReply::RadioDm { dest_prefix } => {
|
||||
let text = cap_channel(answer);
|
||||
let _ = state
|
||||
.send_cmd(MeshCommand::SendNativeText {
|
||||
dest_pubkey_prefix: *dest_prefix,
|
||||
payload: text.into_bytes(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a failure notice via the requested reply path.
|
||||
async fn send_failure(state: &Arc<MeshState>, reply: &AssistReply, req_id: u64, msg: &str) {
|
||||
match reply {
|
||||
AssistReply::Typed { contact_id } => {
|
||||
let payload = AssistResponsePayload {
|
||||
req_id,
|
||||
text: String::new(),
|
||||
seq: 0,
|
||||
done: true,
|
||||
error: Some(msg.to_string()),
|
||||
};
|
||||
send_typed_response(state, *contact_id, &payload).await;
|
||||
}
|
||||
AssistReply::ChannelText { channel } => {
|
||||
send_channel_text(state, *channel, &format!("AI: {msg}")).await;
|
||||
}
|
||||
AssistReply::ChatText { contact_id } => {
|
||||
send_chat_text(state, *contact_id, &format!("AI: {msg}")).await;
|
||||
}
|
||||
AssistReply::RadioDm { dest_prefix } => {
|
||||
let _ = state
|
||||
.send_cmd(MeshCommand::SendNativeText {
|
||||
dest_pubkey_prefix: *dest_prefix,
|
||||
payload: format!("AI: {msg}").into_bytes(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Split the answer into ordered `AssistResponse` chunks and send each back to
|
||||
/// the asker on the encrypted, peer-addressed path (archipelago UI path).
|
||||
async fn send_typed_chunks(state: &Arc<MeshState>, dest_contact_id: u32, req_id: u64, text: &str) {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let chunks: Vec<String> = if chars.is_empty() {
|
||||
vec![String::new()]
|
||||
} else {
|
||||
chars
|
||||
.chunks(CHUNK_CHARS)
|
||||
.map(|c| c.iter().collect())
|
||||
.collect()
|
||||
};
|
||||
let last = chunks.len().saturating_sub(1);
|
||||
for (i, chunk) in chunks.into_iter().enumerate() {
|
||||
let payload = AssistResponsePayload {
|
||||
req_id,
|
||||
text: chunk,
|
||||
seq: i as u16,
|
||||
done: i == last,
|
||||
error: None,
|
||||
};
|
||||
send_typed_response(state, dest_contact_id, &payload).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode an `AssistResponse` payload and send it to a peer.
|
||||
async fn send_typed_response(
|
||||
state: &Arc<MeshState>,
|
||||
dest_contact_id: u32,
|
||||
payload: &AssistResponsePayload,
|
||||
) {
|
||||
let bytes = match message_types::encode_payload(payload) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
warn!("Failed to encode AssistResponse: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let envelope = message_types::TypedEnvelope::new(MeshMessageType::AssistResponse, bytes);
|
||||
match envelope.to_wire() {
|
||||
Ok(wire) => send_to_peer(state, dest_contact_id, wire).await,
|
||||
Err(e) => warn!("Failed to encode AssistResponse envelope: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Send the answer back into the 1:1 chat thread as a normal chat bubble.
|
||||
/// Used for the `!ai`-in-chat path. We emit an `AssistChatReply` event rather
|
||||
/// than sending here, because the reply must be routed transport-aware:
|
||||
/// `!ai` can arrive over LoRa OR over federation (Tor), and only
|
||||
/// `MeshService::send_message` (which owns the signing key + Tor client) knows
|
||||
/// to POST over the peer's onion for a federation-synthetic contact_id. The
|
||||
/// radio-only path used to drop the reply for federation askers — the answer
|
||||
/// showed on the answering node but never reached the asker. A server-layer
|
||||
/// consumer fulfils this event via `send_message`, which also records the
|
||||
/// Sent bubble and allocates the seq.
|
||||
async fn send_chat_text(state: &Arc<MeshState>, contact_id: u32, text: &str) {
|
||||
let _ = state.event_tx.send(MeshEvent::AssistChatReply {
|
||||
contact_id,
|
||||
text: text.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
/// Broadcast a plain-text answer on a channel for bare `!ai` clients.
|
||||
async fn send_channel_text(state: &Arc<MeshState>, channel: u8, text: &str) {
|
||||
let _ = state
|
||||
.send_cmd(MeshCommand::BroadcastChannel {
|
||||
channel,
|
||||
payload: text.as_bytes().to_vec(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Cap a plain-text channel reply to a couple of frames.
|
||||
fn cap_channel(answer: &str) -> String {
|
||||
let trimmed = answer.trim();
|
||||
if trimmed.chars().count() <= CHANNEL_REPLY_CHARS {
|
||||
return format!("AI: {trimmed}");
|
||||
}
|
||||
let capped: String = trimmed.chars().take(CHANNEL_REPLY_CHARS).collect();
|
||||
format!("AI: {capped}…")
|
||||
}
|
||||
|
||||
/// Call the local Ollama model and return the generated text.
|
||||
async fn call_ollama(model: &str, prompt: &str) -> anyhow::Result<String> {
|
||||
let client = reqwest::Client::builder().timeout(OLLAMA_TIMEOUT).build()?;
|
||||
let body = serde_json::json!({
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"stream": false,
|
||||
});
|
||||
let resp = client.post(OLLAMA_URL).json(&body).send().await?;
|
||||
if !resp.status().is_success() {
|
||||
anyhow::bail!("Ollama returned HTTP {}", resp.status());
|
||||
}
|
||||
let json: serde_json::Value = resp.json().await?;
|
||||
let text = json
|
||||
.get("response")
|
||||
.and_then(|r| r.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
if text.trim().is_empty() {
|
||||
anyhow::bail!("Ollama returned an empty response");
|
||||
}
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
/// Call Claude via the Anthropic Messages API using the node's shared proxy
|
||||
/// token at `secrets/claude-api-key`. Keeps answers short for radio airtime.
|
||||
async fn call_claude(data_dir: &Path, model: &str, prompt: &str) -> anyhow::Result<String> {
|
||||
let key = tokio::fs::read_to_string(data_dir.join("secrets/claude-api-key"))
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("Claude API key not configured on this node"))?;
|
||||
let key = key.trim();
|
||||
if key.is_empty() {
|
||||
anyhow::bail!("Claude API key is empty");
|
||||
}
|
||||
let client = reqwest::Client::builder().timeout(OLLAMA_TIMEOUT).build()?;
|
||||
let body = serde_json::json!({
|
||||
"model": model,
|
||||
"max_tokens": 512,
|
||||
"system": "You answer questions over a low-bandwidth radio mesh. Reply in at most two short sentences. No markdown, no preamble.",
|
||||
"messages": [{ "role": "user", "content": prompt }],
|
||||
});
|
||||
let resp = client
|
||||
.post(CLAUDE_URL)
|
||||
.header("x-api-key", key)
|
||||
.header("anthropic-version", "2023-06-01")
|
||||
.header("content-type", "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let txt = resp.text().await.unwrap_or_default();
|
||||
anyhow::bail!(
|
||||
"Claude API HTTP {}: {}",
|
||||
status,
|
||||
txt.chars().take(180).collect::<String>()
|
||||
);
|
||||
}
|
||||
let json: serde_json::Value = resp.json().await?;
|
||||
// `content` is an array of blocks; take the first text block.
|
||||
let text = json
|
||||
.get("content")
|
||||
.and_then(|c| c.as_array())
|
||||
.and_then(|arr| {
|
||||
arr.iter()
|
||||
.find_map(|b| b.get("text").and_then(|t| t.as_str()))
|
||||
})
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
if text.trim().is_empty() {
|
||||
anyhow::bail!("Claude returned an empty response");
|
||||
}
|
||||
Ok(text)
|
||||
}
|
||||
@@ -0,0 +1,514 @@
|
||||
//! Bitcoin relay operations: TX broadcast, confirmation tracking, peer messaging.
|
||||
|
||||
use super::super::crypto;
|
||||
use super::super::message_types;
|
||||
use super::MeshCommand;
|
||||
use super::MeshState;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
/// Called on an internet-connected node when it receives a TxRelay request.
|
||||
/// Broadcasts the raw TX to Bitcoin via RPC, sends the txid back, then
|
||||
/// monitors for 3 confirmations and sends updates back via mesh.
|
||||
pub(super) async fn handle_tx_relay_broadcast(
|
||||
relay: message_types::TxRelayPayload,
|
||||
sender_contact_id: u32,
|
||||
state: &Arc<MeshState>,
|
||||
) {
|
||||
let client = match reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
warn!("Failed to create HTTP client for TX relay: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let (rpc_user, rpc_pass) = crate::bitcoin_rpc::bitcoin_rpc_credentials().await;
|
||||
|
||||
// Pre-flight: check if Bitcoin Core is reachable and synced
|
||||
if !preflight_check(
|
||||
&client,
|
||||
&rpc_user,
|
||||
&rpc_pass,
|
||||
&relay,
|
||||
sender_contact_id,
|
||||
state,
|
||||
)
|
||||
.await
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 1: Broadcast via Bitcoin Core RPC sendrawtransaction
|
||||
let txid = match broadcast_transaction(
|
||||
&client,
|
||||
&rpc_user,
|
||||
&rpc_pass,
|
||||
&relay,
|
||||
sender_contact_id,
|
||||
state,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Some(id) => id,
|
||||
None => return,
|
||||
};
|
||||
|
||||
info!(request_id = relay.request_id, txid = %txid, "TX broadcast successful — tracking confirmations");
|
||||
|
||||
// Step 2: Send TxRelayResponse with txid back to originator
|
||||
send_tx_relay_response(
|
||||
state,
|
||||
sender_contact_id,
|
||||
relay.request_id,
|
||||
Some(&txid),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
// Step 3: Monitor confirmations (poll every 30s, up to 3 hours)
|
||||
track_confirmations(&client, &txid, relay.request_id, sender_contact_id, state).await;
|
||||
}
|
||||
|
||||
/// Pre-flight check: verify Bitcoin Core is reachable and synced.
|
||||
/// Returns `true` if the node is ready, `false` if an error response was sent.
|
||||
async fn preflight_check(
|
||||
client: &reqwest::Client,
|
||||
rpc_user: &str,
|
||||
rpc_pass: &str,
|
||||
relay: &message_types::TxRelayPayload,
|
||||
sender_contact_id: u32,
|
||||
state: &Arc<MeshState>,
|
||||
) -> bool {
|
||||
let preflight_body = serde_json::json!({
|
||||
"jsonrpc": "1.0",
|
||||
"id": "preflight",
|
||||
"method": "getblockchaininfo",
|
||||
"params": []
|
||||
});
|
||||
|
||||
match client
|
||||
.post(crate::constants::BITCOIN_RPC_URL)
|
||||
.basic_auth(rpc_user, Some(rpc_pass))
|
||||
.json(&preflight_body)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => {
|
||||
if let Ok(rpc_resp) = resp.json::<serde_json::Value>().await {
|
||||
if let Some(result) = rpc_resp.get("result") {
|
||||
let ibd = result
|
||||
.get("initialblockdownload")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let progress = result
|
||||
.get("verificationprogress")
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(0.0);
|
||||
if ibd || progress < 0.999 {
|
||||
let pct = (progress * 100.0) as u32;
|
||||
let msg =
|
||||
format!("Bitcoin node is syncing ({}%) — cannot broadcast yet", pct);
|
||||
warn!(request_id = relay.request_id, "{}", msg);
|
||||
send_tx_relay_response(
|
||||
state,
|
||||
sender_contact_id,
|
||||
relay.request_id,
|
||||
None,
|
||||
Some(&msg),
|
||||
Some("bitcoin_syncing"),
|
||||
)
|
||||
.await;
|
||||
return false;
|
||||
}
|
||||
} else if let Some(err) = rpc_resp.get("error").and_then(|e| e.as_object()) {
|
||||
let msg = err
|
||||
.get("message")
|
||||
.and_then(|m| m.as_str())
|
||||
.unwrap_or("RPC error");
|
||||
warn!(
|
||||
request_id = relay.request_id,
|
||||
"Bitcoin pre-flight failed: {}", msg
|
||||
);
|
||||
send_tx_relay_response(
|
||||
state,
|
||||
sender_contact_id,
|
||||
relay.request_id,
|
||||
None,
|
||||
Some(&format!("Bitcoin node error: {}", msg)),
|
||||
Some("bitcoin_unreachable"),
|
||||
)
|
||||
.await;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = format!(
|
||||
"Bitcoin node unreachable — {}",
|
||||
if e.is_connect() {
|
||||
"connection refused (node may be stopped)"
|
||||
} else if e.is_timeout() {
|
||||
"connection timed out"
|
||||
} else {
|
||||
"network error"
|
||||
}
|
||||
);
|
||||
warn!(request_id = relay.request_id, "Pre-flight: {}: {}", msg, e);
|
||||
send_tx_relay_response(
|
||||
state,
|
||||
sender_contact_id,
|
||||
relay.request_id,
|
||||
None,
|
||||
Some(&msg),
|
||||
Some("bitcoin_unreachable"),
|
||||
)
|
||||
.await;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Broadcast a raw transaction via Bitcoin Core RPC.
|
||||
/// Returns the txid on success, or None if an error response was sent.
|
||||
async fn broadcast_transaction(
|
||||
client: &reqwest::Client,
|
||||
rpc_user: &str,
|
||||
rpc_pass: &str,
|
||||
relay: &message_types::TxRelayPayload,
|
||||
sender_contact_id: u32,
|
||||
state: &Arc<MeshState>,
|
||||
) -> Option<String> {
|
||||
let body = serde_json::json!({
|
||||
"jsonrpc": "1.0",
|
||||
"id": "mesh-relay",
|
||||
"method": "sendrawtransaction",
|
||||
"params": [relay.tx_hex]
|
||||
});
|
||||
|
||||
let txid = match client
|
||||
.post(crate::constants::BITCOIN_RPC_URL)
|
||||
.basic_auth(rpc_user, Some(rpc_pass))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => match resp.json::<serde_json::Value>().await {
|
||||
Ok(rpc_resp) => {
|
||||
if let Some(err) = rpc_resp.get("error").and_then(|e| e.as_object()) {
|
||||
let code = err.get("code").and_then(|c| c.as_i64()).unwrap_or(0);
|
||||
let msg = err
|
||||
.get("message")
|
||||
.and_then(|m| m.as_str())
|
||||
.unwrap_or("unknown");
|
||||
let user_msg = match code {
|
||||
-25 => format!("TX already in mempool or confirmed: {}", msg),
|
||||
-26 => format!("TX rejected by mempool policy: {}", msg),
|
||||
-27 => "TX already confirmed in a block".to_string(),
|
||||
_ => format!("Bitcoin rejected TX (code {}): {}", code, msg),
|
||||
};
|
||||
warn!(
|
||||
request_id = relay.request_id,
|
||||
rpc_code = code,
|
||||
"sendrawtransaction: {}",
|
||||
msg
|
||||
);
|
||||
send_tx_relay_response(
|
||||
state,
|
||||
sender_contact_id,
|
||||
relay.request_id,
|
||||
None,
|
||||
Some(&user_msg),
|
||||
Some(&format!("tx_rejected:{}", code)),
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
}
|
||||
rpc_resp
|
||||
.get("result")
|
||||
.and_then(|r| r.as_str())
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to parse Bitcoin RPC response: {}", e);
|
||||
send_tx_relay_response(
|
||||
state,
|
||||
sender_contact_id,
|
||||
relay.request_id,
|
||||
None,
|
||||
Some("Failed to parse Bitcoin node response"),
|
||||
Some("rpc_parse_error"),
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
let msg = format!(
|
||||
"Bitcoin node unreachable during broadcast — {}",
|
||||
if e.is_connect() {
|
||||
"connection refused"
|
||||
} else if e.is_timeout() {
|
||||
"timed out"
|
||||
} else {
|
||||
"network error"
|
||||
}
|
||||
);
|
||||
warn!("Bitcoin Core RPC unreachable: {}", e);
|
||||
send_tx_relay_response(
|
||||
state,
|
||||
sender_contact_id,
|
||||
relay.request_id,
|
||||
None,
|
||||
Some(&msg),
|
||||
Some("bitcoin_unreachable"),
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
if txid.is_none() {
|
||||
send_tx_relay_response(
|
||||
state,
|
||||
sender_contact_id,
|
||||
relay.request_id,
|
||||
None,
|
||||
Some("Bitcoin node returned no transaction ID"),
|
||||
Some("rpc_parse_error"),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
txid
|
||||
}
|
||||
|
||||
/// Monitor a transaction for confirmations (poll every 30s, up to 3 hours).
|
||||
async fn track_confirmations(
|
||||
client: &reqwest::Client,
|
||||
txid: &str,
|
||||
request_id: u64,
|
||||
sender_contact_id: u32,
|
||||
state: &Arc<MeshState>,
|
||||
) {
|
||||
let mut last_reported_confs: u32 = 0;
|
||||
for _ in 0..360 {
|
||||
tokio::time::sleep(Duration::from_secs(30)).await;
|
||||
|
||||
match check_tx_confirmations(client, txid).await {
|
||||
Ok((confs, block_height)) => {
|
||||
if confs > last_reported_confs && confs <= 3 {
|
||||
info!(txid = %txid, confirmations = confs, "Sending confirmation update via mesh");
|
||||
send_confirmation_update(
|
||||
state,
|
||||
sender_contact_id,
|
||||
request_id,
|
||||
txid,
|
||||
confs,
|
||||
block_height,
|
||||
)
|
||||
.await;
|
||||
last_reported_confs = confs;
|
||||
if confs >= 3 {
|
||||
info!(txid = %txid, "TX fully confirmed (3/3) — done tracking");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
debug!(txid = %txid, "Confirmation check: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a TxRelayResponse back to the originating peer.
|
||||
async fn send_tx_relay_response(
|
||||
state: &Arc<MeshState>,
|
||||
dest_contact_id: u32,
|
||||
request_id: u64,
|
||||
txid: Option<&str>,
|
||||
error: Option<&str>,
|
||||
error_code: Option<&str>,
|
||||
) {
|
||||
let wire = match super::super::bitcoin_relay::build_tx_relay_response(
|
||||
request_id, txid, error, error_code,
|
||||
) {
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
warn!("Failed to build TX relay response: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
send_to_peer(state, dest_contact_id, wire).await;
|
||||
}
|
||||
|
||||
/// Send a TxConfirmation update to the originator.
|
||||
async fn send_confirmation_update(
|
||||
state: &Arc<MeshState>,
|
||||
dest_contact_id: u32,
|
||||
request_id: u64,
|
||||
txid: &str,
|
||||
confirmations: u32,
|
||||
block_height: u64,
|
||||
) {
|
||||
let conf = message_types::TxConfirmationPayload {
|
||||
request_id,
|
||||
txid: txid.to_string(),
|
||||
confirmations,
|
||||
block_height,
|
||||
};
|
||||
if let Ok(payload_bytes) = message_types::encode_payload(&conf) {
|
||||
let envelope = message_types::TypedEnvelope::new(
|
||||
message_types::MeshMessageType::TxConfirmation,
|
||||
payload_bytes,
|
||||
);
|
||||
if let Ok(wire) = envelope.to_wire() {
|
||||
send_to_peer(state, dest_contact_id, wire).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Encrypt a typed wire payload for a specific peer.
|
||||
/// Attempts ratchet encryption first (forward secrecy), falls back to static
|
||||
/// shared secret, falls back to plaintext if neither is available.
|
||||
/// Respects the encrypt_relay config toggle for rollback.
|
||||
async fn encrypt_for_peer(state: &Arc<MeshState>, contact_id: u32, typed_wire: &[u8]) -> Vec<u8> {
|
||||
if !state.encrypt_relay {
|
||||
return typed_wire.to_vec();
|
||||
}
|
||||
|
||||
// Look up peer DID for ratchet session
|
||||
let peer_did = state
|
||||
.peers
|
||||
.read()
|
||||
.await
|
||||
.get(&contact_id)
|
||||
.and_then(|p| p.did.clone());
|
||||
|
||||
// Try ratchet encryption first (forward secrecy)
|
||||
if let Some(ref did) = peer_did {
|
||||
if state.session_manager.has_session(did).await {
|
||||
match state
|
||||
.session_manager
|
||||
.encrypt_for_peer(did, typed_wire)
|
||||
.await
|
||||
{
|
||||
Ok(ratchet_msg) => {
|
||||
let ratchet_bytes = ratchet_msg.to_bytes();
|
||||
let mut buf = Vec::with_capacity(1 + ratchet_bytes.len());
|
||||
buf.push(message_types::RATCHET_TYPED_MARKER);
|
||||
buf.extend_from_slice(&ratchet_bytes);
|
||||
debug!(contact_id, did = %did, "Encrypted with Double Ratchet (0xDD)");
|
||||
return buf;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(contact_id, did = %did, "Ratchet encrypt failed, trying static: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to static shared secret (0xEE)
|
||||
let secrets = state.shared_secrets.read().await;
|
||||
if let Some(secret) = secrets.get(&contact_id) {
|
||||
match crypto::encrypt(secret, typed_wire) {
|
||||
Ok(ciphertext) => {
|
||||
let mut buf = Vec::with_capacity(1 + ciphertext.len());
|
||||
buf.push(message_types::ENCRYPTED_TYPED_MARKER);
|
||||
buf.extend_from_slice(&ciphertext);
|
||||
debug!(contact_id, "Encrypted with static shared secret (0xEE)");
|
||||
return buf;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
contact_id,
|
||||
"Static encrypt failed, sending plaintext: {}", e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No encryption available — send plaintext
|
||||
debug!(
|
||||
contact_id,
|
||||
"No encryption available, sending plaintext (0x02)"
|
||||
);
|
||||
typed_wire.to_vec()
|
||||
}
|
||||
|
||||
/// Send raw wire bytes to a specific peer by contact_id.
|
||||
/// Encrypts directed messages via ratchet or shared secret when available.
|
||||
/// Falls back to channel 0 broadcast (plaintext) if peer's pubkey is unknown.
|
||||
/// `pub(super)` so sibling handlers (e.g. the AI assistant) can reply on the
|
||||
/// same encrypted, peer-addressed path the relay handlers use.
|
||||
pub(super) async fn send_to_peer(state: &Arc<MeshState>, contact_id: u32, typed_wire: Vec<u8>) {
|
||||
let peers = state.peers.read().await;
|
||||
if let Some(peer) = peers.get(&contact_id) {
|
||||
if let Some(ref pk) = peer.pubkey_hex {
|
||||
if let Ok(pk_bytes) = hex::decode(pk) {
|
||||
if pk_bytes.len() >= 6 {
|
||||
let mut prefix = [0u8; 6];
|
||||
prefix.copy_from_slice(&pk_bytes[..6]);
|
||||
drop(peers);
|
||||
// Encrypt for this specific peer before sending
|
||||
let payload = encrypt_for_peer(state, contact_id, &typed_wire).await;
|
||||
let _ = state
|
||||
.send_cmd(MeshCommand::SendRaw {
|
||||
dest_pubkey_prefix: prefix,
|
||||
payload,
|
||||
})
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(peers);
|
||||
// Broadcast fallback — plaintext (no specific peer to encrypt for)
|
||||
let _ = state
|
||||
.send_cmd(MeshCommand::BroadcastChannel {
|
||||
channel: 0,
|
||||
payload: typed_wire,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Check transaction confirmation count via Bitcoin Core RPC.
|
||||
async fn check_tx_confirmations(
|
||||
client: &reqwest::Client,
|
||||
txid: &str,
|
||||
) -> anyhow::Result<(u32, u64)> {
|
||||
let body = serde_json::json!({
|
||||
"jsonrpc": "1.0",
|
||||
"id": "mesh-conf",
|
||||
"method": "gettransaction",
|
||||
"params": [txid]
|
||||
});
|
||||
let (rpc_user, rpc_pass) = crate::bitcoin_rpc::bitcoin_rpc_credentials().await;
|
||||
let resp = client
|
||||
.post(crate::constants::BITCOIN_RPC_URL)
|
||||
.basic_auth(&rpc_user, Some(&rpc_pass))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
let rpc_resp: serde_json::Value = resp.json().await?;
|
||||
if let Some(result) = rpc_resp.get("result") {
|
||||
let confs = result
|
||||
.get("confirmations")
|
||||
.and_then(|c| c.as_u64())
|
||||
.unwrap_or(0) as u32;
|
||||
let block_height = result
|
||||
.get("blockheight")
|
||||
.and_then(|h| h.as_u64())
|
||||
.unwrap_or(0);
|
||||
Ok((confs, block_height))
|
||||
} else {
|
||||
anyhow::bail!("gettransaction returned no result")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,732 @@
|
||||
//! Message decoding: base64, encryption, chunk reassembly, peer resolution.
|
||||
|
||||
use super::super::crypto;
|
||||
use super::super::message_types::{self, TypedEnvelope};
|
||||
use super::super::types::*;
|
||||
use super::MeshState;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
/// Try to base64-decode payload and check if the result is a typed envelope.
|
||||
/// Handles: plain typed (0x02), steganographic (0xAA), and encrypted (0xEE).
|
||||
/// Returns the decoded bytes if it's a valid base64-encoded TypedEnvelope.
|
||||
pub(super) fn try_base64_typed(payload: &[u8]) -> Option<Vec<u8>> {
|
||||
use base64::Engine;
|
||||
if payload.is_empty() || payload[0] == message_types::TYPED_MESSAGE_MARKER {
|
||||
return None;
|
||||
}
|
||||
let text = std::str::from_utf8(payload).ok()?;
|
||||
let decoded = base64::engine::general_purpose::STANDARD
|
||||
.decode(text.trim())
|
||||
.ok()?;
|
||||
unwrap_wire_layers(&decoded)
|
||||
}
|
||||
|
||||
/// Try to base64-decode and decrypt an encrypted typed message.
|
||||
/// Handles the common case where encrypted messages arrive as base64 text.
|
||||
pub(super) async fn try_decrypt_base64(
|
||||
payload: &[u8],
|
||||
sender_contact_id: u32,
|
||||
state: &Arc<MeshState>,
|
||||
) -> Option<Vec<u8>> {
|
||||
use base64::Engine;
|
||||
let text = std::str::from_utf8(payload).ok()?;
|
||||
let decoded = base64::engine::general_purpose::STANDARD
|
||||
.decode(text.trim())
|
||||
.ok()?;
|
||||
if decoded.first() != Some(&message_types::ENCRYPTED_TYPED_MARKER) {
|
||||
return None;
|
||||
}
|
||||
let secrets = state.shared_secrets.read().await;
|
||||
try_decrypt_typed(&decoded, sender_contact_id, &secrets)
|
||||
}
|
||||
|
||||
/// Try to decrypt a Double Ratchet encrypted message (0xDD prefix).
|
||||
/// Format: [0xDD] [RatchetHeader(40) + nonce(12) + ciphertext + tag(16)]
|
||||
/// Returns the decrypted typed wire bytes ([0x02][CBOR]) if successful.
|
||||
async fn try_decrypt_ratchet(
|
||||
decoded: &[u8],
|
||||
sender_contact_id: u32,
|
||||
state: &Arc<MeshState>,
|
||||
) -> Option<Vec<u8>> {
|
||||
if decoded.first() != Some(&message_types::RATCHET_TYPED_MARKER) {
|
||||
return None;
|
||||
}
|
||||
let ratchet_bytes = &decoded[1..]; // skip 0xDD marker
|
||||
|
||||
let ratchet_msg = match super::super::ratchet::RatchetMessage::from_bytes(ratchet_bytes) {
|
||||
Ok(msg) => msg,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
contact_id = sender_contact_id,
|
||||
"Failed to parse ratchet message: {}", e
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
// Look up peer DID for session manager
|
||||
let peer_did = state
|
||||
.peers
|
||||
.read()
|
||||
.await
|
||||
.get(&sender_contact_id)
|
||||
.and_then(|p| p.did.clone())?;
|
||||
|
||||
match state
|
||||
.session_manager
|
||||
.decrypt_from_peer(&peer_did, &ratchet_msg)
|
||||
.await
|
||||
{
|
||||
Ok(plaintext) => {
|
||||
debug!(contact_id = sender_contact_id, did = %peer_did, "Decrypted ratchet message (0xDD)");
|
||||
// The plaintext should be the original [0x02][CBOR] typed wire
|
||||
if TypedEnvelope::is_typed(&plaintext) {
|
||||
Some(plaintext)
|
||||
} else {
|
||||
// Could be nested stego -> typed
|
||||
unwrap_wire_layers(&plaintext)
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
contact_id = sender_contact_id,
|
||||
"Ratchet decrypt failed: {}", e
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to base64-decode and decrypt a ratchet-encrypted message.
|
||||
/// Handles the case where ratchet messages arrive as base64 text.
|
||||
pub(super) async fn try_decrypt_ratchet_base64(
|
||||
payload: &[u8],
|
||||
sender_contact_id: u32,
|
||||
state: &Arc<MeshState>,
|
||||
) -> Option<Vec<u8>> {
|
||||
use base64::Engine;
|
||||
let text = std::str::from_utf8(payload).ok()?;
|
||||
let decoded = base64::engine::general_purpose::STANDARD
|
||||
.decode(text.trim())
|
||||
.ok()?;
|
||||
if decoded.first() != Some(&message_types::RATCHET_TYPED_MARKER) {
|
||||
return None;
|
||||
}
|
||||
try_decrypt_ratchet(&decoded, sender_contact_id, state).await
|
||||
}
|
||||
|
||||
/// Unwrap wire layers: encrypted (0xEE) -> stego (0xAA) -> typed (0x02).
|
||||
/// Returns None if decoding fails at any layer (caller should use shared_secrets variant).
|
||||
fn unwrap_wire_layers(decoded: &[u8]) -> Option<Vec<u8>> {
|
||||
// Check for steganographic frame (0xAA prefix) — unwrap to typed envelope
|
||||
if decoded.first() == Some(&super::super::steganography::STEGO_MARKER) {
|
||||
match super::super::steganography::decode_typed_wire(decoded) {
|
||||
Ok(typed_wire) => return Some(typed_wire),
|
||||
Err(_) => return None,
|
||||
}
|
||||
}
|
||||
if TypedEnvelope::is_typed(decoded) {
|
||||
Some(decoded.to_vec())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to decrypt an encrypted typed message (0xEE prefix) using known shared secrets.
|
||||
/// Format: [0xEE] [nonce: 12] [ciphertext + tag: 16]
|
||||
fn try_decrypt_typed(
|
||||
decoded: &[u8],
|
||||
sender_contact_id: u32,
|
||||
shared_secrets: &HashMap<u32, [u8; 32]>,
|
||||
) -> Option<Vec<u8>> {
|
||||
if decoded.first() != Some(&message_types::ENCRYPTED_TYPED_MARKER) {
|
||||
return None;
|
||||
}
|
||||
let ciphertext = &decoded[1..]; // skip 0xEE marker
|
||||
|
||||
// Try sender's shared secret first (most likely)
|
||||
if let Some(secret) = shared_secrets.get(&sender_contact_id) {
|
||||
if let Ok(plaintext) = crypto::decrypt(secret, ciphertext) {
|
||||
return unwrap_wire_layers(&plaintext);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: try all known shared secrets (in case contact_id mapping is stale)
|
||||
for (cid, secret) in shared_secrets {
|
||||
if *cid == sender_contact_id {
|
||||
continue;
|
||||
} // already tried
|
||||
if let Ok(plaintext) = crypto::decrypt(secret, ciphertext) {
|
||||
return unwrap_wire_layers(&plaintext);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Cheap structural check: does this payload look like an `MCxxyyzz…`
|
||||
/// chunk frame? Used by the receive dispatcher to decide whether a `None`
|
||||
/// from `try_chunk_reassemble` means "not a chunk" (fall through to the
|
||||
/// plaintext store) or "chunk buffered, waiting for more frames" (do not
|
||||
/// store anything yet — partial chunks must never be persisted as their
|
||||
/// own messages, which was the cause of the raw `MC0301…` bubbles users
|
||||
/// were seeing in chat).
|
||||
pub(super) fn is_mc_chunk_frame(payload: &[u8]) -> bool {
|
||||
let Ok(text) = std::str::from_utf8(payload) else {
|
||||
return false;
|
||||
};
|
||||
if !text.starts_with("MC") || text.len() < 8 {
|
||||
return false;
|
||||
}
|
||||
u8::from_str_radix(&text[2..4], 16).is_ok()
|
||||
&& u8::from_str_radix(&text[4..6], 16).is_ok()
|
||||
&& u8::from_str_radix(&text[6..8], 16).is_ok()
|
||||
}
|
||||
|
||||
/// Check if payload is a mesh chunk ("MC" prefix) and try to reassemble.
|
||||
/// Format: MC{msg_id:2hex}{chunk_idx:2hex}{total:2hex}{base64_data}
|
||||
/// Returns Some(decoded_bytes) when all chunks have arrived.
|
||||
pub(super) async fn try_chunk_reassemble(
|
||||
payload: &[u8],
|
||||
sender_contact_id: u32,
|
||||
state: &Arc<MeshState>,
|
||||
) -> Option<Vec<u8>> {
|
||||
use base64::Engine;
|
||||
let text = std::str::from_utf8(payload).ok()?;
|
||||
if !text.starts_with("MC") || text.len() < 8 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let msg_id = u8::from_str_radix(&text[2..4], 16).ok()?;
|
||||
let chunk_idx = u8::from_str_radix(&text[4..6], 16).ok()?;
|
||||
let total = u8::from_str_radix(&text[6..8], 16).ok()?;
|
||||
let chunk_data = &text[8..];
|
||||
|
||||
if total == 0 || total > 20 {
|
||||
return None; // sanity check
|
||||
}
|
||||
|
||||
let key = (sender_contact_id, msg_id);
|
||||
let mut buffer = state.chunk_buffer.write().await;
|
||||
|
||||
// Clean up stale entries (>120s old)
|
||||
buffer.retain(|_, v| v.created.elapsed().as_secs() < 120);
|
||||
|
||||
let assembly = buffer.entry(key).or_insert_with(|| super::ChunkAssembly {
|
||||
chunks: HashMap::new(),
|
||||
total,
|
||||
created: std::time::Instant::now(),
|
||||
});
|
||||
|
||||
assembly.chunks.insert(chunk_idx, chunk_data.to_string());
|
||||
assembly.total = total; // update in case first chunk had it wrong
|
||||
|
||||
debug!(
|
||||
msg_id,
|
||||
chunk_idx,
|
||||
total,
|
||||
received = assembly.chunks.len(),
|
||||
"Chunk received"
|
||||
);
|
||||
|
||||
// Check if we have all chunks
|
||||
if assembly.chunks.len() < total as usize {
|
||||
return None;
|
||||
}
|
||||
|
||||
// All chunks received — reassemble in order
|
||||
let mut combined = String::new();
|
||||
for i in 0..total {
|
||||
match assembly.chunks.get(&i) {
|
||||
Some(data) => combined.push_str(data),
|
||||
None => {
|
||||
warn!(msg_id, missing = i, "Chunk missing during reassembly");
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(&combined) {
|
||||
// Check for ratchet-encrypted frame (0xDD) — decrypt then unwrap
|
||||
if decoded.first() == Some(&message_types::RATCHET_TYPED_MARKER) {
|
||||
// Must drop buffer lock before calling async try_decrypt_ratchet
|
||||
let decoded_clone = decoded.clone();
|
||||
drop(buffer);
|
||||
if let Some(typed_wire) =
|
||||
try_decrypt_ratchet(&decoded_clone, sender_contact_id, state).await
|
||||
{
|
||||
info!(
|
||||
msg_id,
|
||||
chunks = total,
|
||||
total_len = typed_wire.len(),
|
||||
"Reassembled ratchet-encrypted chunked message"
|
||||
);
|
||||
state.chunk_buffer.write().await.remove(&key);
|
||||
return Some(typed_wire);
|
||||
}
|
||||
buffer = state.chunk_buffer.write().await;
|
||||
}
|
||||
// Check for static-encrypted frame (0xEE) — decrypt then unwrap
|
||||
if decoded.first() == Some(&message_types::ENCRYPTED_TYPED_MARKER) {
|
||||
let secrets = state.shared_secrets.read().await;
|
||||
if let Some(typed_wire) = try_decrypt_typed(&decoded, sender_contact_id, &secrets) {
|
||||
info!(
|
||||
msg_id,
|
||||
chunks = total,
|
||||
total_len = typed_wire.len(),
|
||||
"Reassembled encrypted chunked message"
|
||||
);
|
||||
buffer.remove(&key);
|
||||
return Some(typed_wire);
|
||||
}
|
||||
}
|
||||
// Check for stego frame — unwrap to typed envelope
|
||||
if decoded.first() == Some(&super::super::steganography::STEGO_MARKER) {
|
||||
if let Ok(typed_wire) = super::super::steganography::decode_typed_wire(&decoded) {
|
||||
info!(
|
||||
msg_id,
|
||||
chunks = total,
|
||||
total_len = typed_wire.len(),
|
||||
"Reassembled stego chunked message"
|
||||
);
|
||||
buffer.remove(&key);
|
||||
return Some(typed_wire);
|
||||
}
|
||||
}
|
||||
if TypedEnvelope::is_typed(&decoded) {
|
||||
info!(
|
||||
msg_id,
|
||||
chunks = total,
|
||||
total_len = decoded.len(),
|
||||
"Reassembled chunked message"
|
||||
);
|
||||
buffer.remove(&key);
|
||||
return Some(decoded);
|
||||
}
|
||||
}
|
||||
|
||||
warn!(msg_id, "All chunks received but decode failed");
|
||||
buffer.remove(&key);
|
||||
None
|
||||
}
|
||||
|
||||
/// Look up a peer by pubkey hex prefix. Returns (contact_id, display_name).
|
||||
pub(super) async fn resolve_peer(state: &Arc<MeshState>, sender_prefix: &str) -> (u32, String) {
|
||||
{
|
||||
let peers = state.peers.read().await;
|
||||
if let Some(peer) = peers.values().find(|p| {
|
||||
p.pubkey_hex
|
||||
.as_ref()
|
||||
.map(|k| k.starts_with(sender_prefix))
|
||||
.unwrap_or(false)
|
||||
}) {
|
||||
return (peer.contact_id, peer.advert_name.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((node_num, pubkey_hex, name)) = meshtastic_peer_from_prefix(sender_prefix) {
|
||||
let peer = MeshPeer {
|
||||
contact_id: node_num,
|
||||
advert_name: name.clone(),
|
||||
did: None,
|
||||
pubkey_hex: Some(pubkey_hex),
|
||||
arch_pubkey_hex: None,
|
||||
x25519_pubkey: None,
|
||||
rssi: None,
|
||||
snr: None,
|
||||
last_heard: chrono::Utc::now().to_rfc3339(),
|
||||
hops: 0xff,
|
||||
last_advert: 0,
|
||||
reachable: true,
|
||||
// Stamped fresh from `peer_pubkeys` in `get_contacts` once a real
|
||||
// contact refresh runs; unknown at synthesis time here.
|
||||
pkc_capable: false,
|
||||
lat: None,
|
||||
lon: None,
|
||||
// Set only by an explicit LightningInfo advert, never inferred.
|
||||
lightning_uri: None,
|
||||
};
|
||||
let is_new = {
|
||||
let mut peers = state.peers.write().await;
|
||||
peers.insert(node_num, peer.clone()).is_none()
|
||||
};
|
||||
state.update_peer_count().await;
|
||||
let _ = state.event_tx.send(if is_new {
|
||||
MeshEvent::PeerDiscovered(peer)
|
||||
} else {
|
||||
MeshEvent::PeerUpdated(peer)
|
||||
});
|
||||
return (node_num, name);
|
||||
}
|
||||
|
||||
(0, sender_prefix.to_string())
|
||||
}
|
||||
|
||||
fn meshtastic_peer_from_prefix(sender_prefix: &str) -> Option<(u32, String, String)> {
|
||||
if sender_prefix.len() < 12 {
|
||||
return None;
|
||||
}
|
||||
let bytes = hex::decode(&sender_prefix[..12]).ok()?;
|
||||
if bytes.len() != 6 || bytes[4] != b'm' || bytes[5] != b'e' {
|
||||
return None;
|
||||
}
|
||||
let node_num = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
|
||||
if node_num == 0 || node_num == u32::MAX {
|
||||
return None;
|
||||
}
|
||||
let mut full_key = [0u8; 32];
|
||||
full_key[..4].copy_from_slice(&node_num.to_le_bytes());
|
||||
full_key[4..15].copy_from_slice(b"meshtastic:");
|
||||
let name = format!("Meshtastic !{:08x}", node_num);
|
||||
Some((node_num, hex::encode(full_key), name))
|
||||
}
|
||||
|
||||
/// Stamp the SNR carried in a Meshcore v3 contact-message frame onto the
|
||||
/// sender's peer record so the signal-bars indicator has real data (Meshcore
|
||||
/// has no per-packet RSSI like Meshtastic, only this 1-byte SNR — see
|
||||
/// `protocol::parse_contact_msg_v3_raw`).
|
||||
pub(super) async fn update_peer_snr(state: &Arc<MeshState>, contact_id: u32, snr: f32) {
|
||||
let mut peers = state.peers.write().await;
|
||||
if let Some(peer) = peers.get_mut(&contact_id) {
|
||||
peer.snr = Some(snr);
|
||||
}
|
||||
}
|
||||
|
||||
/// Store a plain-text (non-typed) message and emit an event.
|
||||
pub(super) async fn store_plain_message(
|
||||
state: &Arc<MeshState>,
|
||||
contact_id: u32,
|
||||
peer_name: &str,
|
||||
text: &str,
|
||||
) {
|
||||
store_plain_message_with_encryption(state, contact_id, peer_name, text, false).await;
|
||||
}
|
||||
|
||||
pub(super) async fn store_plain_message_with_encryption(
|
||||
state: &Arc<MeshState>,
|
||||
contact_id: u32,
|
||||
peer_name: &str,
|
||||
text: &str,
|
||||
encrypted: bool,
|
||||
) {
|
||||
let msg_id = state.next_id().await;
|
||||
let radio_transport = radio_transport_label(state.status.read().await.device_type);
|
||||
let msg = MeshMessage {
|
||||
id: msg_id,
|
||||
direction: MessageDirection::Received,
|
||||
peer_contact_id: contact_id,
|
||||
peer_name: Some(peer_name.to_string()),
|
||||
plaintext: text.to_string(),
|
||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||
delivered: true,
|
||||
encrypted,
|
||||
transport: Some(radio_transport.to_string()),
|
||||
message_type: "text".to_string(),
|
||||
typed_payload: None,
|
||||
sender_pubkey: None,
|
||||
sender_seq: None,
|
||||
};
|
||||
state.store_message(msg.clone()).await;
|
||||
state.status.write().await.messages_received += 1;
|
||||
let _ = state.event_tx.send(MeshEvent::MessageReceived(msg));
|
||||
|
||||
// Where a plain-text answer goes: a private NATIVE DM to the asker whenever
|
||||
// we know its radio pubkey (so it does NOT land on the public channel and a
|
||||
// stock meshcore client can read it); we only fall back to a channel reply
|
||||
// if the sender has no resolvable pubkey (rare).
|
||||
let plain_reply = || async {
|
||||
let peers = state.peers.read().await;
|
||||
peers
|
||||
.get(&contact_id)
|
||||
.and_then(|p| p.pubkey_hex.clone())
|
||||
.filter(|h| h.len() >= 12)
|
||||
.and_then(|h| hex::decode(&h[..12]).ok())
|
||||
.filter(|b| b.len() == 6)
|
||||
.map(|b| {
|
||||
let mut pre = [0u8; 6];
|
||||
pre.copy_from_slice(&b);
|
||||
super::assist::AssistReply::RadioDm { dest_prefix: pre }
|
||||
})
|
||||
.unwrap_or(super::assist::AssistReply::ChannelText { channel: 0 })
|
||||
};
|
||||
|
||||
// `!archy [sub]` — node status straight from this node's caches. No model is
|
||||
// involved, so it stays available with the AI assistant switched off; the
|
||||
// trust gate in run_node_cmd still applies.
|
||||
if let Some(rest) = super::node_cmd::strip_archy_trigger(text) {
|
||||
let reply = plain_reply().await;
|
||||
let req_id = state.next_id().await;
|
||||
let rest = rest.to_string();
|
||||
let name = peer_name.to_string();
|
||||
let st = Arc::clone(state);
|
||||
tokio::spawn(async move {
|
||||
// Bare plain-text carries no signature — not authenticated.
|
||||
super::node_cmd::run_node_cmd(rest, req_id, contact_id, name, false, reply, st).await;
|
||||
});
|
||||
}
|
||||
// Mesh-AI assistant (issue #50): a plain `!ai`/`!ask <question>` is answered
|
||||
// by this node's local model when the assistant is on. The trust/rate gate
|
||||
// lives in run_assist.
|
||||
else if state.assistant.read().await.enabled {
|
||||
if let Some(prompt) = strip_ai_trigger(text) {
|
||||
if !prompt.is_empty() {
|
||||
let reply = plain_reply().await;
|
||||
let req_id = state.next_id().await;
|
||||
let prompt = prompt.to_string();
|
||||
let name = peer_name.to_string();
|
||||
let st = Arc::clone(state);
|
||||
tokio::spawn(async move {
|
||||
// A bare plain-text channel `!ai` carries no signature, so it
|
||||
// is NOT authenticated — under trusted_only it'll be denied,
|
||||
// and it can only be answered under the "anyone" policy.
|
||||
super::assist::run_assist(
|
||||
prompt, None, req_id, contact_id, name, false, reply, st,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Recognise a `!ai`/`!ask ` command prefix (case-insensitive) and return the
|
||||
/// trimmed question after it, or `None` if the text isn't an AI command.
|
||||
pub(super) fn strip_ai_trigger(text: &str) -> Option<&str> {
|
||||
let t = text.trim_start();
|
||||
for p in ["!ai ", "!ask "] {
|
||||
if t.len() >= p.len() && t[..p.len()].eq_ignore_ascii_case(p) {
|
||||
return Some(t[p.len()..].trim());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Handle a received identity broadcast from a peer.
|
||||
#[allow(dead_code)]
|
||||
pub(super) 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"
|
||||
);
|
||||
|
||||
// Verify Ed25519 public key is valid
|
||||
let ed_pubkey_bytes = match hex::decode(ed_pubkey_hex) {
|
||||
Ok(b) if b.len() == 32 => {
|
||||
let mut arr = [0u8; 32];
|
||||
arr.copy_from_slice(&b);
|
||||
arr
|
||||
}
|
||||
_ => {
|
||||
warn!(contact_id, "Rejecting identity: invalid Ed25519 public key");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if ed25519_dalek::VerifyingKey::from_bytes(&ed_pubkey_bytes).is_err() {
|
||||
warn!(
|
||||
contact_id,
|
||||
"Rejecting identity: Ed25519 key is not a valid curve point"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify X25519 public key is consistent with Ed25519 key
|
||||
let expected_x25519 = match crypto::ed25519_pubkey_to_x25519(&ed_pubkey_bytes) {
|
||||
Ok(k) => k,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
contact_id,
|
||||
"Rejecting identity: cannot derive X25519 from Ed25519: {}", e
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// 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!(contact_id, "Rejecting identity: invalid X25519 public key");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if x25519_bytes != expected_x25519 {
|
||||
warn!(contact_id, did = %did, "Rejecting identity: X25519 key does not match Ed25519 key");
|
||||
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 mut peer = MeshPeer {
|
||||
contact_id,
|
||||
// .get(): a malformed DID shorter than the "did:key:" prefix must
|
||||
// not panic the listener on a radio-supplied string.
|
||||
advert_name: format!("Archy-{}", did.get(8..16.min(did.len())).unwrap_or(did)),
|
||||
did: Some(did.to_string()),
|
||||
pubkey_hex: Some(ed_pubkey_hex.to_string()),
|
||||
// The advert signature was verified above, so this is an authenticated
|
||||
// archipelago identity. Bind it separately so a later refresh_contacts
|
||||
// (which rewrites pubkey_hex to the firmware routing key) can't drop it.
|
||||
arch_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,
|
||||
last_advert: 0,
|
||||
// We just heard this peer's identity advert, so it's reachable.
|
||||
reachable: true,
|
||||
// PKC capability is tracked by the radio driver's get_contacts(), not
|
||||
// known at identity-advert time.
|
||||
pkc_capable: false,
|
||||
lat: None,
|
||||
lon: None,
|
||||
// Set only by an explicit LightningInfo advert, never inferred.
|
||||
lightning_uri: None,
|
||||
};
|
||||
|
||||
let is_new = {
|
||||
let mut peers = state.peers.write().await;
|
||||
let is_new = !peers.contains_key(&contact_id);
|
||||
if let Some(existing) = peers.get(&contact_id) {
|
||||
// This id is shared with the federation-seeded row for the same
|
||||
// node (that's the point — identity adverts MERGE, not duplicate).
|
||||
// The wholesale insert below must not stomp the federation row's
|
||||
// real node name with our synthetic "Archy-…" placeholder — with
|
||||
// Reticulum re-emitting identity adverts every announce tick,
|
||||
// that renamed every federated contact once a minute. Same for a
|
||||
// known position: keep it rather than nulling it out.
|
||||
if !existing.advert_name.trim().is_empty()
|
||||
&& !existing.advert_name.starts_with("Archy-")
|
||||
{
|
||||
peer.advert_name = existing.advert_name.clone();
|
||||
}
|
||||
if peer.lat.is_none() {
|
||||
peer.lat = existing.lat;
|
||||
peer.lon = existing.lon;
|
||||
}
|
||||
// Same hazard as the name and position above: an identity advert
|
||||
// carries no Lightning datum, and Reticulum re-emits one every
|
||||
// announce tick, so a wholesale insert would drop a peer out of the
|
||||
// channel-open picker about once a minute. The URI comes only from
|
||||
// an explicit LightningInfo advert — preserve it.
|
||||
if peer.lightning_uri.is_none() {
|
||||
peer.lightning_uri = existing.lightning_uri.clone();
|
||||
}
|
||||
}
|
||||
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).
|
||||
#[allow(dead_code)]
|
||||
pub(super) 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 radio_transport = radio_transport_label(state.status.read().await.device_type);
|
||||
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,
|
||||
transport: Some(radio_transport.to_string()),
|
||||
message_type: "text".to_string(),
|
||||
typed_payload: None,
|
||||
sender_pubkey: None,
|
||||
sender_seq: None,
|
||||
};
|
||||
|
||||
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));
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,537 @@
|
||||
//! Inbound frame dispatcher — routes device frames to the appropriate handler.
|
||||
|
||||
use super::super::message_types::TypedEnvelope;
|
||||
use super::super::protocol;
|
||||
use super::decode::{
|
||||
handle_identity_received, is_mc_chunk_frame, resolve_peer, store_plain_message,
|
||||
store_plain_message_with_encryption, try_base64_typed, try_chunk_reassemble,
|
||||
try_decrypt_base64, try_decrypt_ratchet_base64, update_peer_snr,
|
||||
};
|
||||
use super::dispatch::handle_typed_message;
|
||||
use super::MeshState;
|
||||
use std::sync::Arc;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
/// Handle a single inbound frame from the device.
|
||||
/// Returns `true` if contacts should be refreshed from the device.
|
||||
pub(super) 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,
|
||||
data_len = frame.data.len(),
|
||||
"Contact discovery event — refreshing contacts"
|
||||
);
|
||||
// Auto-import: a PUSH_CONTACT_ADVERT (0x80) carries the 32-byte
|
||||
// pubkey of a node we just heard. If it isn't already a contact,
|
||||
// add it to the firmware table so it shows up immediately — no
|
||||
// flood-advert dance required. (PUSH_NEW_CONTACT/0x8A is already
|
||||
// added by the firmware, so we skip it.)
|
||||
if frame.code == protocol::PUSH_CONTACT_ADVERT && frame.data.len() >= 32 {
|
||||
let mut pubkey = [0u8; 32];
|
||||
pubkey.copy_from_slice(&frame.data[..32]);
|
||||
let pk_hex = hex::encode(pubkey);
|
||||
let known = state
|
||||
.peers
|
||||
.read()
|
||||
.await
|
||||
.values()
|
||||
.any(|p| p.pubkey_hex.as_deref() == Some(pk_hex.as_str()));
|
||||
if !known {
|
||||
let _ = state
|
||||
.send_cmd(super::MeshCommand::AddContact {
|
||||
pubkey,
|
||||
name: String::new(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
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 | protocol::RESP_CONTACT_MSG_V3_E2E => {
|
||||
// Direct message received (v3 format) — check for typed envelope first
|
||||
match protocol::parse_contact_msg_v3_raw(&frame.data) {
|
||||
Ok((sender_prefix, payload, snr)) => {
|
||||
if !payload.is_empty() {
|
||||
let encrypted = frame.code == protocol::RESP_CONTACT_MSG_V3_E2E;
|
||||
let (contact_id, name) = resolve_peer(state, &sender_prefix).await;
|
||||
update_peer_snr(state, contact_id, snr as f32).await;
|
||||
if TypedEnvelope::is_typed(&payload) {
|
||||
handle_typed_message(&payload, contact_id, &name, state).await;
|
||||
} else if let Some(decoded) = try_base64_typed(&payload) {
|
||||
handle_typed_message(&decoded, contact_id, &name, state).await;
|
||||
} else if let Some(decoded) =
|
||||
try_decrypt_ratchet_base64(&payload, contact_id, state).await
|
||||
{
|
||||
handle_typed_message(&decoded, contact_id, &name, state).await;
|
||||
} else if let Some(decoded) =
|
||||
try_decrypt_base64(&payload, contact_id, state).await
|
||||
{
|
||||
handle_typed_message(&decoded, contact_id, &name, state).await;
|
||||
} else if let Some(decoded) =
|
||||
try_chunk_reassemble(&payload, contact_id, state).await
|
||||
{
|
||||
handle_typed_message(&decoded, contact_id, &name, state).await;
|
||||
} else if !payload.starts_with(b"MC") {
|
||||
let text = String::from_utf8_lossy(&payload).to_string();
|
||||
store_plain_message_with_encryption(
|
||||
state, contact_id, &name, &text, encrypted,
|
||||
)
|
||||
.await;
|
||||
info!(from = %sender_prefix, "Received mesh DM (v3)");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => warn!("Failed to parse v3 message: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
protocol::RESP_CONTACT_MSG => {
|
||||
// Direct message received (v1 format)
|
||||
match protocol::parse_contact_msg_v1_raw(&frame.data) {
|
||||
Ok((sender_prefix, payload)) => {
|
||||
if !payload.is_empty() {
|
||||
let (contact_id, name) = resolve_peer(state, &sender_prefix).await;
|
||||
if TypedEnvelope::is_typed(&payload) {
|
||||
handle_typed_message(&payload, contact_id, &name, state).await;
|
||||
} else if let Some(decoded) = try_base64_typed(&payload) {
|
||||
handle_typed_message(&decoded, contact_id, &name, state).await;
|
||||
} else if let Some(decoded) =
|
||||
try_decrypt_ratchet_base64(&payload, contact_id, state).await
|
||||
{
|
||||
handle_typed_message(&decoded, contact_id, &name, state).await;
|
||||
} else if let Some(decoded) =
|
||||
try_decrypt_base64(&payload, contact_id, state).await
|
||||
{
|
||||
handle_typed_message(&decoded, contact_id, &name, state).await;
|
||||
} else if let Some(decoded) =
|
||||
try_chunk_reassemble(&payload, contact_id, state).await
|
||||
{
|
||||
handle_typed_message(&decoded, contact_id, &name, state).await;
|
||||
} else if !payload.starts_with(b"MC") {
|
||||
let text = String::from_utf8_lossy(&payload).to_string();
|
||||
store_plain_message(state, contact_id, &name, &text).await;
|
||||
info!(from = %sender_prefix, "Received mesh DM (v1)");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => warn!("Failed to parse v1 message: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
protocol::RESP_CHANNEL_MSG_V3 => {
|
||||
// Channel broadcast received (v3) — check for typed envelope
|
||||
match protocol::parse_channel_msg_v3_raw(&frame.data) {
|
||||
Ok((channel_idx, payload)) => {
|
||||
if !payload.is_empty() {
|
||||
handle_channel_payload(
|
||||
state,
|
||||
channel_idx,
|
||||
&payload,
|
||||
our_x25519_secret,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
Err(e) => warn!("Failed to parse v3 channel message: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
protocol::RESP_CHANNEL_MSG => {
|
||||
// Channel broadcast received (v1)
|
||||
match protocol::parse_channel_msg_v1_raw(&frame.data) {
|
||||
Ok((channel_idx, payload)) => {
|
||||
if !payload.is_empty() {
|
||||
handle_channel_payload(
|
||||
state,
|
||||
channel_idx,
|
||||
&payload,
|
||||
our_x25519_secret,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
Err(e) => warn!("Failed to parse channel message: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
// Synthetic Meshtastic channel broadcast that carries its sender:
|
||||
// `[channel_idx: u8][sender_pubkey_prefix: 6 bytes][text…]`. Resolve the
|
||||
// sender to a friendly name, then file the message under the channel
|
||||
// thread attributed to them — this is what makes the default public
|
||||
// LongFast channel actually show inbound traffic (and who sent it).
|
||||
protocol::RESP_MESHTASTIC_CHANNEL_TEXT => {
|
||||
if frame.data.len() > 7 {
|
||||
let channel_idx = frame.data[0];
|
||||
let sender_prefix_hex = hex::encode(&frame.data[1..7]);
|
||||
let payload = frame.data[7..].to_vec();
|
||||
if !payload.is_empty() {
|
||||
let (_cid, name) = resolve_peer(state, &sender_prefix_hex).await;
|
||||
handle_channel_payload(
|
||||
state,
|
||||
channel_idx,
|
||||
&payload,
|
||||
our_x25519_secret,
|
||||
Some(name),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/// Process a channel-broadcast payload. If the payload carries the
|
||||
/// DM-via-channel marker and the destination prefix matches any of our
|
||||
/// local mesh peer pubkeys (or we can't tell), the inner payload is
|
||||
/// dispatched through the direct-message path so it lands in the right
|
||||
/// chat. Otherwise it's handled as a normal channel text/typed message.
|
||||
async fn handle_channel_payload(
|
||||
state: &Arc<MeshState>,
|
||||
channel_idx: u8,
|
||||
payload: &[u8],
|
||||
our_x25519_secret: &[u8; 32],
|
||||
// When the transport knows who sent this channel broadcast (Meshtastic
|
||||
// packets carry the originating node), the plain-text/typed message is filed
|
||||
// under the channel thread but attributed to this sender name. Meshcore
|
||||
// channel frames carry no sender, so they pass `None` and fall back to a
|
||||
// generic "Channel N" label.
|
||||
sender_name: Option<String>,
|
||||
) {
|
||||
// DM-via-channel wrapper (text form): the channel text carries an
|
||||
// ASCII "@DM:<base64>" token somewhere in the body. We locate the
|
||||
// marker anywhere in the payload (the firmware auto-prepends the
|
||||
// sender's `"<advert_name>: "` before our bytes, so the marker is
|
||||
// not at offset 0), then base64-decode to get `[dest(6)][inner]`.
|
||||
// Using a text marker + base64 avoids the C-string NUL truncation
|
||||
// that broke the previous raw-byte wrapper on the firmware's side.
|
||||
let text_view = std::str::from_utf8(payload).ok();
|
||||
|
||||
// v2 format: `@DM2:` + base64(`[dest(6)][sender_arch(6)][inner…]`).
|
||||
// Carries a sender prefix so we can attribute the message to the real
|
||||
// sender's contact_id instead of guessing — fixes the long-standing
|
||||
// bug where every inbound DM-via-channel was misattributed to whichever
|
||||
// `Archy-*` peer happened to have the lowest contact_id in the firmware
|
||||
// contact table (the third-device thread on .39/.76).
|
||||
if let Some(idx) = text_view.and_then(|t| t.find("@DM2:")) {
|
||||
use base64::Engine;
|
||||
let b64 = &text_view.unwrap()[idx + 5..];
|
||||
let b64 = b64.trim_end_matches(|c: char| c == '\0' || c.is_whitespace());
|
||||
match base64::engine::general_purpose::STANDARD.decode(b64) {
|
||||
Ok(body) if body.len() >= 12 => {
|
||||
let dest_prefix: [u8; 6] = body[..6].try_into().expect("sliced 6 bytes");
|
||||
let sender_prefix: [u8; 6] = body[6..12].try_into().expect("sliced 6 bytes");
|
||||
let inner_vec = body[12..].to_vec();
|
||||
let inner: &[u8] = &inner_vec;
|
||||
let addressed_to_us = dest_prefix_is_us(state, &dest_prefix).await;
|
||||
if !addressed_to_us {
|
||||
debug!(
|
||||
dest = %hex::encode(dest_prefix),
|
||||
inner_len = inner.len(),
|
||||
"Dropping DM2-via-channel (not for us)"
|
||||
);
|
||||
return;
|
||||
}
|
||||
info!(
|
||||
dest = %hex::encode(dest_prefix),
|
||||
sender = %hex::encode(sender_prefix),
|
||||
inner_len = inner.len(),
|
||||
channel = channel_idx,
|
||||
"Received DM2 via channel (addressed to us)"
|
||||
);
|
||||
let (contact_id, name) =
|
||||
resolve_sender_by_arch_prefix(state, &sender_prefix, &dest_prefix).await;
|
||||
if TypedEnvelope::is_typed(inner) {
|
||||
handle_typed_message(inner, contact_id, &name, state).await;
|
||||
} else if let Some(decoded) = try_base64_typed(inner) {
|
||||
handle_typed_message(&decoded, contact_id, &name, state).await;
|
||||
} else if let Some(decoded) = try_chunk_reassemble(inner, contact_id, state).await {
|
||||
if TypedEnvelope::is_typed(&decoded) {
|
||||
handle_typed_message(&decoded, contact_id, &name, state).await;
|
||||
} else {
|
||||
let text = String::from_utf8_lossy(&decoded).to_string();
|
||||
store_plain_message(state, contact_id, &name, &text).await;
|
||||
}
|
||||
} else if is_mc_chunk_frame(inner) {
|
||||
// Chunk buffered for reassembly — do not store the raw
|
||||
// `MCxxyyzz…` frame as its own plaintext message. Wait
|
||||
// for the rest of the chunks to arrive.
|
||||
debug!(inner_len = inner.len(), "DM2 chunk buffered");
|
||||
} else {
|
||||
let text = String::from_utf8_lossy(inner).to_string();
|
||||
store_plain_message(state, contact_id, &name, &text).await;
|
||||
}
|
||||
return;
|
||||
}
|
||||
Ok(_) => debug!("DM2-via-channel b64 decoded too short"),
|
||||
Err(e) => debug!("DM2-via-channel b64 decode failed: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(idx) = text_view.and_then(|t| t.find("@DM:")) {
|
||||
use base64::Engine;
|
||||
let b64 = &text_view.unwrap()[idx + 4..];
|
||||
// Trim any trailing whitespace / NULs that firmware may append.
|
||||
let b64 = b64.trim_end_matches(|c: char| c == '\0' || c.is_whitespace());
|
||||
match base64::engine::general_purpose::STANDARD.decode(b64) {
|
||||
Ok(body) if body.len() >= 6 => {
|
||||
let dest_prefix: [u8; 6] = body[..6].try_into().expect("sliced 6 bytes");
|
||||
let inner_vec = body[6..].to_vec();
|
||||
let inner: &[u8] = &inner_vec;
|
||||
let addressed_to_us = dest_prefix_is_us(state, &dest_prefix).await;
|
||||
if !addressed_to_us {
|
||||
debug!(
|
||||
dest = %hex::encode(dest_prefix),
|
||||
inner_len = inner.len(),
|
||||
"Dropping DM-via-channel (not for us)"
|
||||
);
|
||||
return;
|
||||
}
|
||||
info!(
|
||||
dest = %hex::encode(dest_prefix),
|
||||
inner_len = inner.len(),
|
||||
channel = channel_idx,
|
||||
"Received DM via channel (addressed to us)"
|
||||
);
|
||||
let (contact_id, name) = resolve_counterparty(state, &dest_prefix).await;
|
||||
if TypedEnvelope::is_typed(inner) {
|
||||
handle_typed_message(inner, contact_id, &name, state).await;
|
||||
} else if let Some(decoded) = try_base64_typed(inner) {
|
||||
handle_typed_message(&decoded, contact_id, &name, state).await;
|
||||
} else if let Some(decoded) = try_chunk_reassemble(inner, contact_id, state).await {
|
||||
if TypedEnvelope::is_typed(&decoded) {
|
||||
handle_typed_message(&decoded, contact_id, &name, state).await;
|
||||
} else {
|
||||
let text = String::from_utf8_lossy(&decoded).to_string();
|
||||
store_plain_message(state, contact_id, &name, &text).await;
|
||||
}
|
||||
} else if is_mc_chunk_frame(inner) {
|
||||
debug!(inner_len = inner.len(), "DM chunk buffered");
|
||||
} else {
|
||||
let text = String::from_utf8_lossy(inner).to_string();
|
||||
store_plain_message(state, contact_id, &name, &text).await;
|
||||
}
|
||||
return;
|
||||
}
|
||||
Ok(_) => debug!("DM-via-channel b64 decoded too short"),
|
||||
Err(e) => debug!("DM-via-channel b64 decode failed: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy raw-byte wrapper kept as a defensive no-op.
|
||||
if payload.len() >= 7 && payload[0] == protocol::DM_VIA_CHANNEL_MARKER {
|
||||
let dest_prefix: [u8; 6] = payload[1..7].try_into().expect("sliced 6 bytes");
|
||||
let inner = &payload[7..];
|
||||
|
||||
// If the destination prefix matches a contact we know about that
|
||||
// isn't ourselves, forward it (the channel broadcast is shared by
|
||||
// everyone but only the intended recipient should treat it as a
|
||||
// DM). We compare against our mesh contacts — if the prefix is
|
||||
// not one of our known peers AND not our self_node_id, we drop
|
||||
// because it's someone else's DM bouncing through the mesh.
|
||||
let addressed_to_us = dest_prefix_is_us(state, &dest_prefix).await;
|
||||
if !addressed_to_us {
|
||||
debug!(
|
||||
dest = %hex::encode(dest_prefix),
|
||||
inner_len = inner.len(),
|
||||
"Dropping DM-via-channel (not for us)"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
info!(
|
||||
dest = %hex::encode(dest_prefix),
|
||||
inner_len = inner.len(),
|
||||
channel = channel_idx,
|
||||
"Received DM via channel (addressed to us)"
|
||||
);
|
||||
|
||||
// Treat the inner payload exactly the same as we'd treat a direct
|
||||
// unicast frame — resolve the sender from our peer table (we
|
||||
// don't know the sender here, so use a synthetic-ish contact_id
|
||||
// derived from the first peer whose dest_prefix != us), and
|
||||
// dispatch through the typed / base64 / plain-text ladder.
|
||||
// Because the wrapped frame doesn't carry the sender prefix, we
|
||||
// pick "the other side of the conversation" — there are only two
|
||||
// known archipelago peers in the radio neighborhood, so the
|
||||
// sender is whoever isn't us. For the typical 2-node setup this
|
||||
// is correct. When there are more peers, upper layers (typed
|
||||
// envelope sender_pubkey) will carry the real sender identity.
|
||||
let (contact_id, name) = resolve_counterparty(state, &dest_prefix).await;
|
||||
if TypedEnvelope::is_typed(inner) {
|
||||
handle_typed_message(inner, contact_id, &name, state).await;
|
||||
} else if let Some(decoded) = try_base64_typed(inner) {
|
||||
handle_typed_message(&decoded, contact_id, &name, state).await;
|
||||
} else if let Some(decoded) = try_chunk_reassemble(inner, contact_id, state).await {
|
||||
// Reassembled a chunked MC-framed payload
|
||||
if TypedEnvelope::is_typed(&decoded) {
|
||||
handle_typed_message(&decoded, contact_id, &name, state).await;
|
||||
} else {
|
||||
let text = String::from_utf8_lossy(&decoded).to_string();
|
||||
store_plain_message(state, contact_id, &name, &text).await;
|
||||
}
|
||||
} else {
|
||||
let text = String::from_utf8_lossy(inner).to_string();
|
||||
store_plain_message(state, contact_id, &name, &text).await;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Archipelago identity broadcast (`ARCHY:`): upsert the sender's real
|
||||
// archipelago identity (DID + ed25519 + x25519) so trust-gating and
|
||||
// encrypted DMs work over BOTH meshcore and Meshtastic — the latter
|
||||
// otherwise only exposes synthetic node keys. Keyed by the archipelago
|
||||
// pubkey (federation_peer_contact_id) so it MERGES with the federation-
|
||||
// seeded peer instead of creating a duplicate chat thread. Not stored as
|
||||
// a chat message.
|
||||
if let Ok(text) = std::str::from_utf8(payload) {
|
||||
if let Some((did, ed_hex, x_hex)) = super::super::protocol::parse_identity_broadcast(text) {
|
||||
// Ignore our own identity echoed back by the radio/channel.
|
||||
if ed_hex.eq_ignore_ascii_case(&state.our_ed_pubkey_hex) {
|
||||
return;
|
||||
}
|
||||
let contact_id = super::super::federation_peer_contact_id(&ed_hex);
|
||||
handle_identity_received(
|
||||
contact_id,
|
||||
0,
|
||||
&did,
|
||||
&ed_hex,
|
||||
&x_hex,
|
||||
state,
|
||||
our_x25519_secret,
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Regular channel broadcast (not DM-wrapped). File it under the channel
|
||||
// thread (contact_id = u32::MAX - idx) but label it with the real sender
|
||||
// when the transport gave us one (Meshtastic), so the channel view shows who
|
||||
// said what. Meshcore frames have no sender → generic "Channel N".
|
||||
let chan_contact_id = u32::MAX - (channel_idx as u32);
|
||||
let chan_name = sender_name.unwrap_or_else(|| format!("Channel {}", channel_idx));
|
||||
if TypedEnvelope::is_typed(payload) {
|
||||
handle_typed_message(payload, chan_contact_id, &chan_name, state).await;
|
||||
} else {
|
||||
let text = String::from_utf8_lossy(payload).to_string();
|
||||
store_plain_message(state, chan_contact_id, &chan_name, &text).await;
|
||||
info!(channel = channel_idx, sender = %chan_name, "Received mesh channel message");
|
||||
}
|
||||
}
|
||||
|
||||
/// Return true if the given 6-byte pubkey prefix matches our own meshcore
|
||||
/// firmware pubkey. We don't currently track our own firmware pubkey in
|
||||
/// state (the SELF_INFO parse only pulls the node_id), so this falls back
|
||||
/// to "not any of our known peers" — i.e. if the prefix isn't one of the
|
||||
/// OTHER contacts in our mesh contact table, it must be us. That holds
|
||||
/// for the typical 2-node-plus-repeaters topology and is good enough to
|
||||
/// filter out DMs clearly addressed to someone else.
|
||||
async fn dest_prefix_is_us(state: &Arc<MeshState>, dest_prefix: &[u8; 6]) -> bool {
|
||||
let peers = state.peers.read().await;
|
||||
for p in peers.values() {
|
||||
if let Some(hex_pk) = p.pubkey_hex.as_deref() {
|
||||
if hex_pk.len() >= 12 {
|
||||
if let Ok(bytes) = hex::decode(&hex_pk[..12]) {
|
||||
if bytes.len() == 6 && bytes[..] == dest_prefix[..] {
|
||||
// It matches a peer we know — so it's NOT for us
|
||||
// (we'd never have a peer row for ourselves).
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Look up the contact_id for a `@DM2:` sender by matching the 6-byte
|
||||
/// archipelago ed25519 prefix against `state.peers`. Federation-seeded
|
||||
/// peers (added by `mesh::upsert_federation_peer` at startup and after
|
||||
/// every federation mutation) carry the archipelago key in `pubkey_hex`,
|
||||
/// so the prefix lookup resolves to the unified federation chat thread —
|
||||
/// which is exactly where we want both LoRa-arrived and Tor-arrived
|
||||
/// messages from the same peer to land. If no peer matches (sender isn't
|
||||
/// in our federation list yet), we fall through to `resolve_counterparty`
|
||||
/// so the message still lands somewhere visible rather than being dropped.
|
||||
async fn resolve_sender_by_arch_prefix(
|
||||
state: &Arc<MeshState>,
|
||||
sender_arch_prefix: &[u8; 6],
|
||||
dest_prefix: &[u8; 6],
|
||||
) -> (u32, String) {
|
||||
let prefix_hex = hex::encode(sender_arch_prefix);
|
||||
let peers = state.peers.read().await;
|
||||
if let Some(p) = peers.values().find(|p| {
|
||||
p.pubkey_hex
|
||||
.as_deref()
|
||||
.map(|k| k.starts_with(&prefix_hex))
|
||||
.unwrap_or(false)
|
||||
}) {
|
||||
return (p.contact_id, p.advert_name.clone());
|
||||
}
|
||||
drop(peers);
|
||||
resolve_counterparty(state, dest_prefix).await
|
||||
}
|
||||
|
||||
/// Pick a "counterparty" contact_id when dispatching a DM-via-channel
|
||||
/// whose sender we don't otherwise know. We look for any archipelago
|
||||
/// (type-1, "Archy-*") peer in the contact table whose prefix ISN'T the
|
||||
/// destination — that's "the other side." Falls back to contact_id=0
|
||||
/// when nothing matches.
|
||||
async fn resolve_counterparty(state: &Arc<MeshState>, dest_prefix: &[u8; 6]) -> (u32, String) {
|
||||
// Collect every `Archy-*` peer whose meshcore pubkey-prefix differs
|
||||
// from dest_prefix (dest is ours, so "not dest" = "not us"), then
|
||||
// pick the lowest contact_id. HashMap iteration order is randomized,
|
||||
// so sorting is required to avoid flapping between peers across
|
||||
// receives (which was producing doubled chat threads in the UI).
|
||||
let peers = state.peers.read().await;
|
||||
let mut candidates: Vec<(u32, String)> = peers
|
||||
.values()
|
||||
.filter(|p| p.advert_name.starts_with("Archy-"))
|
||||
.filter_map(|p| {
|
||||
let hex_pk = p.pubkey_hex.as_deref()?;
|
||||
if hex_pk.len() < 12 {
|
||||
return None;
|
||||
}
|
||||
let bytes = hex::decode(&hex_pk[..12]).ok()?;
|
||||
if bytes.len() == 6 && bytes[..] != dest_prefix[..] {
|
||||
Some((p.contact_id, p.advert_name.clone()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
candidates.sort_by_key(|(id, _)| *id);
|
||||
candidates
|
||||
.into_iter()
|
||||
.next()
|
||||
.unwrap_or((0, "dm-via-channel".to_string()))
|
||||
}
|
||||
@@ -0,0 +1,718 @@
|
||||
//! 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
|
||||
|
||||
mod assist;
|
||||
mod bitcoin;
|
||||
mod decode;
|
||||
pub(crate) mod dispatch;
|
||||
mod frames;
|
||||
mod node_cmd;
|
||||
mod session;
|
||||
|
||||
pub(crate) use session::{probe_device, DeviceProbe};
|
||||
|
||||
use super::types::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::{broadcast, mpsc, RwLock};
|
||||
use tracing::{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);
|
||||
|
||||
/// Backlog #12 (provisioning robustness): if we haven't successfully received
|
||||
/// ANY frame in this long, treat the serial link as stalled and force a
|
||||
/// reconnect — the write-side `consecutive_write_failures` counter is blind
|
||||
/// to a receive-only stall (writes can keep succeeding while the radio's
|
||||
/// stopped streaming inbound, e.g. the FROM_RADIO_REBOOTED-without-recovery
|
||||
/// case meshtastic.rs already has a targeted, immediate fix for — this
|
||||
/// watchdog is just the backstop for a device that goes silent WITHOUT
|
||||
/// emitting that notification).
|
||||
///
|
||||
/// 5 minutes was originally chosen on the (wrong) assumption that the 60s
|
||||
/// advert / 10s sync cadence implies *received* traffic — those are our own
|
||||
/// OUTBOUND cadences and say nothing about what peers send us. A quiet mesh
|
||||
/// (no peer transmitting, or Reticulum/LXMF's point-to-point store-and-
|
||||
/// forward model with no broadcast echo) can be legitimately RX-silent for
|
||||
/// long stretches with the link perfectly healthy; at 300s this forced a
|
||||
/// full auto-detect reconnect (visible in the UI as "Connecting…") every
|
||||
/// ~5 minutes on otherwise-idle nodes. 30 minutes still catches a wedged
|
||||
/// device in reasonable time without false-triggering on normal mesh quiet.
|
||||
const RX_STALL_TIMEOUT: Duration = Duration::from_secs(1800);
|
||||
|
||||
/// Maximum stored messages (circular buffer).
|
||||
const MAX_MESSAGES: usize = 100;
|
||||
|
||||
/// On-disk message-history file under `data_dir/` (written by
|
||||
/// `spawn_message_persister`, restored by `load_persisted_messages`).
|
||||
const MESSAGES_FILE: &str = "mesh-messages.json";
|
||||
|
||||
/// Serialized form of the persisted history. `send_seqs` rides along because
|
||||
/// the per-target outbound sequence counters must survive restarts too:
|
||||
/// receivers dedup on (sender_pubkey, sender_seq), so a node that reboots and
|
||||
/// starts counting from 1 again has its first messages silently dropped by
|
||||
/// every peer that already saw those sequence numbers.
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
struct PersistedMessages {
|
||||
messages: Vec<MeshMessage>,
|
||||
#[serde(default)]
|
||||
send_seqs: HashMap<u32, u64>,
|
||||
}
|
||||
|
||||
/// Check if two ISO8601 timestamps are within N seconds of each other.
|
||||
fn within_seconds_iso(ts1: &str, ts2: &str, secs: i64) -> bool {
|
||||
use chrono::DateTime;
|
||||
let a = DateTime::parse_from_rfc3339(ts1).ok();
|
||||
let b = DateTime::parse_from_rfc3339(ts2).ok();
|
||||
match (a, b) {
|
||||
(Some(a), Some(b)) => (a - b).num_seconds().unsigned_abs() < secs as u64,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Initial delay before reconnection attempt after device disconnect.
|
||||
const RECONNECT_DELAY_INIT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Maximum reconnect delay (cap for exponential backoff).
|
||||
const RECONNECT_DELAY_MAX: Duration = Duration::from_secs(60);
|
||||
|
||||
/// Minimum time a session must run before we trust it enough to reset
|
||||
/// backoff to the minimum. Without this gate, a device that connects then
|
||||
/// fails again within a couple of seconds (e.g. mid-boot-loop) never backs
|
||||
/// off — every retry immediately re-opens the port, which toggles DTR/RTS
|
||||
/// (resets many ESP32 boards' MCU on native-USB and CP2102/CH340
|
||||
/// auto-reset-circuit boards alike), turning a device that's merely
|
||||
/// unstable into a self-sustaining boot loop that outlasts whatever
|
||||
/// triggered the original instability. Confirmed live 2026-07-23: a Heltec
|
||||
/// V3 stuck retrying every ~5-15s for 5+ minutes after a failed firmware
|
||||
/// flash left it in a marginal state.
|
||||
const STABLE_SESSION_THRESHOLD: Duration = Duration::from_secs(20);
|
||||
|
||||
/// Number of consecutive write failures before we consider the device dead
|
||||
/// and trigger a reconnection cycle.
|
||||
const MAX_CONSECUTIVE_WRITE_FAILURES: u32 = 3;
|
||||
|
||||
/// 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>,
|
||||
},
|
||||
/// Send pre-encoded binary (TypedEnvelope wire bytes) to a peer.
|
||||
SendRaw {
|
||||
dest_pubkey_prefix: [u8; 6],
|
||||
payload: Vec<u8>,
|
||||
},
|
||||
/// Send pre-encoded binary over a dedicated Reticulum RNS Resource
|
||||
/// transfer instead of the small inline-chunk path — Reticulum-only, see
|
||||
/// `MeshRadioDevice::send_resource`. Used for large attachments
|
||||
/// (compressed photos, voice messages) that exceed the small-message cap
|
||||
/// but fit a sane LoRa-Resource budget; routing decision is made by the
|
||||
/// RPC layer (`mesh.transport-advice`'s `"resource-mesh"` tier).
|
||||
SendResource {
|
||||
dest_pubkey_prefix: [u8; 6],
|
||||
payload: Vec<u8>,
|
||||
},
|
||||
/// Native LXMF `FIELD_IMAGE` send — Reticulum-only, for a stock
|
||||
/// (non-archy) peer that can't decode our typed envelope. See
|
||||
/// `MeshRadioDevice::send_native_image`.
|
||||
SendNativeImage {
|
||||
dest_pubkey_prefix: [u8; 6],
|
||||
mime: String,
|
||||
bytes: Vec<u8>,
|
||||
caption: Option<String>,
|
||||
},
|
||||
/// Send PLAIN text as one or more native meshcore DMs to a stock client
|
||||
/// (e.g. a phone). Long text is split into multiple readable plain messages
|
||||
/// — never MC-chunked — because stock clients can't reassemble archy's
|
||||
/// chunk framing. Used for chat/AI replies to non-archipelago contacts.
|
||||
SendNativeText {
|
||||
dest_pubkey_prefix: [u8; 6],
|
||||
payload: Vec<u8>,
|
||||
},
|
||||
/// Broadcast pre-encoded binary on a mesh channel.
|
||||
BroadcastChannel {
|
||||
channel: u8,
|
||||
payload: Vec<u8>,
|
||||
},
|
||||
SendAdvert,
|
||||
/// Reboot the locally-connected radio firmware to recover a wedged /
|
||||
/// RX-deaf radio. Meshtastic: firmware reboot command. Reticulum: the
|
||||
/// sidecar daemon is restarted (radio re-detected + reconfigured).
|
||||
/// MeshCore: unsupported, and says so. `reply` (when present) carries
|
||||
/// the real outcome to the RPC caller — the buttons used to be
|
||||
/// fire-and-forget `warn!`s, i.e. no feedback ever reached the UI
|
||||
/// (operator, 2026-08-06).
|
||||
RebootRadio {
|
||||
seconds: i64,
|
||||
reply: Option<tokio::sync::oneshot::Sender<Result<String, String>>>,
|
||||
},
|
||||
/// Query the live RNode radio state (Reticulum-only): the sidecar's
|
||||
/// radio-confirmed parameters, for the LoRa settings panel's current
|
||||
/// values + apply read-back.
|
||||
QueryRadioState {
|
||||
reply: tokio::sync::oneshot::Sender<Result<serde_json::Value, String>>,
|
||||
},
|
||||
/// Re-fetch contact list from the radio device.
|
||||
RefreshContacts,
|
||||
/// Delete a contact from the firmware table (clear-all / unreachable wipe).
|
||||
RemoveContact {
|
||||
pubkey: [u8; 32],
|
||||
},
|
||||
/// Import/add a heard advert as a firmware contact so it shows up without
|
||||
/// needing a flood advert. Name may be empty (firmware fills from advert).
|
||||
AddContact {
|
||||
pubkey: [u8; 32],
|
||||
name: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// 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>,
|
||||
/// Command channel sender. Wrapped in RwLock so `MeshService::stop()`
|
||||
/// can swap it for a fresh channel when the listener task drains the
|
||||
/// old receiver — without this, a disable→enable cycle fails with
|
||||
/// "Command channel already consumed" on the second start().
|
||||
pub cmd_tx: RwLock<mpsc::Sender<MeshCommand>>,
|
||||
next_message_id: RwLock<u64>,
|
||||
/// Per-contact outbound sequence counter. Increments on every typed
|
||||
/// envelope we send to a given peer so the receiver (and anyone else
|
||||
/// forwarding/reacting) can build a stable MessageKey = (our_pubkey, seq).
|
||||
/// Channel broadcasts use contact_id = 0 as a shared counter.
|
||||
next_send_seq: RwLock<HashMap<u32, u64>>,
|
||||
/// Block header cache — populated when receiving headers from internet-connected peers.
|
||||
pub block_header_cache: Arc<super::bitcoin_relay::BlockHeaderCache>,
|
||||
/// Relay tracker — stores completed relay results for frontend polling.
|
||||
pub relay_tracker: Option<Arc<super::bitcoin_relay::RelayTracker>>,
|
||||
/// Steganography mode for outgoing/incoming messages.
|
||||
pub stego_mode: super::steganography::SteganographyMode,
|
||||
/// Chunk reassembly buffer for multi-frame messages.
|
||||
pub(crate) chunk_buffer: RwLock<HashMap<(u32, u8), ChunkAssembly>>,
|
||||
/// Double Ratchet session manager for forward-secret encryption.
|
||||
pub session_manager: Arc<super::session::SessionManager>,
|
||||
/// Whether to encrypt directed relay messages (config toggle for rollback).
|
||||
pub encrypt_relay: bool,
|
||||
/// Whether to accept inbound Bitcoin block headers from peers (issue #28).
|
||||
pub receive_block_headers: bool,
|
||||
/// Last-seen presence heartbeats per peer pubkey hex: (status, last_active_epoch, received_at).
|
||||
pub presence: RwLock<HashMap<String, (String, u32, u64)>>,
|
||||
/// Contacts store — alias/notes/pinned/blocked per peer pubkey hex.
|
||||
pub contacts: RwLock<HashMap<String, ContactEntry>>,
|
||||
/// Our archipelago ed25519 public key (hex). Used by outbound DM-via-channel
|
||||
/// to embed a sender prefix on the wire so receivers can attribute inbound
|
||||
/// messages to the correct contact_id even when multiple `Archy-*` peers
|
||||
/// share the LoRa channel.
|
||||
pub our_ed_pubkey_hex: String,
|
||||
/// Shared blob store for writing received inline attachments. Populated
|
||||
/// by `RpcHandler` after startup so the mesh listener can persist inline
|
||||
/// file bytes into the same store the HTTP layer serves.
|
||||
pub blob_store: RwLock<Option<Arc<crate::blobs::BlobStore>>>,
|
||||
/// The assistant's shared tool loop, reached through the same RpcHandler
|
||||
/// every caller dispatches on. Populated by the server after startup
|
||||
/// (same pattern as blob_store). `None` on early boot → the legacy
|
||||
/// bare-LLM path in assist.rs answers instead.
|
||||
pub assistant_handler: RwLock<Option<Arc<crate::api::rpc::RpcHandler>>>,
|
||||
/// Firmware-pubkey-hex of radio contacts the user has chosen to ignore
|
||||
/// (via mesh.clear-all). `refresh_contacts` skips any device contact
|
||||
/// whose pubkey is in this set, preventing the meshcore firmware's
|
||||
/// persistent contact table from regenerating rows the user just
|
||||
/// wiped. Persisted to `mesh-ignored-radio-contacts.json`.
|
||||
pub radio_contact_blocklist: RwLock<HashSet<String>>,
|
||||
/// Mesh-AI assistant settings (issue #50): whether this node answers
|
||||
/// AssistQuery messages with its local LLM, and who may ask. Live-updatable
|
||||
/// so the UI toggle applies without restarting the listener.
|
||||
pub assistant: RwLock<AssistantConfig>,
|
||||
/// Data dir — lets dispatch handlers reach disk-backed stores (e.g. the
|
||||
/// federation trust list used to gate AI queries) without threading a path
|
||||
/// through every call.
|
||||
pub data_dir: std::path::PathBuf,
|
||||
/// Contact-ids with an AI query currently being answered. Caps each asker to
|
||||
/// one in-flight query so a peer can't flood the node's compute / airtime.
|
||||
pub assist_inflight: RwLock<HashSet<u32>>,
|
||||
/// Recently-denied `!ai` askers (newest first, capped). When `trusted_only`
|
||||
/// rejects a sender — typically a radio (meshcore) device that presents a
|
||||
/// firmware key rather than an archipelago DID — we record who tried so the
|
||||
/// UI can surface them and let the operator one-click allow their key.
|
||||
/// Silent on the wire (no airtime spent), visible to the operator here.
|
||||
pub assist_denied: RwLock<VecDeque<DeniedAsker>>,
|
||||
}
|
||||
|
||||
/// A `!ai` asker that the assistant policy turned away. Surfaced to the UI so
|
||||
/// the operator can add their key to the allowlist without hunting the journal.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct DeniedAsker {
|
||||
/// Meshcore contact id of the asker.
|
||||
pub contact_id: u32,
|
||||
/// Best-known display name (advert name) at denial time.
|
||||
pub name: String,
|
||||
/// The asker's ed25519 pubkey hex, if known. `None` for a raw radio device
|
||||
/// that hasn't advertised an archipelago key — such a sender can only be
|
||||
/// admitted by switching the policy to "anyone", not via the allowlist.
|
||||
pub pubkey_hex: Option<String>,
|
||||
/// ISO-8601 timestamp of the (most recent) denial.
|
||||
pub at: String,
|
||||
}
|
||||
|
||||
/// Mesh-AI assistant configuration, snapshotted from `MeshConfig` at startup.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AssistantConfig {
|
||||
/// Answer AssistQuery messages with the local LLM.
|
||||
pub enabled: bool,
|
||||
/// Model to use; None → the backend's built-in default.
|
||||
pub model: Option<String>,
|
||||
/// Restrict asking to federation-Trusted peers (vs. anyone on the mesh).
|
||||
pub trusted_only: bool,
|
||||
/// AI backend: "claude" (shared proxy token) or "ollama" (local model).
|
||||
pub backend: String,
|
||||
/// Per-contact allowlist (ed25519 pubkey hex) permitted to use `!ai`
|
||||
/// regardless of `trusted_only`. Empty → only the `trusted_only` policy
|
||||
/// applies. A user-blocked contact is always denied even if listed here.
|
||||
pub allowed_contacts: Vec<String>,
|
||||
}
|
||||
|
||||
/// Contact metadata kept alongside MeshState.peers. Pinned contacts sort to
|
||||
/// the top of the chat list, blocked ones are filtered out of notifications.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ContactEntry {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub alias: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub notes: Option<String>,
|
||||
#[serde(default)]
|
||||
pub pinned: bool,
|
||||
#[serde(default)]
|
||||
pub blocked: bool,
|
||||
}
|
||||
|
||||
/// In-progress chunk reassembly for a multi-frame message.
|
||||
pub(crate) struct ChunkAssembly {
|
||||
chunks: HashMap<u8, String>,
|
||||
total: u8,
|
||||
created: std::time::Instant,
|
||||
}
|
||||
|
||||
impl MeshState {
|
||||
pub fn new(
|
||||
channel_name: &str,
|
||||
block_header_cache: Arc<super::bitcoin_relay::BlockHeaderCache>,
|
||||
relay_tracker: Option<Arc<super::bitcoin_relay::RelayTracker>>,
|
||||
stego_mode: super::steganography::SteganographyMode,
|
||||
encrypt_relay: bool,
|
||||
receive_block_headers: bool,
|
||||
session_manager: Arc<super::session::SessionManager>,
|
||||
our_ed_pubkey_hex: String,
|
||||
assistant: AssistantConfig,
|
||||
data_dir: std::path::PathBuf,
|
||||
) -> (
|
||||
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: RwLock::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,
|
||||
region: None,
|
||||
}),
|
||||
event_tx: tx,
|
||||
next_message_id: RwLock::new(1),
|
||||
next_send_seq: RwLock::new(HashMap::new()),
|
||||
block_header_cache,
|
||||
relay_tracker,
|
||||
stego_mode,
|
||||
chunk_buffer: RwLock::new(HashMap::new()),
|
||||
session_manager,
|
||||
encrypt_relay,
|
||||
receive_block_headers,
|
||||
presence: RwLock::new(HashMap::new()),
|
||||
contacts: RwLock::new(HashMap::new()),
|
||||
our_ed_pubkey_hex,
|
||||
blob_store: RwLock::new(None),
|
||||
assistant_handler: RwLock::new(None),
|
||||
radio_contact_blocklist: RwLock::new(HashSet::new()),
|
||||
assistant: RwLock::new(assistant),
|
||||
data_dir,
|
||||
assist_inflight: RwLock::new(HashSet::new()),
|
||||
assist_denied: RwLock::new(VecDeque::new()),
|
||||
});
|
||||
(state, rx, cmd_rx)
|
||||
}
|
||||
|
||||
/// Send a command to the listener. Reads the current sender from the
|
||||
/// RwLock and clones for the async send. Returns the mpsc SendError so
|
||||
/// callers can treat a dead listener as "mesh not running".
|
||||
pub async fn send_cmd(
|
||||
&self,
|
||||
cmd: MeshCommand,
|
||||
) -> Result<(), mpsc::error::SendError<MeshCommand>> {
|
||||
let tx = self.cmd_tx.read().await.clone();
|
||||
tx.send(cmd).await
|
||||
}
|
||||
|
||||
pub async fn next_id(&self) -> u64 {
|
||||
let mut id = self.next_message_id.write().await;
|
||||
let current = *id;
|
||||
*id += 1;
|
||||
current
|
||||
}
|
||||
|
||||
/// Allocate the next outbound sequence number for a given target
|
||||
/// (contact_id for direct peers, 0 for channel broadcasts). Monotonic
|
||||
/// per target, starts at 1.
|
||||
pub async fn next_send_seq(&self, target: u32) -> u64 {
|
||||
let mut map = self.next_send_seq.write().await;
|
||||
let slot = map.entry(target).or_insert(0);
|
||||
*slot += 1;
|
||||
*slot
|
||||
}
|
||||
|
||||
pub async fn store_message(&self, msg: MeshMessage) {
|
||||
let mut messages = self.messages.write().await;
|
||||
// Deduplicate RECEIVED messages only — a Sent record is the user's
|
||||
// own action and must ALWAYS be shown, even when the display text
|
||||
// collides with an earlier one (e.g. two 👍 reactions to different
|
||||
// targets, or "ok" reply twice in a row).
|
||||
//
|
||||
// Dedup runs THREE checks, any match drops the incoming message:
|
||||
// (a) (sender_pubkey, sender_seq) — exact MessageKey match
|
||||
// (b) (sender_seq, plaintext, 120s) — cross-transport match when
|
||||
// the same envelope arrives via radio and federation: radio
|
||||
// populates sender_pubkey from the firmware key, federation
|
||||
// populates it from the archipelago ed25519 key, so (a) misses
|
||||
// but the seq+text still uniquely identifies the envelope
|
||||
// (c) (peer_contact_id, plaintext, 30s) — legacy plain-text frames
|
||||
// without a sender_seq at all
|
||||
if matches!(msg.direction, MessageDirection::Received) {
|
||||
let has_seq = msg.sender_seq.is_some();
|
||||
let key_match = has_seq
|
||||
&& msg.sender_pubkey.is_some()
|
||||
&& messages.iter().rev().take(40).any(|m| {
|
||||
matches!(m.direction, MessageDirection::Received)
|
||||
&& m.sender_pubkey == msg.sender_pubkey
|
||||
&& m.sender_seq == msg.sender_seq
|
||||
});
|
||||
let cross_transport_match = has_seq
|
||||
&& messages.iter().rev().take(40).any(|m| {
|
||||
matches!(m.direction, MessageDirection::Received)
|
||||
&& m.sender_seq == msg.sender_seq
|
||||
&& m.plaintext == msg.plaintext
|
||||
&& within_seconds_iso(&m.timestamp, &msg.timestamp, 120)
|
||||
});
|
||||
let legacy_match = !has_seq
|
||||
&& messages.iter().rev().take(20).any(|m| {
|
||||
matches!(m.direction, MessageDirection::Received)
|
||||
&& m.peer_contact_id == msg.peer_contact_id
|
||||
&& m.plaintext == msg.plaintext
|
||||
&& within_seconds_iso(&m.timestamp, &msg.timestamp, 30)
|
||||
});
|
||||
if key_match || cross_transport_match || legacy_match {
|
||||
return;
|
||||
}
|
||||
}
|
||||
messages.push_back(msg);
|
||||
if messages.len() > MAX_MESSAGES {
|
||||
messages.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn update_peer_count(&self) {
|
||||
let count = self.peers.read().await.len();
|
||||
self.status.write().await.peer_count = count;
|
||||
}
|
||||
|
||||
/// Restore the persisted message history + outbound sequence counters.
|
||||
/// Called once at service startup, before the listener spawns. Missing
|
||||
/// file (fresh node) is normal; a corrupt file is logged and skipped
|
||||
/// rather than blocking mesh startup.
|
||||
pub async fn load_persisted_messages(&self) {
|
||||
let path = self.data_dir.join(MESSAGES_FILE);
|
||||
let bytes = match tokio::fs::read(&path).await {
|
||||
Ok(b) => b,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return,
|
||||
Err(e) => {
|
||||
warn!("mesh: reading {} failed: {e}", path.display());
|
||||
return;
|
||||
}
|
||||
};
|
||||
let persisted: PersistedMessages = match serde_json::from_slice(&bytes) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"mesh: parsing {} failed (skipping restore): {e}",
|
||||
path.display()
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let count = persisted.messages.len();
|
||||
let max_id = persisted.messages.iter().map(|m| m.id).max().unwrap_or(0);
|
||||
*self.messages.write().await = persisted.messages.into();
|
||||
if !persisted.send_seqs.is_empty() {
|
||||
*self.next_send_seq.write().await = persisted.send_seqs;
|
||||
}
|
||||
{
|
||||
let mut id = self.next_message_id.write().await;
|
||||
if *id <= max_id {
|
||||
*id = max_id + 1;
|
||||
}
|
||||
}
|
||||
info!(
|
||||
"mesh: restored {count} persisted messages (next id {})",
|
||||
max_id + 1
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist the message history on a low-rate timer instead of hooking every
|
||||
/// mutation path (store, delivered/transport/encrypted stamps, edits, deletes,
|
||||
/// read-receipt prunes — and whatever gets added next). Serializing ≤100
|
||||
/// messages every few seconds is trivial; the write is skipped when nothing
|
||||
/// changed, and at most the last few seconds of history are lost on a hard
|
||||
/// kill — versus the entire history, which is what a restart cost before this
|
||||
/// existed. Atomic write-then-rename, 0600 (DM plaintext lives in this file).
|
||||
pub fn spawn_message_persister(state: Arc<MeshState>) {
|
||||
tokio::spawn(async move {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let path = state.data_dir.join(MESSAGES_FILE);
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
let mut last_written: Option<Vec<u8>> = None;
|
||||
let mut tick = tokio::time::interval(Duration::from_secs(5));
|
||||
tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
loop {
|
||||
tick.tick().await;
|
||||
let snapshot = PersistedMessages {
|
||||
messages: state.messages.read().await.iter().cloned().collect(),
|
||||
send_seqs: state.next_send_seq.read().await.clone(),
|
||||
};
|
||||
let json = match serde_json::to_vec_pretty(&snapshot) {
|
||||
Ok(j) => j,
|
||||
Err(e) => {
|
||||
warn!("mesh: serializing message history failed: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if last_written.as_deref() == Some(json.as_slice()) {
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = tokio::fs::write(&tmp, &json).await {
|
||||
warn!("mesh: writing {} failed: {e}", tmp.display());
|
||||
continue;
|
||||
}
|
||||
let perms = std::fs::Permissions::from_mode(0o600);
|
||||
if let Err(e) = tokio::fs::set_permissions(&tmp, perms).await {
|
||||
warn!("mesh: chmod {} failed: {e}", tmp.display());
|
||||
}
|
||||
if let Err(e) = tokio::fs::rename(&tmp, &path).await {
|
||||
warn!(
|
||||
"mesh: renaming {} -> {} failed: {e}",
|
||||
tmp.display(),
|
||||
path.display()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
last_written = Some(json);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 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>,
|
||||
data_dir: std::path::PathBuf,
|
||||
device_path: Option<String>,
|
||||
our_did: String,
|
||||
our_ed_pubkey_hex: String,
|
||||
our_x25519_secret: [u8; 32],
|
||||
our_x25519_pubkey_hex: String,
|
||||
server_name: Option<String>,
|
||||
lora_region: Option<String>,
|
||||
lora_radio_params: Option<super::LoraRadioParams>,
|
||||
channel_name: Option<String>,
|
||||
device_kind: Option<super::types::DeviceType>,
|
||||
reticulum_tcp: Option<super::types::ReticulumTcpConfig>,
|
||||
manage_radio: bool,
|
||||
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;
|
||||
let mut reconnect_delay = RECONNECT_DELAY_INIT;
|
||||
// Mutable so a successful auto-detect can pin the firmware kind for
|
||||
// the rest of this listener's lifetime — see the pin-on-first-success
|
||||
// block below for why.
|
||||
let mut device_kind = device_kind;
|
||||
// Backlog #12 hot-swap re-binding: each run_mesh_session call already
|
||||
// builds a fresh device struct (contacts/current_region/etc. all
|
||||
// start empty), so per-device session state is naturally isolated
|
||||
// across reconnects — there's no stale in-memory state to clear here.
|
||||
// What's worth doing is detecting when the *physical radio itself*
|
||||
// changed (a genuine hot-swap, not just the same radio reconnecting)
|
||||
// so it's visible in logs rather than silently treated the same as
|
||||
// an ordinary reconnect.
|
||||
let mut last_self_node_id: Option<u32> = None;
|
||||
loop {
|
||||
if *shutdown.borrow() {
|
||||
info!("Mesh listener shutting down");
|
||||
return;
|
||||
}
|
||||
|
||||
let session_start = std::time::Instant::now();
|
||||
match session::run_mesh_session(
|
||||
&state,
|
||||
&data_dir,
|
||||
device_path.as_deref(),
|
||||
&our_did,
|
||||
&our_ed_pubkey_hex,
|
||||
&our_x25519_secret,
|
||||
&our_x25519_pubkey_hex,
|
||||
server_name.as_deref(),
|
||||
lora_region.as_deref(),
|
||||
lora_radio_params,
|
||||
channel_name.as_deref(),
|
||||
device_kind,
|
||||
reticulum_tcp.clone(),
|
||||
manage_radio,
|
||||
&mut shutdown,
|
||||
&mut cmd_rx,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
info!("Mesh session ended cleanly");
|
||||
// Only trust a session that actually ran for a while —
|
||||
// see STABLE_SESSION_THRESHOLD's doc comment.
|
||||
if session_start.elapsed() >= STABLE_SESSION_THRESHOLD {
|
||||
reconnect_delay = RECONNECT_DELAY_INIT;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
if session_start.elapsed() >= STABLE_SESSION_THRESHOLD {
|
||||
reconnect_delay = RECONNECT_DELAY_INIT;
|
||||
}
|
||||
error!("Mesh session error: {} (retry in {:?})", e, reconnect_delay);
|
||||
}
|
||||
}
|
||||
|
||||
// Hot-swap detection: compare this session's self_node_id against
|
||||
// the last one we saw. A change means the physical radio itself
|
||||
// was swapped (not just a reconnect of the same board).
|
||||
{
|
||||
let current_self_node_id = state.status.read().await.self_node_id;
|
||||
if let (Some(prev), Some(cur)) = (last_self_node_id, current_self_node_id) {
|
||||
if prev != cur {
|
||||
info!(
|
||||
previous_node_id = prev,
|
||||
new_node_id = cur,
|
||||
"Local mesh radio identity changed — treating as a hot-swapped device"
|
||||
);
|
||||
}
|
||||
}
|
||||
if current_self_node_id.is_some() {
|
||||
last_self_node_id = current_self_node_id;
|
||||
}
|
||||
}
|
||||
|
||||
// Pin the firmware kind after the first successful auto-detect.
|
||||
// Confirmed live 2026-07-23: with device_kind left unpinned (e.g.
|
||||
// after clearing a stale pin), EVERY reconnect re-runs the full
|
||||
// Reticulum→Meshcore→Meshtastic auto-detect cascade — each
|
||||
// candidate past the first does its own open() with the DTR/RTS
|
||||
// reset both boards need, so a device correctly identified as
|
||||
// Meshtastic still gets reset once for the failed Meshcore
|
||||
// attempt before Meshtastic's own open() resets it again. That
|
||||
// doubled the reset count on every single reconnect indefinitely,
|
||||
// not just during initial detection. Once auto-detect has
|
||||
// identified the device this listener is actually talking to,
|
||||
// there's no reason to keep guessing on subsequent reconnects —
|
||||
// pin it, both in this task's own loop (takes effect
|
||||
// immediately) and on disk (survives a service restart). A
|
||||
// genuine hot-swap to different firmware is still handled: the
|
||||
// setup modal's `mesh.probe-device` always re-probes unpinned,
|
||||
// and the flash flow already clears this pin on its own.
|
||||
if device_kind.is_none() {
|
||||
let detected = state.status.read().await.device_type;
|
||||
if detected != super::types::DeviceType::Unknown {
|
||||
device_kind = Some(detected);
|
||||
match super::load_config(&data_dir).await {
|
||||
Ok(mut cfg) if cfg.device_kind.is_none() => {
|
||||
cfg.device_kind = Some(detected);
|
||||
if let Err(e) = super::save_config(&data_dir, &cfg).await {
|
||||
warn!("Failed to persist auto-detected device_kind: {}", e);
|
||||
} else {
|
||||
info!(
|
||||
kind = %detected,
|
||||
"Pinned auto-detected firmware kind to avoid repeated multi-protocol resets on reconnect"
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => warn!("Failed to load mesh config to persist device_kind: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update status to disconnected. device_type/firmware_version are
|
||||
// reset too — they were previously left holding the LAST radio's
|
||||
// identity, so after a hot-swap the UI showed the old firmware
|
||||
// ("meshcore, disconnected") while a different stick sat in the
|
||||
// port. Unknown-until-next-successful-connect is the honest state.
|
||||
{
|
||||
let mut status = state.status.write().await;
|
||||
status.device_connected = false;
|
||||
status.device_path = None;
|
||||
status.device_type = super::types::DeviceType::Unknown;
|
||||
status.firmware_version = None;
|
||||
}
|
||||
let _ = state.event_tx.send(MeshEvent::DeviceDisconnected);
|
||||
|
||||
// Wait before reconnecting (exponential backoff)
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(reconnect_delay) => {},
|
||||
_ = shutdown.changed() => {
|
||||
if *shutdown.borrow() { return; }
|
||||
},
|
||||
}
|
||||
|
||||
// Increase backoff for next failure, cap at max
|
||||
reconnect_delay = (reconnect_delay * 2).min(RECONNECT_DELAY_MAX);
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
//! Mesh `!archy` command — answers node-status questions from this node's own
|
||||
//! cached state, with no model in the loop.
|
||||
//!
|
||||
//! `!ai` sends the question to an LLM; `!archy` never does. Every answer here is
|
||||
//! read straight from the same status caches the HTTP endpoints serve, so it is
|
||||
//! deterministic, costs no tokens, and works with the assistant switched off.
|
||||
//! Airtime is scarce, so replies are single-frame terse.
|
||||
|
||||
use super::assist::{is_sender_allowed, send_reply, AssistReply};
|
||||
use super::MeshState;
|
||||
use crate::{bitcoin_status, electrs_status};
|
||||
use std::sync::Arc;
|
||||
use tracing::{info, warn};
|
||||
|
||||
/// A parsed `!archy` sub-command.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(super) enum NodeCmd {
|
||||
Status,
|
||||
Bitcoin,
|
||||
Electrs,
|
||||
Version,
|
||||
Help,
|
||||
}
|
||||
|
||||
impl NodeCmd {
|
||||
fn parse(rest: &str) -> Self {
|
||||
match rest.trim().to_ascii_lowercase().as_str() {
|
||||
"" | "status" => Self::Status,
|
||||
"btc" | "bitcoin" | "node" | "sync" => Self::Bitcoin,
|
||||
"electrs" | "electrum" => Self::Electrs,
|
||||
"version" | "ver" => Self::Version,
|
||||
_ => Self::Help,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Recognise a `!archy [subcommand]` prefix (case-insensitive) and return the
|
||||
/// trimmed remainder, or `None` if the text isn't an archy command.
|
||||
///
|
||||
/// Accepts both a bare `!archy` and `!archy <sub>`, so the trailing space that
|
||||
/// `strip_ai_trigger` requires is deliberately not required here.
|
||||
pub(super) fn strip_archy_trigger(text: &str) -> Option<&str> {
|
||||
const P: &str = "!archy";
|
||||
let t = text.trim_start();
|
||||
if t.len() < P.len() || !t[..P.len()].eq_ignore_ascii_case(P) {
|
||||
return None;
|
||||
}
|
||||
let rest = &t[P.len()..];
|
||||
// `!archyfoo` is not the command; require end-of-text or a separator.
|
||||
if !rest.is_empty() && !rest.starts_with(char::is_whitespace) {
|
||||
return None;
|
||||
}
|
||||
Some(rest.trim())
|
||||
}
|
||||
|
||||
/// Entry point: gate the asker, format the answer from local caches, reply.
|
||||
///
|
||||
/// Uses the same trust policy as the AI assistant (`is_sender_allowed`), so a
|
||||
/// `trusted_only` node still only answers authenticated or allowlisted peers.
|
||||
/// It does NOT require the assistant to be enabled — this path never calls a
|
||||
/// model, so turning the LLM off shouldn't take node status with it.
|
||||
pub(super) async fn run_node_cmd(
|
||||
rest: String,
|
||||
req_id: u64,
|
||||
asker_contact_id: u32,
|
||||
sender_name: String,
|
||||
authenticated: bool,
|
||||
reply: AssistReply,
|
||||
state: Arc<MeshState>,
|
||||
) {
|
||||
let asker = asker_contact_id;
|
||||
|
||||
if !is_sender_allowed(&state, asker, authenticated).await {
|
||||
warn!(
|
||||
from = asker,
|
||||
name = %sender_name,
|
||||
"!archy denied — sender not permitted by assistant policy"
|
||||
);
|
||||
// Silent on the wire, matching the assistant's denial behaviour.
|
||||
return;
|
||||
}
|
||||
|
||||
let cmd = NodeCmd::parse(&rest);
|
||||
info!(from = asker, req_id, ?cmd, "Answering !archy over mesh");
|
||||
let answer = match cmd {
|
||||
NodeCmd::Status => status_line().await,
|
||||
NodeCmd::Bitcoin => bitcoin_line().await,
|
||||
NodeCmd::Electrs => electrs_line().await,
|
||||
NodeCmd::Version => version_line(),
|
||||
NodeCmd::Help => {
|
||||
"archy: !archy [status|btc|electrs|version]. Node status, no AI.".to_string()
|
||||
}
|
||||
};
|
||||
|
||||
send_reply(&state, &reply, req_id, &answer).await;
|
||||
}
|
||||
|
||||
fn version_line() -> String {
|
||||
format!("Archipelago OS v{}", env!("CARGO_PKG_VERSION"))
|
||||
}
|
||||
|
||||
/// Pull `blocks`/`headers`/`connections` out of the cached Core RPC blobs.
|
||||
async fn bitcoin_facts() -> Option<(u64, u64, u64, bool)> {
|
||||
let s = bitcoin_status::get_bitcoin_status().await;
|
||||
let chain = s.blockchain_info.as_ref()?;
|
||||
let blocks = chain.get("blocks")?.as_u64()?;
|
||||
let headers = chain.get("headers")?.as_u64()?;
|
||||
let ibd = chain
|
||||
.get("initialblockdownload")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let peers = s
|
||||
.network_info
|
||||
.as_ref()
|
||||
.and_then(|n| n.get("connections"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0);
|
||||
Some((blocks, headers, peers, ibd))
|
||||
}
|
||||
|
||||
async fn bitcoin_line() -> String {
|
||||
match bitcoin_facts().await {
|
||||
Some((blocks, headers, peers, ibd)) => {
|
||||
if !ibd && blocks == headers {
|
||||
format!("BTC: synced, block {blocks}, {peers} peers.")
|
||||
} else {
|
||||
format!("BTC: syncing {blocks}/{headers}, {peers} peers.")
|
||||
}
|
||||
}
|
||||
None => "BTC: status unavailable.".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn electrs_line() -> String {
|
||||
let e = electrs_status::get_electrs_sync_status().await;
|
||||
if let Some(err) = e.error.as_deref() {
|
||||
return format!("Electrum: error ({err}).");
|
||||
}
|
||||
if e.status == "ready" || e.status == "synced" {
|
||||
format!("Electrum: synced at {}.", e.indexed_height)
|
||||
} else {
|
||||
format!(
|
||||
"Electrum: {} {:.1}% ({}/{}).",
|
||||
e.status, e.progress_pct, e.indexed_height, e.bitcoin_height
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// One-frame overview: OS version + chain + electrum, kept under the plain-text
|
||||
/// channel cap so a stock meshcore client sees the whole thing.
|
||||
async fn status_line() -> String {
|
||||
let btc = match bitcoin_facts().await {
|
||||
Some((blocks, headers, peers, ibd)) if !ibd && blocks == headers => {
|
||||
format!("BTC synced {blocks} ({peers}p)")
|
||||
}
|
||||
Some((blocks, headers, peers, _)) => format!("BTC {blocks}/{headers} ({peers}p)"),
|
||||
None => "BTC n/a".to_string(),
|
||||
};
|
||||
let e = electrs_status::get_electrs_sync_status().await;
|
||||
let elec = if e.status == "ready" || e.status == "synced" {
|
||||
"electrum synced".to_string()
|
||||
} else {
|
||||
format!("electrum {:.0}%", e.progress_pct)
|
||||
};
|
||||
format!(
|
||||
"Archipelago OS v{}: {btc}, {elec}.",
|
||||
env!("CARGO_PKG_VERSION")
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn trigger_matches_bare_and_sub() {
|
||||
assert_eq!(strip_archy_trigger("!archy"), Some(""));
|
||||
assert_eq!(strip_archy_trigger(" !ARCHY "), Some(""));
|
||||
assert_eq!(strip_archy_trigger("!archy btc"), Some("btc"));
|
||||
assert_eq!(strip_archy_trigger("!Archy electrs "), Some("electrs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trigger_rejects_non_commands() {
|
||||
assert_eq!(strip_archy_trigger("!archyfoo"), None);
|
||||
assert_eq!(strip_archy_trigger("!ai what is archy"), None);
|
||||
assert_eq!(strip_archy_trigger("archy"), None);
|
||||
assert_eq!(strip_archy_trigger("tell me !archy"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subcommands_parse() {
|
||||
assert_eq!(NodeCmd::parse(""), NodeCmd::Status);
|
||||
assert_eq!(NodeCmd::parse("status"), NodeCmd::Status);
|
||||
assert_eq!(NodeCmd::parse("BTC"), NodeCmd::Bitcoin);
|
||||
assert_eq!(NodeCmd::parse("electrum"), NodeCmd::Electrs);
|
||||
assert_eq!(NodeCmd::parse("ver"), NodeCmd::Version);
|
||||
assert_eq!(NodeCmd::parse("wat"), NodeCmd::Help);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,345 @@
|
||||
// WIP mesh/transport protocol — suppress dead code warnings
|
||||
#![allow(dead_code)]
|
||||
//! Store-and-forward message queue for mesh networking.
|
||||
//!
|
||||
//! When a destination peer is offline or unreachable, messages are queued
|
||||
//! in the outbox and retried periodically. Messages expire after TTL (24h default).
|
||||
//! Intermediate nodes can relay messages for peers up to 3 hops away.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::VecDeque;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::{debug, info};
|
||||
|
||||
/// Default time-to-live for queued messages (24 hours).
|
||||
const DEFAULT_TTL_SECS: u64 = 86400;
|
||||
|
||||
/// Maximum relay hops for store-and-forward.
|
||||
const MAX_RELAY_HOPS: u8 = 3;
|
||||
|
||||
/// Maximum queued messages to prevent unbounded memory use.
|
||||
const MAX_QUEUE_SIZE: usize = 200;
|
||||
|
||||
const OUTBOX_FILE: &str = "mesh-outbox.json";
|
||||
|
||||
/// A message waiting to be delivered.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PendingMessage {
|
||||
pub id: u64,
|
||||
/// Destination peer DID.
|
||||
pub dest_did: String,
|
||||
/// Encrypted payload bytes (already ratchet-encrypted or static-encrypted).
|
||||
#[serde(with = "base64_bytes")]
|
||||
pub encrypted_payload: Vec<u8>,
|
||||
/// When this message was created (RFC 3339).
|
||||
pub created_at: String,
|
||||
/// Time-to-live in seconds.
|
||||
pub ttl_secs: u64,
|
||||
/// Number of times we've attempted delivery.
|
||||
pub retry_count: u32,
|
||||
/// How many relay hops this message has traversed.
|
||||
pub relay_hops: u8,
|
||||
/// Original sender DID (for relayed messages).
|
||||
pub from_did: String,
|
||||
}
|
||||
|
||||
impl PendingMessage {
|
||||
/// Check if this message has expired.
|
||||
pub fn is_expired(&self) -> bool {
|
||||
let Ok(created) = chrono::DateTime::parse_from_rfc3339(&self.created_at) else {
|
||||
return true; // Can't parse = treat as expired
|
||||
};
|
||||
let age = chrono::Utc::now().signed_duration_since(created);
|
||||
// Use `>=` so a ttl_secs=0 message is expired immediately (used by
|
||||
// tests and by callers that want a fire-and-forget behavior when
|
||||
// the relay can't deliver on first try).
|
||||
age.num_seconds() as u64 >= self.ttl_secs
|
||||
}
|
||||
|
||||
/// Check if this message can be relayed further.
|
||||
pub fn can_relay(&self) -> bool {
|
||||
self.relay_hops < MAX_RELAY_HOPS
|
||||
}
|
||||
}
|
||||
|
||||
/// Persistent store-and-forward queue.
|
||||
pub struct MeshOutbox {
|
||||
queue: RwLock<VecDeque<PendingMessage>>,
|
||||
data_dir: PathBuf,
|
||||
next_id: RwLock<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
struct OutboxFile {
|
||||
messages: Vec<PendingMessage>,
|
||||
next_id: u64,
|
||||
}
|
||||
|
||||
impl MeshOutbox {
|
||||
/// Load outbox from disk or create empty.
|
||||
pub async fn load(data_dir: &Path) -> Result<Self> {
|
||||
let path = data_dir.join(OUTBOX_FILE);
|
||||
let (messages, next_id) = if path.exists() {
|
||||
let content = tokio::fs::read_to_string(&path)
|
||||
.await
|
||||
.context("Failed to read mesh outbox")?;
|
||||
let file: OutboxFile = serde_json::from_str(&content).unwrap_or_default();
|
||||
(VecDeque::from(file.messages), file.next_id)
|
||||
} else {
|
||||
(VecDeque::new(), 1)
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
queue: RwLock::new(messages),
|
||||
data_dir: data_dir.to_path_buf(),
|
||||
next_id: RwLock::new(next_id),
|
||||
})
|
||||
}
|
||||
|
||||
/// Persist queue to disk.
|
||||
pub async fn save(&self) -> Result<()> {
|
||||
let queue = self.queue.read().await;
|
||||
let next_id = *self.next_id.read().await;
|
||||
let file = OutboxFile {
|
||||
messages: queue.iter().cloned().collect(),
|
||||
next_id,
|
||||
};
|
||||
let content = serde_json::to_string_pretty(&file).context("Failed to serialize outbox")?;
|
||||
tokio::fs::write(self.data_dir.join(OUTBOX_FILE), content)
|
||||
.await
|
||||
.context("Failed to write outbox")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Enqueue a message for delivery.
|
||||
pub async fn enqueue(
|
||||
&self,
|
||||
dest_did: &str,
|
||||
from_did: &str,
|
||||
encrypted_payload: Vec<u8>,
|
||||
ttl_secs: Option<u64>,
|
||||
) -> Result<u64> {
|
||||
let mut next_id = self.next_id.write().await;
|
||||
let id = *next_id;
|
||||
*next_id += 1;
|
||||
|
||||
let msg = PendingMessage {
|
||||
id,
|
||||
dest_did: dest_did.to_string(),
|
||||
encrypted_payload,
|
||||
created_at: chrono::Utc::now().to_rfc3339(),
|
||||
ttl_secs: ttl_secs.unwrap_or(DEFAULT_TTL_SECS),
|
||||
retry_count: 0,
|
||||
relay_hops: 0,
|
||||
from_did: from_did.to_string(),
|
||||
};
|
||||
|
||||
let mut queue = self.queue.write().await;
|
||||
// Evict oldest if over limit
|
||||
while queue.len() >= MAX_QUEUE_SIZE {
|
||||
queue.pop_front();
|
||||
}
|
||||
queue.push_back(msg);
|
||||
|
||||
info!(id = id, dest = %dest_did, "Message queued for delivery");
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Enqueue a relayed message (from another peer, not originated by us).
|
||||
pub async fn enqueue_relay(&self, mut msg: PendingMessage) -> Result<()> {
|
||||
if !msg.can_relay() {
|
||||
anyhow::bail!("Message exceeded max relay hops ({})", MAX_RELAY_HOPS);
|
||||
}
|
||||
msg.relay_hops += 1;
|
||||
|
||||
let mut queue = self.queue.write().await;
|
||||
while queue.len() >= MAX_QUEUE_SIZE {
|
||||
queue.pop_front();
|
||||
}
|
||||
queue.push_back(msg);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove expired messages from the queue.
|
||||
pub async fn expire_stale(&self) -> usize {
|
||||
let mut queue = self.queue.write().await;
|
||||
let before = queue.len();
|
||||
queue.retain(|msg| !msg.is_expired());
|
||||
let expired = before - queue.len();
|
||||
if expired > 0 {
|
||||
debug!(expired = expired, "Expired stale outbox messages");
|
||||
}
|
||||
expired
|
||||
}
|
||||
|
||||
/// Get messages pending for a specific peer.
|
||||
pub async fn messages_for_peer(&self, did: &str) -> Vec<PendingMessage> {
|
||||
self.queue
|
||||
.read()
|
||||
.await
|
||||
.iter()
|
||||
.filter(|m| m.dest_did == did)
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Mark a message as delivered (remove from queue).
|
||||
pub async fn mark_delivered(&self, id: u64) -> bool {
|
||||
let mut queue = self.queue.write().await;
|
||||
let before = queue.len();
|
||||
queue.retain(|m| m.id != id);
|
||||
queue.len() < before
|
||||
}
|
||||
|
||||
/// Increment retry count for a message.
|
||||
pub async fn increment_retry(&self, id: u64) {
|
||||
let mut queue = self.queue.write().await;
|
||||
if let Some(msg) = queue.iter_mut().find(|m| m.id == id) {
|
||||
msg.retry_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all pending messages (for RPC status).
|
||||
pub async fn list(&self, limit: Option<usize>) -> Vec<PendingMessage> {
|
||||
let queue = self.queue.read().await;
|
||||
let limit = limit.unwrap_or(50);
|
||||
queue.iter().take(limit).cloned().collect()
|
||||
}
|
||||
|
||||
/// Count of pending messages.
|
||||
pub async fn count(&self) -> usize {
|
||||
self.queue.read().await.len()
|
||||
}
|
||||
}
|
||||
|
||||
// ─── base64 serde for encrypted payloads ────────────────────────────────
|
||||
|
||||
mod base64_bytes {
|
||||
use base64::Engine;
|
||||
use serde::{Deserialize, Deserializer, Serializer};
|
||||
|
||||
pub fn serialize<S: Serializer>(bytes: &Vec<u8>, s: S) -> Result<S::Ok, S::Error> {
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(bytes);
|
||||
s.serialize_str(&encoded)
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<u8>, D::Error> {
|
||||
let s = String::deserialize(d)?;
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(&s)
|
||||
.map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_enqueue_and_list() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let outbox = MeshOutbox::load(dir.path()).await.unwrap();
|
||||
|
||||
let id = outbox
|
||||
.enqueue("did:key:z6MkDest", "did:key:z6MkSelf", vec![1, 2, 3], None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(outbox.count().await, 1);
|
||||
let msgs = outbox.list(None).await;
|
||||
assert_eq!(msgs[0].id, id);
|
||||
assert_eq!(msgs[0].dest_did, "did:key:z6MkDest");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_mark_delivered() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let outbox = MeshOutbox::load(dir.path()).await.unwrap();
|
||||
|
||||
let id = outbox
|
||||
.enqueue("did:key:z6MkDest", "did:key:z6MkSelf", vec![1], None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(outbox.mark_delivered(id).await);
|
||||
assert_eq!(outbox.count().await, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_expire_stale() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let outbox = MeshOutbox::load(dir.path()).await.unwrap();
|
||||
|
||||
// Enqueue with 0 TTL (immediately expired)
|
||||
outbox
|
||||
.enqueue("did:key:z6MkDest", "did:key:z6MkSelf", vec![1], Some(0))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let expired = outbox.expire_stale().await;
|
||||
assert_eq!(expired, 1);
|
||||
assert_eq!(outbox.count().await, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_persistence_roundtrip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let outbox = MeshOutbox::load(dir.path()).await.unwrap();
|
||||
|
||||
outbox
|
||||
.enqueue(
|
||||
"did:key:z6MkDest",
|
||||
"did:key:z6MkSelf",
|
||||
vec![42, 43, 44],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
outbox.save().await.unwrap();
|
||||
|
||||
// Reload
|
||||
let outbox2 = MeshOutbox::load(dir.path()).await.unwrap();
|
||||
assert_eq!(outbox2.count().await, 1);
|
||||
let msgs = outbox2.list(None).await;
|
||||
assert_eq!(msgs[0].encrypted_payload, vec![42, 43, 44]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_max_queue_size() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let outbox = MeshOutbox::load(dir.path()).await.unwrap();
|
||||
|
||||
for i in 0..210 {
|
||||
outbox
|
||||
.enqueue("did:key:z6MkDest", "did:key:z6MkSelf", vec![i as u8], None)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Should cap at MAX_QUEUE_SIZE
|
||||
assert!(outbox.count().await <= 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_relay_hops() {
|
||||
let msg = PendingMessage {
|
||||
id: 1,
|
||||
dest_did: "did:key:test".to_string(),
|
||||
encrypted_payload: vec![],
|
||||
created_at: chrono::Utc::now().to_rfc3339(),
|
||||
ttl_secs: 86400,
|
||||
retry_count: 0,
|
||||
relay_hops: 2,
|
||||
from_did: "did:key:sender".to_string(),
|
||||
};
|
||||
assert!(msg.can_relay()); // 2 < 3
|
||||
|
||||
let msg2 = PendingMessage {
|
||||
relay_hops: 3,
|
||||
..msg
|
||||
};
|
||||
assert!(!msg2.can_relay()); // 3 >= 3
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,969 @@
|
||||
// WIP mesh/transport protocol — suppress dead code warnings
|
||||
#![allow(dead_code)]
|
||||
//! 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;
|
||||
/// CMD_RESET_PATH (0x0D): Tell the firmware to drop the stored route for
|
||||
/// a contact and fall back to flood routing (out_path_len = 0xFF). Used to
|
||||
/// unstick direct messages to contacts whose `path_len=0` means "no route
|
||||
/// known" — without this, the firmware silently drops outbound TXT_MSG
|
||||
/// frames to such contacts.
|
||||
pub const CMD_RESET_PATH: u8 = 0x0D;
|
||||
/// CMD_ADD_UPDATE_CONTACT (0x09): add or update a contact in the firmware
|
||||
/// table. 144-byte frame (see `build_add_contact`).
|
||||
pub const CMD_ADD_UPDATE_CONTACT: u8 = 0x09;
|
||||
/// CMD_REMOVE_CONTACT (0x0F): `[0x0F][pub_key:32]` — delete a contact from the
|
||||
/// firmware's persistent table (used by clear-all so wiped contacts actually
|
||||
/// go away and only return when they re-advertise).
|
||||
pub const CMD_REMOVE_CONTACT: u8 = 0x0F;
|
||||
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;
|
||||
/// Archipelago-internal synthetic response code used by the Meshtastic adapter
|
||||
/// for text DMs that the firmware reports as PKI-encrypted. Meshcore firmware
|
||||
/// never emits this code; it lets the shared listener persist the E2E badge
|
||||
/// without changing the on-wire Meshcore frame format.
|
||||
pub const RESP_CONTACT_MSG_V3_E2E: u8 = 0x13;
|
||||
/// Archipelago-internal synthetic response code used by the Meshtastic adapter
|
||||
/// for CHANNEL broadcast text (e.g. the default public LongFast channel). Unlike
|
||||
/// the Meshcore `RESP_CHANNEL_MSG_V3` — which carries no sender — a Meshtastic
|
||||
/// MeshPacket gives us the originating node, so the listener can both file the
|
||||
/// message under the channel thread AND attribute it to its sender. Frame
|
||||
/// layout: `[channel_idx: u8][sender_pubkey_prefix: 6 bytes][text…]`. Kept below
|
||||
/// 0x80 so it is not mistaken for a device push notification; Meshcore never
|
||||
/// emits it.
|
||||
pub const RESP_MESHTASTIC_CHANNEL_TEXT: u8 = 0x70;
|
||||
|
||||
// --- 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;
|
||||
|
||||
/// Marker byte for "direct message wrapped as channel broadcast". Our
|
||||
/// meshcore devices can hear each other's channel broadcasts (via
|
||||
/// repeater flooding) but direct unicast frames don't reach between
|
||||
/// archipelago nodes — so we emulate DMs by sending them on the shared
|
||||
/// channel with a recipient pubkey-prefix header. Format:
|
||||
/// `[DM_VIA_CHANNEL_MARKER][dest_pubkey_prefix(6B)][inner_payload…]`
|
||||
/// The inner payload is whatever we would have sent directly — a typed
|
||||
/// envelope, a chunked MC frame, or plain text.
|
||||
pub const DM_VIA_CHANNEL_MARKER: u8 = 0xD1;
|
||||
|
||||
/// 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_RADIO_PARAMS (0x0B): set the LoRa PHY config. The device reboots to
|
||||
/// apply. `freq_field` and `bw_field` are the raw firmware fields (freq =
|
||||
/// MHz×1000 e.g. 869618 for 869.618 MHz; bw = kHz×1000 e.g. 62500 for 62.5 kHz);
|
||||
/// `sf` is 5..=12 and `cr` is 5..=8. Wire format verified against the MeshCore
|
||||
/// companion firmware handler (`examples/companion_radio/MyMesh.cpp`,
|
||||
/// `CMD_SET_RADIO_PARAMS`): `[11][freq:u32 LE][bw:u32 LE][sf:u8][cr:u8]`. The
|
||||
/// same fields (same units) come back in the SELF_INFO reply, so a caller can
|
||||
/// read them to detect drift. Values outside the firmware's accepted ranges are
|
||||
/// rejected by the device (it replies with an error frame), not clamped here.
|
||||
pub fn build_set_radio_params(freq_field: u32, bw_field: u32, sf: u8, cr: u8) -> Vec<u8> {
|
||||
let mut data = vec![CMD_SET_RADIO_PARAMS];
|
||||
data.extend_from_slice(&freq_field.to_le_bytes());
|
||||
data.extend_from_slice(&bw_field.to_le_bytes());
|
||||
data.push(sf);
|
||||
data.push(cr);
|
||||
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.
|
||||
/// Frame layout per meshcore companion protocol:
|
||||
/// `[0x03][txt_type=0][channel][timestamp_le32][text…]`
|
||||
/// The txt_type and timestamp fields are mandatory — without them the
|
||||
/// firmware rejects the command with ERR_UNSUPPORTED.
|
||||
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 timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as u32)
|
||||
.unwrap_or(0);
|
||||
let mut data = vec![CMD_SEND_CHANNEL_TXT_MSG, 0x00, channel];
|
||||
data.extend_from_slice(×tamp.to_le_bytes());
|
||||
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_RESET_PATH (0x0D): `[0x0D][pub_key:32]`. Clears the stored route
|
||||
/// for a contact so subsequent sends route via flood instead of being
|
||||
/// silently dropped.
|
||||
pub fn build_reset_path(pubkey: &[u8; 32]) -> Vec<u8> {
|
||||
let mut data = vec![CMD_RESET_PATH];
|
||||
data.extend_from_slice(pubkey);
|
||||
encode_frame(&data)
|
||||
}
|
||||
|
||||
/// CMD_REMOVE_CONTACT (0x0F): `[0x0F][pub_key:32]`. Removes the contact from
|
||||
/// the firmware's persistent contact table.
|
||||
pub fn build_remove_contact(pubkey: &[u8; 32]) -> Vec<u8> {
|
||||
let mut data = vec![CMD_REMOVE_CONTACT];
|
||||
data.extend_from_slice(pubkey);
|
||||
encode_frame(&data)
|
||||
}
|
||||
|
||||
/// CMD_ADD_UPDATE_CONTACT (0x09): add/update a contact. 144-byte body:
|
||||
/// `[0x09][pub_key:32][type:1][flags:1][out_path_len:1][out_path:64][name:32]
|
||||
/// [last_advert:4 LE][adv_lat:4 LE][adv_lon:4 LE]`.
|
||||
/// `name` is zero-padded to 32 bytes (the firmware fills it from the heard
|
||||
/// advert on its side too, so an empty name still resolves on get-contacts).
|
||||
pub fn build_add_contact(
|
||||
pubkey: &[u8; 32],
|
||||
contact_type: u8,
|
||||
flags: u8,
|
||||
out_path_len: u8,
|
||||
name: &str,
|
||||
last_advert: u32,
|
||||
) -> Vec<u8> {
|
||||
let mut data = Vec::with_capacity(144);
|
||||
data.push(CMD_ADD_UPDATE_CONTACT);
|
||||
data.extend_from_slice(pubkey); // 32
|
||||
data.push(contact_type); // 1
|
||||
data.push(flags); // 1
|
||||
data.push(out_path_len); // 1
|
||||
data.extend_from_slice(&[0u8; 64]); // out_path (64)
|
||||
let mut name_buf = [0u8; 32];
|
||||
let nb = name.as_bytes();
|
||||
let n = nb.len().min(32);
|
||||
name_buf[..n].copy_from_slice(&nb[..n]);
|
||||
data.extend_from_slice(&name_buf); // name (32)
|
||||
data.extend_from_slice(&last_advert.to_le_bytes()); // last_advert (4)
|
||||
data.extend_from_slice(&0i32.to_le_bytes()); // adv_lat (4)
|
||||
data.extend_from_slice(&0i32.to_le_bytes()); // adv_lon (4)
|
||||
encode_frame(&data)
|
||||
}
|
||||
|
||||
/// 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 ───────────────────────────────────────────────────
|
||||
|
||||
/// Decode a device/contact name from raw frame bytes, defensively.
|
||||
///
|
||||
/// Name fields sit at firmware-version-dependent offsets; when an offset
|
||||
/// lands inside binary data (path/pubkey bytes), `from_utf8_lossy` used to
|
||||
/// hand the UI replacement-character soup ("�\u{618}…"). Strict rules: valid
|
||||
/// UTF-8 up to the first NUL, no control characters, at least one
|
||||
/// non-whitespace char — anything else gets the caller's readable fallback.
|
||||
fn decode_mesh_name(bytes: &[u8], fallback: &str) -> String {
|
||||
let end = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len());
|
||||
match std::str::from_utf8(&bytes[..end]) {
|
||||
Ok(s) => {
|
||||
let s = s.trim();
|
||||
if !s.is_empty() && s.chars().all(|c| !c.is_control()) {
|
||||
s.to_string()
|
||||
} else {
|
||||
fallback.to_string()
|
||||
}
|
||||
}
|
||||
Err(_) => fallback.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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. A firmware whose fixed-field layout
|
||||
// differs would put binary here — decode defensively so the settings
|
||||
// panel never shows byte soup.
|
||||
let name_start = 4;
|
||||
let name = if data.len() > name_start {
|
||||
decode_mesh_name(&data[name_start..], &format!("node-{node_id:08x}"))
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
Ok((node_id, name))
|
||||
}
|
||||
|
||||
/// Parsed contact from RESP_CONTACT (0x03).
|
||||
#[derive(Clone)]
|
||||
pub struct ParsedContact {
|
||||
pub public_key_hex: String,
|
||||
pub advert_name: String,
|
||||
pub last_advert: u32,
|
||||
pub contact_type: u8,
|
||||
pub path_len: u8,
|
||||
pub flags: u8,
|
||||
/// Whether this contact is end-to-end (PKI / Curve25519) capable. Only the
|
||||
/// Meshtastic adapter sets this (true once we've learned the peer's real
|
||||
/// NodeInfo public key, so the firmware delivers DMs PKC-encrypted). Meshcore
|
||||
/// contacts leave it `false` — their E2E status is tracked per-message.
|
||||
pub pkc_capable: bool,
|
||||
/// Signal strength (dBm) / signal-to-noise ratio (dB) of the most recently
|
||||
/// heard packet from this contact. Meshtastic-only today (from
|
||||
/// `MeshPacket.rx_rssi`/`.rx_snr`); other transports leave these `None`.
|
||||
pub rssi: Option<i16>,
|
||||
pub snr: Option<f32>,
|
||||
/// Last known position, from a Meshtastic `POSITION_APP` broadcast
|
||||
/// (`Position.latitude_i`/`.longitude_i`, degrees). `None` until the
|
||||
/// contact has shared one.
|
||||
pub lat: Option<f64>,
|
||||
pub lon: Option<f64>,
|
||||
/// Archipelago ed25519 identity hex, when this transport carried it
|
||||
/// in-band with the contact announce itself (Reticulum only today — the
|
||||
/// RNS announce's app_data can embed an `ARCHY:n:` identity blob
|
||||
/// alongside the destination hash in the same event, so there's no
|
||||
/// ambiguity about which physical peer it belongs to). Meshcore/
|
||||
/// Meshtastic identity adverts go out on a separate channel and are
|
||||
/// correlated after the fact by `bind_federation_twins`'s advert_name
|
||||
/// matching instead, so they always leave this `None`.
|
||||
pub arch_pubkey_hex: Option<String>,
|
||||
}
|
||||
|
||||
/// 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];
|
||||
let flags = if data.len() > 33 { data[33] } else { 0 };
|
||||
let path_len = if data.len() > 34 { data[34] } else { 0 };
|
||||
// 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 short_id = format!("{}...", &public_key_hex[..8]);
|
||||
let advert_name = if data.len() > name_start {
|
||||
decode_mesh_name(&data[name_start..name_end], &short_id)
|
||||
} else {
|
||||
short_id
|
||||
};
|
||||
|
||||
// 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,
|
||||
path_len,
|
||||
flags,
|
||||
// Meshcore tracks E2E per message, not per contact.
|
||||
pkc_capable: false,
|
||||
// Meshcore's own contact format does carry lat/lon at a fixed offset
|
||||
// (see the format comment above) but wiring that up is out of scope
|
||||
// for this Meshtastic-specific backlog item.
|
||||
rssi: None,
|
||||
snr: None,
|
||||
lat: None,
|
||||
lon: None,
|
||||
arch_pubkey_hex: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// 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))
|
||||
}
|
||||
|
||||
// ─── Raw-bytes variants for typed message detection ────────────────────
|
||||
|
||||
/// Parse RESP_CONTACT_MSG_V3 returning raw payload bytes (not UTF-8 lossy).
|
||||
/// Returns (sender_pubkey_prefix_hex, raw_payload_bytes, snr).
|
||||
pub fn parse_contact_msg_v3_raw(data: &[u8]) -> Result<(String, Vec<u8>, i8)> {
|
||||
if data.len() < 15 {
|
||||
anyhow::bail!("Contact message too short: {} bytes", data.len());
|
||||
}
|
||||
let snr = data[0] as i8;
|
||||
let pubkey_prefix = hex::encode(&data[3..9]);
|
||||
let txt_type = data[10];
|
||||
let text_start = if txt_type == 2 { 19 } else { 15 };
|
||||
let payload = if data.len() > text_start {
|
||||
data[text_start..].to_vec()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
Ok((pubkey_prefix, payload, snr))
|
||||
}
|
||||
|
||||
/// Parse RESP_CONTACT_MSG returning raw payload bytes.
|
||||
/// Returns (sender_pubkey_prefix_hex, raw_payload_bytes).
|
||||
pub fn parse_contact_msg_v1_raw(data: &[u8]) -> Result<(String, Vec<u8>)> {
|
||||
if data.len() < 12 {
|
||||
anyhow::bail!("Contact message v1 too short: {} bytes", data.len());
|
||||
}
|
||||
let pubkey_prefix = hex::encode(&data[0..6]);
|
||||
let txt_type = data[7];
|
||||
let text_start = if txt_type == 2 { 16 } else { 12 };
|
||||
let payload = if data.len() > text_start {
|
||||
data[text_start..].to_vec()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
Ok((pubkey_prefix, payload))
|
||||
}
|
||||
|
||||
/// Parse RESP_CHANNEL_MSG_V3 returning raw payload bytes.
|
||||
/// Returns (channel_idx, raw_payload_bytes).
|
||||
pub fn parse_channel_msg_v3_raw(data: &[u8]) -> Result<(u8, Vec<u8>)> {
|
||||
if data.len() < 7 {
|
||||
anyhow::bail!("Channel message too short: {} bytes", data.len());
|
||||
}
|
||||
let channel_idx = data[0];
|
||||
let payload = if data.len() > 7 {
|
||||
let mut p = data[7..].to_vec();
|
||||
// Strip trailing NUL bytes
|
||||
while p.last() == Some(&0) {
|
||||
p.pop();
|
||||
}
|
||||
p
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
Ok((channel_idx, payload))
|
||||
}
|
||||
|
||||
/// Parse RESP_CHANNEL_MSG returning raw payload bytes.
|
||||
/// Returns (channel_idx, raw_payload_bytes).
|
||||
pub fn parse_channel_msg_v1_raw(data: &[u8]) -> Result<(u8, Vec<u8>)> {
|
||||
if data.len() < 7 {
|
||||
anyhow::bail!("Channel message v1 too short: {} bytes", data.len());
|
||||
}
|
||||
let channel_idx = data[0];
|
||||
let payload = if data.len() > 7 {
|
||||
let mut p = data[7..].to_vec();
|
||||
while p.last() == Some(&0) {
|
||||
p.pop();
|
||||
}
|
||||
p
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
Ok((channel_idx, payload))
|
||||
}
|
||||
|
||||
/// 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_build_set_radio_params_wire_layout() {
|
||||
// Portugal preset: 869.618 MHz, 62.5 kHz BW, SF 8, CR 8.
|
||||
// freq field = MHz*1000 = 869618; bw field = kHz*1000 = 62500.
|
||||
let frame = build_set_radio_params(869_618, 62_500, 8, 8);
|
||||
assert_eq!(frame[0], OUTBOUND_MARKER);
|
||||
// payload length = 1 (cmd) + 4 (freq) + 4 (bw) + 1 (sf) + 1 (cr) = 11
|
||||
assert_eq!(u16::from_le_bytes([frame[1], frame[2]]), 11);
|
||||
let data = &frame[3..];
|
||||
assert_eq!(data[0], CMD_SET_RADIO_PARAMS);
|
||||
assert_eq!(
|
||||
u32::from_le_bytes([data[1], data[2], data[3], data[4]]),
|
||||
869_618
|
||||
);
|
||||
assert_eq!(
|
||||
u32::from_le_bytes([data[5], data[6], data[7], data[8]]),
|
||||
62_500
|
||||
);
|
||||
assert_eq!(data[9], 8); // sf
|
||||
assert_eq!(data[10], 8); // cr
|
||||
assert_eq!(data.len(), 11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decode_frame_complete() -> Result<()> {
|
||||
// Simulate an inbound frame: < + len(2) + [RESP_OK]
|
||||
let buf = vec![INBOUND_MARKER, 0x01, 0x00, RESP_OK];
|
||||
let frame =
|
||||
decode_frame(&buf).ok_or_else(|| anyhow::anyhow!("failed to parse complete frame"))?;
|
||||
assert_eq!(frame.code, RESP_OK);
|
||||
assert!(frame.data.is_empty());
|
||||
assert_eq!(frame.bytes_consumed, 4);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decode_frame_with_data() -> Result<()> {
|
||||
// < + 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).ok_or_else(|| anyhow::anyhow!("failed to parse frame with data"))?;
|
||||
assert_eq!(frame.code, RESP_SELF_INFO);
|
||||
assert_eq!(frame.data, vec![0x01, 0x02, 0x03, 0x04]);
|
||||
assert_eq!(frame.bytes_consumed, 8);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[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() -> Result<()> {
|
||||
// Garbage bytes before the actual frame
|
||||
let buf = vec![0xFF, 0xAA, INBOUND_MARKER, 0x01, 0x00, RESP_OK];
|
||||
let frame = decode_frame(&buf)
|
||||
.ok_or_else(|| anyhow::anyhow!("failed to skip garbage and parse frame"))?;
|
||||
assert_eq!(frame.code, RESP_OK);
|
||||
assert_eq!(frame.bytes_consumed, 6); // 2 garbage + 4 frame
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[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() -> Result<()> {
|
||||
// Frame layout: [0: '>'][1-2: len LE][3: CMD][4: VERSION][5..: padded name]
|
||||
let frame = build_app_start("Archipelago");
|
||||
assert_eq!(frame[3], CMD_APP_START);
|
||||
assert_eq!(frame[4], PROTOCOL_VERSION);
|
||||
let name = &frame[5..];
|
||||
assert_eq!(
|
||||
std::str::from_utf8(name)
|
||||
.map_err(|e| anyhow::anyhow!("invalid UTF-8 in app name: {}", e))?,
|
||||
"Archipelago"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[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() -> Result<()> {
|
||||
let dest: [u8; 6] = [0x00, 0x00, 0x00, 0x2A, 0x00, 0x00];
|
||||
let frame = build_send_text(&dest, b"hello")?;
|
||||
assert_eq!(frame[3], CMD_SEND_TXT_MSG);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_send_text_too_large() {
|
||||
let dest: [u8; 6] = [0x00; 6];
|
||||
let big = vec![0u8; MAX_MESSAGE_LEN + 1];
|
||||
assert!(build_send_text(&dest, &big).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_send_channel_text() -> Result<()> {
|
||||
let frame = build_send_channel_text(2, b"test")?;
|
||||
// Frame: [marker][len_lo][len_hi][cmd][txt_type][channel][ts(4)][text]
|
||||
assert_eq!(frame[3], CMD_SEND_CHANNEL_TXT_MSG);
|
||||
assert_eq!(frame[4], 0); // txt_type
|
||||
assert_eq!(frame[5], 2); // channel idx
|
||||
// frame[6..10] = timestamp, non-deterministic
|
||||
assert_eq!(&frame[10..], b"test");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_identity_broadcast_roundtrip() -> Result<()> {
|
||||
// The v2 encoding drops the DID and the decoder reconstructs it
|
||||
// deterministically from the ed25519 pubkey, so the roundtripped
|
||||
// DID won't equal an arbitrary input DID. Derive what the decoder
|
||||
// will produce and assert against that.
|
||||
let ed_pub = "a".repeat(64);
|
||||
let x25519_pub = "b".repeat(64);
|
||||
let expected_did = crate::identity::did_key_from_pubkey_hex(&ed_pub)
|
||||
.map_err(|e| anyhow::anyhow!("derive did: {}", e))?;
|
||||
|
||||
let encoded = encode_identity_broadcast(&expected_did, &ed_pub, &x25519_pub);
|
||||
|
||||
let (parsed_did, parsed_ed, parsed_x) = parse_identity_broadcast(&encoded)
|
||||
.ok_or_else(|| anyhow::anyhow!("failed to parse identity broadcast"))?;
|
||||
assert_eq!(parsed_did, expected_did);
|
||||
assert_eq!(parsed_ed, ed_pub);
|
||||
assert_eq!(parsed_x, x25519_pub);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[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() -> Result<()> {
|
||||
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)?;
|
||||
assert_eq!(id, 42);
|
||||
assert_eq!(name, "TestNode");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_self_info_too_short() {
|
||||
assert!(parse_self_info(&[0x01, 0x02]).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
// WIP mesh/transport protocol — suppress dead code warnings
|
||||
#![allow(dead_code)]
|
||||
//! Double Ratchet protocol for forward-secret mesh messaging.
|
||||
//!
|
||||
//! Implements the Signal protocol's Double Ratchet algorithm:
|
||||
//! - DH ratchet: new X25519 ephemeral keypair per DH step
|
||||
//! - Symmetric-key ratchet: HKDF-SHA256 chain for message keys
|
||||
//! - Forward secrecy: compromising current key doesn't reveal past messages
|
||||
//!
|
||||
//! Wire format per message:
|
||||
//! ```text
|
||||
//! [RatchetHeader: 40 bytes] [nonce: 12] [ciphertext] [tag: 16]
|
||||
//! ```
|
||||
//!
|
||||
//! Reference: Signal Technical Documentation — Double Ratchet Algorithm
|
||||
|
||||
use super::crypto;
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use zeroize::Zeroize;
|
||||
|
||||
/// HKDF info string for root key + chain key derivation.
|
||||
const KDF_RK_INFO: &[u8] = b"ArchyRatchetRK";
|
||||
|
||||
/// HKDF info string for message key derivation from chain key.
|
||||
const KDF_CK_INFO: &[u8] = b"ArchyRatchetCK";
|
||||
|
||||
/// Maximum number of skipped message keys to store (prevents DoS).
|
||||
const MAX_SKIP: u32 = 100;
|
||||
|
||||
/// Ratchet message header sent with every encrypted message.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RatchetHeader {
|
||||
/// Sender's current DH ratchet public key (32 bytes).
|
||||
#[serde(with = "hex_bytes")]
|
||||
pub dh_public: [u8; 32],
|
||||
/// Number of messages in the previous sending chain.
|
||||
pub prev_chain_n: u32,
|
||||
/// Message number in the current sending chain.
|
||||
pub message_n: u32,
|
||||
}
|
||||
|
||||
impl RatchetHeader {
|
||||
/// Serialize header to bytes (fixed 40 bytes).
|
||||
pub fn to_bytes(&self) -> [u8; 40] {
|
||||
let mut buf = [0u8; 40];
|
||||
buf[..32].copy_from_slice(&self.dh_public);
|
||||
buf[32..36].copy_from_slice(&self.prev_chain_n.to_le_bytes());
|
||||
buf[36..40].copy_from_slice(&self.message_n.to_le_bytes());
|
||||
buf
|
||||
}
|
||||
|
||||
/// Parse header from bytes.
|
||||
pub fn from_bytes(data: &[u8; 40]) -> Self {
|
||||
let mut dh_public = [0u8; 32];
|
||||
dh_public.copy_from_slice(&data[..32]);
|
||||
let prev_chain_n = u32::from_le_bytes([data[32], data[33], data[34], data[35]]);
|
||||
let message_n = u32::from_le_bytes([data[36], data[37], data[38], data[39]]);
|
||||
Self {
|
||||
dh_public,
|
||||
prev_chain_n,
|
||||
message_n,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A complete ratchet-encrypted message (header + ciphertext).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RatchetMessage {
|
||||
pub header: RatchetHeader,
|
||||
pub ciphertext: Vec<u8>, // nonce(12) + encrypted(N) + tag(16)
|
||||
}
|
||||
|
||||
impl RatchetMessage {
|
||||
/// Serialize to wire format: header(40) + ciphertext.
|
||||
pub fn to_bytes(&self) -> Vec<u8> {
|
||||
let header_bytes = self.header.to_bytes();
|
||||
let mut buf = Vec::with_capacity(40 + self.ciphertext.len());
|
||||
buf.extend_from_slice(&header_bytes);
|
||||
buf.extend_from_slice(&self.ciphertext);
|
||||
buf
|
||||
}
|
||||
|
||||
/// Parse from wire format.
|
||||
pub fn from_bytes(data: &[u8]) -> Result<Self> {
|
||||
if data.len() < 40 + 12 + 16 + 1 {
|
||||
anyhow::bail!("Ratchet message too short: {} bytes", data.len());
|
||||
}
|
||||
let mut header_bytes = [0u8; 40];
|
||||
header_bytes.copy_from_slice(&data[..40]);
|
||||
Ok(Self {
|
||||
header: RatchetHeader::from_bytes(&header_bytes),
|
||||
ciphertext: data[40..].to_vec(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-peer Double Ratchet state.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct RatchetState {
|
||||
// DH ratchet: our current ephemeral keypair
|
||||
dh_self_secret: [u8; 32],
|
||||
dh_self_public: [u8; 32],
|
||||
// DH ratchet: peer's last known public key
|
||||
dh_remote_public: Option<[u8; 32]>,
|
||||
// Root key (ratcheted on each DH step)
|
||||
root_key: [u8; 32],
|
||||
// Sending chain key
|
||||
chain_key_send: Option<[u8; 32]>,
|
||||
// Receiving chain key
|
||||
chain_key_recv: Option<[u8; 32]>,
|
||||
// Message counters
|
||||
send_n: u32,
|
||||
recv_n: u32,
|
||||
prev_send_n: u32,
|
||||
// Skipped message keys for out-of-order delivery
|
||||
// Key: (dh_public_hex, message_number)
|
||||
skipped_keys: HashMap<(String, u32), [u8; 32]>,
|
||||
}
|
||||
|
||||
impl Drop for RatchetState {
|
||||
fn drop(&mut self) {
|
||||
self.dh_self_secret.zeroize();
|
||||
self.root_key.zeroize();
|
||||
if let Some(ref mut k) = self.chain_key_send {
|
||||
k.zeroize();
|
||||
}
|
||||
if let Some(ref mut k) = self.chain_key_recv {
|
||||
k.zeroize();
|
||||
}
|
||||
for (_, v) in self.skipped_keys.iter_mut() {
|
||||
v.zeroize();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RatchetState {
|
||||
/// Initialize as the session initiator (the one who performed X3DH initiate).
|
||||
/// The initiator sends the first message, so they start with a sending chain.
|
||||
pub fn init_as_sender(
|
||||
root_key: [u8; 32],
|
||||
their_signed_prekey_public: &[u8; 32],
|
||||
) -> Result<Self> {
|
||||
let (dh_secret, dh_public) = crypto::generate_x25519_ephemeral();
|
||||
|
||||
// First DH ratchet step: derive sending chain key
|
||||
let dh_output = crypto::x25519_shared_secret(&dh_secret, their_signed_prekey_public);
|
||||
let (new_root_key, chain_key_send) =
|
||||
crypto::hkdf_sha256_64(&root_key, &dh_output, KDF_RK_INFO)?;
|
||||
|
||||
Ok(Self {
|
||||
dh_self_secret: dh_secret,
|
||||
dh_self_public: dh_public,
|
||||
dh_remote_public: Some(*their_signed_prekey_public),
|
||||
root_key: new_root_key,
|
||||
chain_key_send: Some(chain_key_send),
|
||||
chain_key_recv: None,
|
||||
send_n: 0,
|
||||
recv_n: 0,
|
||||
prev_send_n: 0,
|
||||
skipped_keys: HashMap::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Initialize as the session receiver (the one who performed X3DH respond).
|
||||
/// The receiver waits for the first message before creating their sending chain.
|
||||
pub fn init_as_receiver(
|
||||
root_key: [u8; 32],
|
||||
our_signed_prekey_secret: [u8; 32],
|
||||
our_signed_prekey_public: [u8; 32],
|
||||
) -> Self {
|
||||
Self {
|
||||
dh_self_secret: our_signed_prekey_secret,
|
||||
dh_self_public: our_signed_prekey_public,
|
||||
dh_remote_public: None,
|
||||
root_key,
|
||||
chain_key_send: None,
|
||||
chain_key_recv: None,
|
||||
send_n: 0,
|
||||
recv_n: 0,
|
||||
prev_send_n: 0,
|
||||
skipped_keys: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Encrypt a plaintext message.
|
||||
/// Ratchets the sending chain forward, derives a per-message key,
|
||||
/// and encrypts with ChaCha20-Poly1305.
|
||||
pub fn encrypt(&mut self, plaintext: &[u8]) -> Result<RatchetMessage> {
|
||||
let chain_key = self.chain_key_send.ok_or_else(|| {
|
||||
anyhow::anyhow!("No sending chain key — session not fully initialized")
|
||||
})?;
|
||||
|
||||
// Derive message key from chain key
|
||||
let (new_chain_key, message_key) = kdf_chain_key(&chain_key)?;
|
||||
self.chain_key_send = Some(new_chain_key);
|
||||
|
||||
// Encrypt with message key
|
||||
let ciphertext = crypto::encrypt(&message_key, plaintext)?;
|
||||
|
||||
let header = RatchetHeader {
|
||||
dh_public: self.dh_self_public,
|
||||
prev_chain_n: self.prev_send_n,
|
||||
message_n: self.send_n,
|
||||
};
|
||||
|
||||
self.send_n += 1;
|
||||
|
||||
Ok(RatchetMessage { header, ciphertext })
|
||||
}
|
||||
|
||||
/// Decrypt a received ratchet message.
|
||||
/// Handles DH ratchet steps, out-of-order messages via skipped keys.
|
||||
pub fn decrypt(&mut self, message: &RatchetMessage) -> Result<Vec<u8>> {
|
||||
// 1. Try skipped message keys first (out-of-order delivery)
|
||||
let dh_hex = hex::encode(message.header.dh_public);
|
||||
if let Some(mk) = self
|
||||
.skipped_keys
|
||||
.remove(&(dh_hex.clone(), message.header.message_n))
|
||||
{
|
||||
return crypto::decrypt(&mk, &message.ciphertext);
|
||||
}
|
||||
|
||||
// 2. Check if we need a DH ratchet step (new DH public key from peer)
|
||||
let need_dh_ratchet = match self.dh_remote_public {
|
||||
None => true,
|
||||
Some(ref remote) => remote != &message.header.dh_public,
|
||||
};
|
||||
|
||||
if need_dh_ratchet {
|
||||
// Skip any remaining messages in the current receiving chain
|
||||
if self.chain_key_recv.is_some() {
|
||||
self.skip_message_keys(message.header.prev_chain_n)?;
|
||||
}
|
||||
|
||||
// DH ratchet step: derive new receiving chain
|
||||
let dh_output =
|
||||
crypto::x25519_shared_secret(&self.dh_self_secret, &message.header.dh_public);
|
||||
let (new_root_key, chain_key_recv) =
|
||||
crypto::hkdf_sha256_64(&self.root_key, &dh_output, KDF_RK_INFO)?;
|
||||
self.root_key = new_root_key;
|
||||
self.chain_key_recv = Some(chain_key_recv);
|
||||
self.dh_remote_public = Some(message.header.dh_public);
|
||||
self.prev_send_n = self.send_n;
|
||||
self.send_n = 0;
|
||||
self.recv_n = 0;
|
||||
|
||||
// Generate new DH keypair for our next sending chain
|
||||
let (new_secret, new_public) = crypto::generate_x25519_ephemeral();
|
||||
let dh_output2 = crypto::x25519_shared_secret(&new_secret, &message.header.dh_public);
|
||||
let (new_root_key2, chain_key_send) =
|
||||
crypto::hkdf_sha256_64(&self.root_key, &dh_output2, KDF_RK_INFO)?;
|
||||
self.root_key = new_root_key2;
|
||||
self.chain_key_send = Some(chain_key_send);
|
||||
self.dh_self_secret.zeroize();
|
||||
self.dh_self_secret = new_secret;
|
||||
self.dh_self_public = new_public;
|
||||
}
|
||||
|
||||
// 3. Skip any messages before this one in the current chain
|
||||
self.skip_message_keys(message.header.message_n)?;
|
||||
|
||||
// 4. Derive message key and decrypt
|
||||
let chain_key = self
|
||||
.chain_key_recv
|
||||
.ok_or_else(|| anyhow::anyhow!("No receiving chain key"))?;
|
||||
let (new_chain_key, message_key) = kdf_chain_key(&chain_key)?;
|
||||
self.chain_key_recv = Some(new_chain_key);
|
||||
self.recv_n += 1;
|
||||
|
||||
crypto::decrypt(&message_key, &message.ciphertext)
|
||||
}
|
||||
|
||||
/// Skip message keys up to `until` (exclusive) and store them for later.
|
||||
fn skip_message_keys(&mut self, until: u32) -> Result<()> {
|
||||
if self.recv_n + MAX_SKIP < until {
|
||||
anyhow::bail!(
|
||||
"Too many skipped messages: {} (max {})",
|
||||
until - self.recv_n,
|
||||
MAX_SKIP
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(mut chain_key) = self.chain_key_recv {
|
||||
while self.recv_n < until {
|
||||
let (new_chain_key, message_key) = kdf_chain_key(&chain_key)?;
|
||||
let dh_hex = self.dh_remote_public.map(hex::encode).unwrap_or_default();
|
||||
self.skipped_keys.insert((dh_hex, self.recv_n), message_key);
|
||||
chain_key = new_chain_key;
|
||||
self.recv_n += 1;
|
||||
|
||||
// Evict oldest if over limit
|
||||
if self.skipped_keys.len() > MAX_SKIP as usize {
|
||||
if let Some(key) = self.skipped_keys.keys().next().cloned() {
|
||||
self.skipped_keys.remove(&key);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.chain_key_recv = Some(chain_key);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the current DH ratchet generation (number of DH steps).
|
||||
pub fn generation(&self) -> u32 {
|
||||
self.prev_send_n + self.send_n
|
||||
}
|
||||
|
||||
/// Total messages sent in this session.
|
||||
pub fn total_sent(&self) -> u32 {
|
||||
self.prev_send_n + self.send_n
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive a message key from a chain key using HKDF.
|
||||
/// Returns (new_chain_key, message_key).
|
||||
fn kdf_chain_key(chain_key: &[u8; 32]) -> Result<([u8; 32], [u8; 32])> {
|
||||
crypto::hkdf_sha256_64(chain_key, &[0x01], KDF_CK_INFO)
|
||||
}
|
||||
|
||||
// ─── Hex serde helper ───────────────────────────────────────────────────
|
||||
|
||||
mod hex_bytes {
|
||||
use serde::{Deserialize, Deserializer, Serializer};
|
||||
|
||||
pub fn serialize<S: Serializer>(bytes: &[u8; 32], s: S) -> Result<S::Ok, S::Error> {
|
||||
s.serialize_str(&hex::encode(bytes))
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<[u8; 32], D::Error> {
|
||||
let s = String::deserialize(d)?;
|
||||
let bytes = hex::decode(&s).map_err(serde::de::Error::custom)?;
|
||||
if bytes.len() != 32 {
|
||||
return Err(serde::de::Error::custom("expected 32 bytes"));
|
||||
}
|
||||
let mut arr = [0u8; 32];
|
||||
arr.copy_from_slice(&bytes);
|
||||
Ok(arr)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Simulate a full conversation between Alice and Bob.
|
||||
#[test]
|
||||
fn test_ratchet_conversation() {
|
||||
// Shared root key from X3DH (normally derived, here mocked)
|
||||
let root_key = [42u8; 32];
|
||||
|
||||
// Bob's signed prekey (normally from X3DH bundle)
|
||||
let (bob_spk_secret, bob_spk_public) = crypto::generate_x25519_ephemeral();
|
||||
|
||||
// Alice (sender) initializes
|
||||
let mut alice = RatchetState::init_as_sender(root_key, &bob_spk_public).unwrap();
|
||||
|
||||
// Bob (receiver) initializes
|
||||
let mut bob = RatchetState::init_as_receiver(root_key, bob_spk_secret, bob_spk_public);
|
||||
|
||||
// Alice sends message 1
|
||||
let msg1 = alice.encrypt(b"Hello Bob, from mesh!").unwrap();
|
||||
let plain1 = bob.decrypt(&msg1).unwrap();
|
||||
assert_eq!(plain1, b"Hello Bob, from mesh!");
|
||||
|
||||
// Bob replies
|
||||
let msg2 = bob.encrypt(b"Hey Alice, loud and clear").unwrap();
|
||||
let plain2 = alice.decrypt(&msg2).unwrap();
|
||||
assert_eq!(plain2, b"Hey Alice, loud and clear");
|
||||
|
||||
// Alice sends again (new DH ratchet step)
|
||||
let msg3 = alice.encrypt(b"Block 890412 confirmed").unwrap();
|
||||
let plain3 = bob.decrypt(&msg3).unwrap();
|
||||
assert_eq!(plain3, b"Block 890412 confirmed");
|
||||
|
||||
// Bob sends multiple in a row
|
||||
let msg4 = bob.encrypt(b"Opening channel").unwrap();
|
||||
let msg5 = bob.encrypt(b"500k sats capacity").unwrap();
|
||||
let plain4 = alice.decrypt(&msg4).unwrap();
|
||||
let plain5 = alice.decrypt(&msg5).unwrap();
|
||||
assert_eq!(plain4, b"Opening channel");
|
||||
assert_eq!(plain5, b"500k sats capacity");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_out_of_order_delivery() {
|
||||
let root_key = [99u8; 32];
|
||||
let (spk_secret, spk_public) = crypto::generate_x25519_ephemeral();
|
||||
|
||||
let mut alice = RatchetState::init_as_sender(root_key, &spk_public).unwrap();
|
||||
let mut bob = RatchetState::init_as_receiver(root_key, spk_secret, spk_public);
|
||||
|
||||
// Alice sends 3 messages
|
||||
let msg1 = alice.encrypt(b"first").unwrap();
|
||||
let msg2 = alice.encrypt(b"second").unwrap();
|
||||
let msg3 = alice.encrypt(b"third").unwrap();
|
||||
|
||||
// Bob receives out of order: 3, 1, 2
|
||||
let p3 = bob.decrypt(&msg3).unwrap();
|
||||
assert_eq!(p3, b"third");
|
||||
let p1 = bob.decrypt(&msg1).unwrap();
|
||||
assert_eq!(p1, b"first");
|
||||
let p2 = bob.decrypt(&msg2).unwrap();
|
||||
assert_eq!(p2, b"second");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_forward_secrecy() {
|
||||
// After DH ratchet steps, old keys are destroyed
|
||||
let root_key = [77u8; 32];
|
||||
let (spk_secret, spk_public) = crypto::generate_x25519_ephemeral();
|
||||
|
||||
let mut alice = RatchetState::init_as_sender(root_key, &spk_public).unwrap();
|
||||
let mut bob = RatchetState::init_as_receiver(root_key, spk_secret, spk_public);
|
||||
|
||||
// Exchange messages to ratchet forward
|
||||
let msg1 = alice.encrypt(b"msg1").unwrap();
|
||||
bob.decrypt(&msg1).unwrap();
|
||||
let msg2 = bob.encrypt(b"msg2").unwrap();
|
||||
alice.decrypt(&msg2).unwrap();
|
||||
|
||||
// At this point, both have ratcheted. The original root_key
|
||||
// and initial chain keys are no longer in memory.
|
||||
// We can verify the state has evolved:
|
||||
assert_ne!(alice.root_key, root_key);
|
||||
assert_ne!(bob.root_key, root_key);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_message_wire_format() {
|
||||
let header = RatchetHeader {
|
||||
dh_public: [0xAA; 32],
|
||||
prev_chain_n: 5,
|
||||
message_n: 12,
|
||||
};
|
||||
let bytes = header.to_bytes();
|
||||
assert_eq!(bytes.len(), 40);
|
||||
|
||||
let parsed = RatchetHeader::from_bytes(&bytes);
|
||||
assert_eq!(parsed.dh_public, [0xAA; 32]);
|
||||
assert_eq!(parsed.prev_chain_n, 5);
|
||||
assert_eq!(parsed.message_n, 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ratchet_message_roundtrip() {
|
||||
let msg = RatchetMessage {
|
||||
header: RatchetHeader {
|
||||
dh_public: [0xBB; 32],
|
||||
prev_chain_n: 0,
|
||||
message_n: 0,
|
||||
},
|
||||
ciphertext: vec![[0x01, 0x02, 0x03]; 30].into_iter().flatten().collect(),
|
||||
};
|
||||
let bytes = msg.to_bytes();
|
||||
let parsed = RatchetMessage::from_bytes(&bytes).unwrap();
|
||||
assert_eq!(parsed.header.dh_public, [0xBB; 32]);
|
||||
assert_eq!(parsed.ciphertext.len(), msg.ciphertext.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_long_conversation() {
|
||||
let root_key = [11u8; 32];
|
||||
let (spk_secret, spk_public) = crypto::generate_x25519_ephemeral();
|
||||
|
||||
let mut alice = RatchetState::init_as_sender(root_key, &spk_public).unwrap();
|
||||
let mut bob = RatchetState::init_as_receiver(root_key, spk_secret, spk_public);
|
||||
|
||||
// 50 messages back and forth
|
||||
for i in 0..50 {
|
||||
let msg_text = format!(
|
||||
"Message #{} from {}",
|
||||
i,
|
||||
if i % 2 == 0 { "Alice" } else { "Bob" }
|
||||
);
|
||||
if i % 2 == 0 {
|
||||
let msg = alice.encrypt(msg_text.as_bytes()).unwrap();
|
||||
let decrypted = bob.decrypt(&msg).unwrap();
|
||||
assert_eq!(decrypted, msg_text.as_bytes());
|
||||
} else {
|
||||
let msg = bob.encrypt(msg_text.as_bytes()).unwrap();
|
||||
let decrypted = alice.decrypt(&msg).unwrap();
|
||||
assert_eq!(decrypted, msg_text.as_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,387 @@
|
||||
//! Persisted RNode LoRa RF settings — the operator-editable half of the
|
||||
//! Reticulum transport (.126 LoRa settings panel).
|
||||
//!
|
||||
//! The reticulum sidecar (reticulum-daemon) writes the RNS config from its
|
||||
//! CLI args at every spawn; before this module those args were never passed,
|
||||
//! so every node ran the sidecar's argparse defaults and nothing was
|
||||
//! operator-editable. These settings persist at
|
||||
//! `<data_dir>/rnode-rf-settings.json`, feed `daemon_command` as explicit
|
||||
//! args, and the panel confirms application via the sidecar's `radio_state`
|
||||
//! read-back (the radio-confirmed `r_*` values, not the requested ones).
|
||||
//!
|
||||
//! An absent file yields [`RNodeRfSettings::default`], which matches the
|
||||
//! sidecar's historical argparse defaults exactly — deploying this changes
|
||||
//! nothing until the operator edits something.
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
|
||||
const SETTINGS_FILE: &str = "rnode-rf-settings.json";
|
||||
|
||||
/// Validation bounds mirror RNS `RNodeInterface.py` (`validate_firmware` /
|
||||
/// the constructor checks) — NOT guessed: frequency 137–1020 MHz, sf 5–12,
|
||||
/// cr 5–8, txpower 0–22 dBm, airtime locks 0–100 %.
|
||||
const FREQ_MIN_HZ: u64 = 137_000_000;
|
||||
const FREQ_MAX_HZ: u64 = 1_020_000_000;
|
||||
/// The discrete bandwidths RNode firmware accepts (Hz).
|
||||
const VALID_BANDWIDTHS: &[u64] = &[
|
||||
7_800, 10_400, 15_600, 20_800, 31_250, 41_700, 62_500, 125_000, 250_000, 500_000,
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct RNodeRfSettings {
|
||||
/// Interface on/off. `false` keeps the daemon from opening the radio at
|
||||
/// all (the mesh service skips the serial transport).
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
/// Serial device override (e.g. `/dev/ttyACM0`). `None` = auto-detect,
|
||||
/// which is what every node did before this existed.
|
||||
#[serde(default)]
|
||||
pub port: Option<String>,
|
||||
#[serde(default = "default_frequency")]
|
||||
pub frequency: u64,
|
||||
#[serde(default = "default_bandwidth")]
|
||||
pub bandwidth: u64,
|
||||
#[serde(default = "default_spreading_factor")]
|
||||
pub spreading_factor: u8,
|
||||
#[serde(default = "default_coding_rate")]
|
||||
pub coding_rate: u8,
|
||||
#[serde(default = "default_txpower")]
|
||||
pub txpower: u8,
|
||||
/// Short-window airtime duty-cycle lock, percent (EU868: 25). `None` =
|
||||
/// no software lock (RNS default).
|
||||
#[serde(default)]
|
||||
pub airtime_limit_short: Option<f64>,
|
||||
/// Long-window airtime duty-cycle lock, percent (EU868: 10).
|
||||
#[serde(default)]
|
||||
pub airtime_limit_long: Option<f64>,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
fn default_frequency() -> u64 {
|
||||
869_525_000
|
||||
}
|
||||
fn default_bandwidth() -> u64 {
|
||||
125_000
|
||||
}
|
||||
fn default_spreading_factor() -> u8 {
|
||||
8
|
||||
}
|
||||
fn default_coding_rate() -> u8 {
|
||||
5
|
||||
}
|
||||
fn default_txpower() -> u8 {
|
||||
17
|
||||
}
|
||||
|
||||
impl Default for RNodeRfSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
port: None,
|
||||
frequency: default_frequency(),
|
||||
bandwidth: default_bandwidth(),
|
||||
spreading_factor: default_spreading_factor(),
|
||||
coding_rate: default_coding_rate(),
|
||||
txpower: default_txpower(),
|
||||
airtime_limit_short: None,
|
||||
airtime_limit_long: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RNodeRfSettings {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
if !(FREQ_MIN_HZ..=FREQ_MAX_HZ).contains(&self.frequency) {
|
||||
bail!(
|
||||
"frequency {} Hz is outside the RNode range ({}–{} Hz)",
|
||||
self.frequency,
|
||||
FREQ_MIN_HZ,
|
||||
FREQ_MAX_HZ
|
||||
);
|
||||
}
|
||||
if !VALID_BANDWIDTHS.contains(&self.bandwidth) {
|
||||
bail!(
|
||||
"bandwidth {} Hz is not an RNode bandwidth (valid: {:?})",
|
||||
self.bandwidth,
|
||||
VALID_BANDWIDTHS
|
||||
);
|
||||
}
|
||||
if !(5..=12).contains(&self.spreading_factor) {
|
||||
bail!("spreading factor {} is outside 5–12", self.spreading_factor);
|
||||
}
|
||||
if !(5..=8).contains(&self.coding_rate) {
|
||||
bail!("coding rate {} is outside 5–8", self.coding_rate);
|
||||
}
|
||||
if self.txpower > 22 {
|
||||
bail!(
|
||||
"tx power {} dBm is above the 22 dBm RNode maximum",
|
||||
self.txpower
|
||||
);
|
||||
}
|
||||
for (label, v) in [
|
||||
("airtime_limit_short", self.airtime_limit_short),
|
||||
("airtime_limit_long", self.airtime_limit_long),
|
||||
] {
|
||||
if let Some(pct) = v {
|
||||
if !(0.0..=100.0).contains(&pct) || !pct.is_finite() {
|
||||
bail!("{label} {pct} is not a percentage (0–100)");
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(port) = &self.port {
|
||||
// Same shape the flasher accepts: an absolute device node. Keeps
|
||||
// shell-metacharacter garbage out of the sidecar's argv.
|
||||
if !port.starts_with("/dev/")
|
||||
|| port.chars().any(|c| {
|
||||
!(c.is_ascii_alphanumeric() || c == '/' || c == '_' || c == '-' || c == '.')
|
||||
})
|
||||
{
|
||||
bail!("port must be an absolute /dev device path");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn load(data_dir: &Path) -> Self {
|
||||
let path = data_dir.join(SETTINGS_FILE);
|
||||
match tokio::fs::read_to_string(&path).await {
|
||||
Ok(raw) => match serde_json::from_str::<Self>(&raw) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "rnode-rf-settings.json unparseable — using defaults");
|
||||
Self::default()
|
||||
}
|
||||
},
|
||||
// First run after the update: no settings file yet. ADOPT the
|
||||
// node's existing effective RF config rather than imposing
|
||||
// defaults — the operator's standing requirement is that the
|
||||
// update changes NO device's applied settings. For archy-managed
|
||||
// radios the sidecar config equals our defaults anyway; this
|
||||
// covers any node whose RNS config diverged (hand edits,
|
||||
// hand-run rnsd).
|
||||
Err(_) => {
|
||||
let adopted = Self::adopt_existing_rns_config().await;
|
||||
if let Some(adopted) = adopted {
|
||||
tracing::info!(
|
||||
settings = ?adopted,
|
||||
"adopted existing RNS RNode config as initial RF settings"
|
||||
);
|
||||
if let Err(e) = adopted.save(data_dir).await {
|
||||
tracing::warn!(error = %e, "could not persist adopted RF settings");
|
||||
}
|
||||
adopted
|
||||
} else {
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the RNodeInterface section out of an existing RNS config file
|
||||
/// (the sidecar's `~/.archy-reticulum/config`, else a hand-run rnsd's
|
||||
/// `~/.reticulum/config`). Returns `None` when neither exists or no
|
||||
/// RNodeInterface section is found. Unparseable/absent fields keep the
|
||||
/// default (which equals the sidecar's historical argparse default).
|
||||
async fn adopt_existing_rns_config() -> Option<Self> {
|
||||
let home = std::env::var("HOME").ok()?;
|
||||
for candidate in [
|
||||
format!("{home}/.archy-reticulum/config"),
|
||||
format!("{home}/.reticulum/config"),
|
||||
] {
|
||||
let Ok(raw) = tokio::fs::read_to_string(&candidate).await else {
|
||||
continue;
|
||||
};
|
||||
if let Some(s) = Self::parse_rnode_section(&raw) {
|
||||
return Some(s);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Extract RNode parameters from RNS config text. Scoped to the block
|
||||
/// after a `type = RNodeInterface` line so TCP interface options can
|
||||
/// never bleed in; stops at the next `[[...]]` section header.
|
||||
fn parse_rnode_section(raw: &str) -> Option<Self> {
|
||||
let mut in_rnode = false;
|
||||
let mut seen_any = false;
|
||||
let mut s = Self::default();
|
||||
for line in raw.lines() {
|
||||
let line = line.trim();
|
||||
if line.starts_with("[[") {
|
||||
if in_rnode {
|
||||
break; // next interface section — RNode block ended
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let Some((key, value)) = line.split_once('=') else {
|
||||
continue;
|
||||
};
|
||||
let (key, value) = (key.trim(), value.trim());
|
||||
if key == "type" {
|
||||
in_rnode = value == "RNodeInterface";
|
||||
continue;
|
||||
}
|
||||
if !in_rnode {
|
||||
continue;
|
||||
}
|
||||
seen_any = true;
|
||||
match key {
|
||||
"enabled" | "interface_enabled" => {
|
||||
s.enabled = matches!(value.to_ascii_lowercase().as_str(), "yes" | "true" | "on")
|
||||
}
|
||||
"port" => s.port = Some(value.to_string()),
|
||||
"frequency" => s.frequency = value.parse().unwrap_or(s.frequency),
|
||||
"bandwidth" => s.bandwidth = value.parse().unwrap_or(s.bandwidth),
|
||||
"txpower" => s.txpower = value.parse().unwrap_or(s.txpower),
|
||||
"spreadingfactor" => {
|
||||
s.spreading_factor = value.parse().unwrap_or(s.spreading_factor)
|
||||
}
|
||||
"codingrate" => s.coding_rate = value.parse().unwrap_or(s.coding_rate),
|
||||
"airtime_limit_short" => s.airtime_limit_short = value.parse().ok(),
|
||||
"airtime_limit_long" => s.airtime_limit_long = value.parse().ok(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
(in_rnode || seen_any).then_some(s)
|
||||
}
|
||||
|
||||
pub async fn save(&self, data_dir: &Path) -> Result<()> {
|
||||
self.validate()?;
|
||||
let path = data_dir.join(SETTINGS_FILE);
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
let raw = serde_json::to_string_pretty(self)?;
|
||||
tokio::fs::write(&tmp, raw).await?;
|
||||
tokio::fs::rename(&tmp, &path).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn defaults_match_the_sidecar_argparse_defaults() {
|
||||
// reticulum_daemon.py: --frequency 869525000 --bandwidth 125000
|
||||
// --txpower 17 --spreadingfactor 8 --codingrate 5, no airtime locks.
|
||||
let d = RNodeRfSettings::default();
|
||||
assert_eq!(d.frequency, 869_525_000);
|
||||
assert_eq!(d.bandwidth, 125_000);
|
||||
assert_eq!(d.txpower, 17);
|
||||
assert_eq!(d.spreading_factor, 8);
|
||||
assert_eq!(d.coding_rate, 5);
|
||||
assert!(d.airtime_limit_short.is_none() && d.airtime_limit_long.is_none());
|
||||
assert!(d.enabled && d.port.is_none());
|
||||
d.validate().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operator_portugal_config_validates() {
|
||||
// The operator's real device config (2026-08-06).
|
||||
let s = RNodeRfSettings {
|
||||
enabled: true,
|
||||
port: Some("/dev/ttyACM0".into()),
|
||||
frequency: 869_462_500,
|
||||
bandwidth: 125_000,
|
||||
spreading_factor: 8,
|
||||
coding_rate: 5,
|
||||
txpower: 14,
|
||||
airtime_limit_short: Some(25.0),
|
||||
airtime_limit_long: Some(10.0),
|
||||
};
|
||||
s.validate().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adoption_preserves_the_operator_portugal_config_exactly() {
|
||||
// The operator's literal RNS config (2026-08-06). The update must
|
||||
// adopt these values verbatim — changing a node's applied RF
|
||||
// settings is forbidden.
|
||||
let raw = "\
|
||||
[reticulum]
|
||||
enable_transport = yes
|
||||
|
||||
[interfaces]
|
||||
[[RNode LoRa Portugal]]
|
||||
type = RNodeInterface
|
||||
interface_enabled = true
|
||||
port = /dev/ttyACM0
|
||||
frequency = 869462500
|
||||
bandwidth = 125000
|
||||
spreadingfactor = 8
|
||||
codingrate = 5
|
||||
txpower = 14
|
||||
airtime_limit_short = 25
|
||||
airtime_limit_long = 10
|
||||
";
|
||||
let s = RNodeRfSettings::parse_rnode_section(raw).expect("section found");
|
||||
assert!(s.enabled);
|
||||
assert_eq!(s.port.as_deref(), Some("/dev/ttyACM0"));
|
||||
assert_eq!(s.frequency, 869_462_500);
|
||||
assert_eq!(s.bandwidth, 125_000);
|
||||
assert_eq!(s.spreading_factor, 8);
|
||||
assert_eq!(s.coding_rate, 5);
|
||||
assert_eq!(s.txpower, 14);
|
||||
assert_eq!(s.airtime_limit_short, Some(25.0));
|
||||
assert_eq!(s.airtime_limit_long, Some(10.0));
|
||||
s.validate().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adoption_ignores_non_rnode_sections_and_absent_config() {
|
||||
let tcp_only = "\
|
||||
[interfaces]
|
||||
[[Reticulum TCP Server]]
|
||||
type = TCPServerInterface
|
||||
listen_ip = 127.0.0.1
|
||||
listen_port = 4242
|
||||
";
|
||||
assert!(RNodeRfSettings::parse_rnode_section(tcp_only).is_none());
|
||||
assert!(RNodeRfSettings::parse_rnode_section("").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_of_range_values_are_rejected() {
|
||||
let base = RNodeRfSettings::default();
|
||||
for bad in [
|
||||
RNodeRfSettings {
|
||||
frequency: 100,
|
||||
..base.clone()
|
||||
},
|
||||
RNodeRfSettings {
|
||||
bandwidth: 123_456,
|
||||
..base.clone()
|
||||
},
|
||||
RNodeRfSettings {
|
||||
spreading_factor: 4,
|
||||
..base.clone()
|
||||
},
|
||||
RNodeRfSettings {
|
||||
coding_rate: 9,
|
||||
..base.clone()
|
||||
},
|
||||
RNodeRfSettings {
|
||||
txpower: 23,
|
||||
..base.clone()
|
||||
},
|
||||
RNodeRfSettings {
|
||||
airtime_limit_short: Some(180.0),
|
||||
..base.clone()
|
||||
},
|
||||
RNodeRfSettings {
|
||||
port: Some("ttyACM0".into()),
|
||||
..base.clone()
|
||||
},
|
||||
RNodeRfSettings {
|
||||
port: Some("/dev/tty; rm -rf /".into()),
|
||||
..base.clone()
|
||||
},
|
||||
] {
|
||||
assert!(bad.validate().is_err(), "{bad:?} should fail validation");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
//! Scheduled / queued mesh messages (issue #50, phase 1.7).
|
||||
//!
|
||||
//! A small persisted queue of messages to send at a future time. A background
|
||||
//! task fires due messages via the listener. A message addressed to a peer that
|
||||
//! isn't currently in the contact table stays queued and retries on later ticks
|
||||
//! — i.e. it sends itself when the peer comes back in range.
|
||||
|
||||
use super::listener::{MeshCommand, MeshState};
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use tokio::fs;
|
||||
use tokio::sync::{watch, RwLock};
|
||||
use tracing::warn;
|
||||
|
||||
const SCHEDULER_FILE: &str = "mesh-scheduled.json";
|
||||
/// Wake interval for firing due messages.
|
||||
const TICK_SECS: u64 = 10;
|
||||
/// Drop a still-undeliverable message after this many attempts (~1h at 10s).
|
||||
const MAX_ATTEMPTS: u32 = 360;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ScheduledMessage {
|
||||
pub id: u64,
|
||||
/// Direct-message target (peer contact_id), or None for a channel broadcast.
|
||||
#[serde(default)]
|
||||
pub contact_id: Option<u32>,
|
||||
/// Channel to broadcast on, or None for a direct message.
|
||||
#[serde(default)]
|
||||
pub channel: Option<u8>,
|
||||
pub body: String,
|
||||
/// Unix seconds when the message becomes due.
|
||||
pub fire_at: i64,
|
||||
#[serde(default)]
|
||||
pub attempts: u32,
|
||||
}
|
||||
|
||||
pub struct MeshScheduler {
|
||||
path: PathBuf,
|
||||
queue: RwLock<Vec<ScheduledMessage>>,
|
||||
next_id: RwLock<u64>,
|
||||
}
|
||||
|
||||
impl MeshScheduler {
|
||||
pub async fn load(data_dir: &Path) -> Self {
|
||||
let path = data_dir.join(SCHEDULER_FILE);
|
||||
let queue: Vec<ScheduledMessage> = match fs::read_to_string(&path).await {
|
||||
Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
let next = queue.iter().map(|m| m.id).max().unwrap_or(0) + 1;
|
||||
Self {
|
||||
path,
|
||||
queue: RwLock::new(queue),
|
||||
next_id: RwLock::new(next),
|
||||
}
|
||||
}
|
||||
|
||||
async fn save(&self) -> Result<()> {
|
||||
let json = {
|
||||
let q = self.queue.read().await;
|
||||
serde_json::to_string_pretty(&*q).context("serialize scheduled queue")?
|
||||
};
|
||||
if let Some(parent) = self.path.parent() {
|
||||
fs::create_dir_all(parent).await.ok();
|
||||
}
|
||||
fs::write(&self.path, json)
|
||||
.await
|
||||
.context("write scheduled queue")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn add(
|
||||
&self,
|
||||
contact_id: Option<u32>,
|
||||
channel: Option<u8>,
|
||||
body: String,
|
||||
fire_at: i64,
|
||||
) -> Result<ScheduledMessage> {
|
||||
let id = {
|
||||
let mut n = self.next_id.write().await;
|
||||
let id = *n;
|
||||
*n += 1;
|
||||
id
|
||||
};
|
||||
let msg = ScheduledMessage {
|
||||
id,
|
||||
contact_id,
|
||||
channel,
|
||||
body,
|
||||
fire_at,
|
||||
attempts: 0,
|
||||
};
|
||||
self.queue.write().await.push(msg.clone());
|
||||
self.save().await?;
|
||||
Ok(msg)
|
||||
}
|
||||
|
||||
pub async fn list(&self) -> Vec<ScheduledMessage> {
|
||||
let mut v = self.queue.read().await.clone();
|
||||
v.sort_by_key(|m| m.fire_at);
|
||||
v
|
||||
}
|
||||
|
||||
pub async fn cancel(&self, id: u64) -> Result<bool> {
|
||||
let removed = {
|
||||
let mut q = self.queue.write().await;
|
||||
let before = q.len();
|
||||
q.retain(|m| m.id != id);
|
||||
q.len() != before
|
||||
};
|
||||
if removed {
|
||||
self.save().await?;
|
||||
}
|
||||
Ok(removed)
|
||||
}
|
||||
}
|
||||
|
||||
/// Background loop: every `TICK_SECS`, fire any due messages.
|
||||
pub async fn run_scheduler(
|
||||
scheduler: Arc<MeshScheduler>,
|
||||
state: Arc<MeshState>,
|
||||
mut shutdown: watch::Receiver<bool>,
|
||||
) {
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(TICK_SECS));
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = interval.tick() => fire_due(&scheduler, &state).await,
|
||||
_ = shutdown.changed() => {
|
||||
if *shutdown.borrow() { return; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn fire_due(scheduler: &Arc<MeshScheduler>, state: &Arc<MeshState>) {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let due: Vec<ScheduledMessage> = scheduler
|
||||
.queue
|
||||
.read()
|
||||
.await
|
||||
.iter()
|
||||
.filter(|m| m.fire_at <= now)
|
||||
.cloned()
|
||||
.collect();
|
||||
if due.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut delivered: Vec<u64> = Vec::new();
|
||||
let mut failed: Vec<u64> = Vec::new();
|
||||
for msg in &due {
|
||||
if try_send(state, msg).await {
|
||||
delivered.push(msg.id);
|
||||
} else {
|
||||
failed.push(msg.id);
|
||||
}
|
||||
}
|
||||
|
||||
let mut to_remove = delivered;
|
||||
{
|
||||
let mut q = scheduler.queue.write().await;
|
||||
for m in q.iter_mut() {
|
||||
if failed.contains(&m.id) {
|
||||
m.attempts += 1;
|
||||
if m.attempts >= MAX_ATTEMPTS {
|
||||
warn!(
|
||||
id = m.id,
|
||||
attempts = m.attempts,
|
||||
"Dropping undeliverable scheduled message"
|
||||
);
|
||||
to_remove.push(m.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
q.retain(|m| !to_remove.contains(&m.id));
|
||||
}
|
||||
if let Err(e) = scheduler.save().await {
|
||||
warn!("Failed to persist mesh outbox after sweep: {e:#}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Hand a due message to the radio. Returns true if it was sent (or should be
|
||||
/// dropped); false to keep it queued for a later retry (peer not in range yet).
|
||||
async fn try_send(state: &Arc<MeshState>, msg: &ScheduledMessage) -> bool {
|
||||
let payload = msg.body.clone().into_bytes();
|
||||
if let Some(channel) = msg.channel {
|
||||
return state
|
||||
.send_cmd(MeshCommand::BroadcastChannel { channel, payload })
|
||||
.await
|
||||
.is_ok();
|
||||
}
|
||||
if let Some(contact_id) = msg.contact_id {
|
||||
let pubkey = {
|
||||
let peers = state.peers.read().await;
|
||||
peers.get(&contact_id).and_then(|p| p.pubkey_hex.clone())
|
||||
};
|
||||
if let Some(pk) = pubkey {
|
||||
if let Ok(bytes) = hex::decode(&pk) {
|
||||
if bytes.len() >= 6 {
|
||||
let mut dest = [0u8; 6];
|
||||
dest.copy_from_slice(&bytes[..6]);
|
||||
return state
|
||||
.send_cmd(MeshCommand::SendText {
|
||||
dest_pubkey_prefix: dest,
|
||||
payload,
|
||||
})
|
||||
.await
|
||||
.is_ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
// Peer unknown / not in range yet — keep queued, retry next tick.
|
||||
return false;
|
||||
}
|
||||
warn!("Scheduled message has neither channel nor contact_id — dropping");
|
||||
true
|
||||
}
|
||||
@@ -0,0 +1,725 @@
|
||||
// WIP mesh/transport protocol — suppress dead code warnings
|
||||
#![allow(dead_code)]
|
||||
//! 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::path::Path;
|
||||
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> {
|
||||
// Check device exists before trying to open (better error message)
|
||||
match tokio::fs::metadata(path).await {
|
||||
Ok(meta) => {
|
||||
debug!(path = %path, permissions = ?meta.permissions(), "Device node exists");
|
||||
}
|
||||
Err(e) => {
|
||||
anyhow::bail!(
|
||||
"Serial device {} not accessible: {} (check PrivateDevices in systemd, or USB connection)",
|
||||
path, e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let port = serial2_tokio::SerialPort::open(path, BAUD_RATE).context(format!(
|
||||
"Failed to open serial port {} (permission denied? device busy?)",
|
||||
path
|
||||
))?;
|
||||
// See probe_rnode() in reticulum.rs for why: ESP32-S3 native-USB
|
||||
// boards (and CP2102/CH340-bridged boards wired for Arduino-style
|
||||
// auto-reset) reset on a DTR/RTS transition, so deassert both and
|
||||
// settle before the handshake below. 300ms is nowhere near a real
|
||||
// firmware boot time (LoRa radio init alone can take longer) —
|
||||
// confirmed live 2026-07-23: with every one of Reticulum/Meshcore/
|
||||
// Meshtastic's open() doing this same reset, a single auto-detect
|
||||
// cycle trying multiple protocols in sequence kept re-resetting the
|
||||
// board before it ever finished booting from the PREVIOUS attempt's
|
||||
// reset, on both a Heltec V3 and V4, regardless of firmware family —
|
||||
// a self-sustaining "never finishes booting" loop with a boot-time
|
||||
// root cause hiding behind what looked like a per-protocol failure.
|
||||
let _ = port.set_dtr(false);
|
||||
let _ = port.set_rts(false);
|
||||
tokio::time::sleep(Duration::from_millis(2000)).await;
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
/// The advert name learned from SELF_INFO during `initialize`.
|
||||
pub fn advert_name(&self) -> Option<String> {
|
||||
self.advert_name.clone()
|
||||
}
|
||||
|
||||
/// Read firmware version + contact capacity via CMD_DEVICE_QUERY (0x16).
|
||||
/// Read-only — used by the hot-swap probe to show what's on a
|
||||
/// just-plugged radio. Tolerates interleaved push frames and firmware
|
||||
/// that doesn't answer the query (returns None rather than erroring).
|
||||
pub async fn query_device_info(&mut self) -> Option<(String, u16)> {
|
||||
if self
|
||||
.send_raw(&protocol::build_device_query())
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
for _ in 0..5 {
|
||||
match self.recv_frame_timeout(Duration::from_secs(2)).await {
|
||||
Ok(f) if f.code == protocol::RESP_DEVICE_INFO => {
|
||||
return protocol::parse_device_info(&f.data).ok();
|
||||
}
|
||||
Ok(_) => continue, // push notification — keep waiting
|
||||
Err(_) => return None,
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 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(())
|
||||
}
|
||||
|
||||
/// Set the radio's LoRa PHY parameters (freq/bw/sf/cr, firmware field
|
||||
/// units — see `protocol::build_set_radio_params`). On RESP_OK the
|
||||
/// firmware persists the params and reboots to apply them, so the caller
|
||||
/// must treat the session as gone and reconnect.
|
||||
pub async fn set_radio_params(
|
||||
&mut self,
|
||||
freq_khz: u32,
|
||||
bw_hz: u32,
|
||||
sf: u8,
|
||||
cr: u8,
|
||||
) -> Result<()> {
|
||||
self.send_raw(&protocol::build_set_radio_params(freq_khz, bw_hz, sf, cr))
|
||||
.await?;
|
||||
let frame = self.recv_frame_timeout(READ_TIMEOUT).await?;
|
||||
if frame.code == protocol::RESP_ERR {
|
||||
anyhow::bail!(
|
||||
"Set radio params failed: {}",
|
||||
protocol::parse_error(&frame.data)
|
||||
);
|
||||
}
|
||||
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).
|
||||
/// Returns whether the firmware routed it via flood (true) or direct (false).
|
||||
/// The response frame is `RESP_CODE_SENT | mode | tag[4] | est_timeout[4]`
|
||||
/// where mode == 1 means flood and mode == 0 means direct.
|
||||
pub async fn send_text(&mut self, dest_pubkey_prefix: &[u8; 6], msg: &[u8]) -> Result<bool> {
|
||||
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));
|
||||
}
|
||||
// RESP_CODE_SENT layout: [mode(1)][tag(4)][est_timeout(4)]
|
||||
let sent_via_flood = frame.data.first().copied().unwrap_or(0) == 1;
|
||||
tracing::info!(
|
||||
dest = %hex::encode(dest_pubkey_prefix),
|
||||
mode = if sent_via_flood { "flood" } else { "direct" },
|
||||
resp_code = frame.code,
|
||||
data_len = frame.data.len(),
|
||||
"[diag] send_text response"
|
||||
);
|
||||
Ok(sent_via_flood)
|
||||
}
|
||||
|
||||
/// 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(())
|
||||
}
|
||||
|
||||
/// Send a NATIVE meshcore direct message (CMD_SEND_TXT_MSG) to a contact,
|
||||
/// addressed by the first 6 bytes of its public key. Unlike the
|
||||
/// `@DM2`-over-channel path, this is a real unicast — it does not appear on
|
||||
/// the public channel, and a stock meshcore client receives it as a normal
|
||||
/// DM. The contact must already exist in the firmware table (with a path).
|
||||
pub async fn send_text_msg(&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!(
|
||||
"Direct text send failed: {}",
|
||||
protocol::parse_error(&frame.data)
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Clear the stored routing path for a contact so the firmware flood-
|
||||
/// routes future messages instead of dropping them when path_len=0.
|
||||
pub async fn reset_contact_path(&mut self, pubkey: &[u8; 32]) -> Result<()> {
|
||||
self.send_raw(&protocol::build_reset_path(pubkey)).await?;
|
||||
let frame = self.recv_frame_timeout(READ_TIMEOUT).await?;
|
||||
if frame.code == protocol::RESP_ERR {
|
||||
anyhow::bail!("Reset path failed: {}", protocol::parse_error(&frame.data));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a contact from the firmware's persistent contact table.
|
||||
pub async fn remove_contact(&mut self, pubkey: &[u8; 32]) -> Result<()> {
|
||||
self.send_raw(&protocol::build_remove_contact(pubkey))
|
||||
.await?;
|
||||
let frame = self.recv_frame_timeout(READ_TIMEOUT).await?;
|
||||
if frame.code == protocol::RESP_ERR {
|
||||
anyhow::bail!(
|
||||
"Remove contact failed: {}",
|
||||
protocol::parse_error(&frame.data)
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add/update a contact in the firmware table (CMD_ADD_UPDATE_CONTACT).
|
||||
/// Used to import a heard advert so it shows up as a contact immediately.
|
||||
pub async fn add_contact(
|
||||
&mut self,
|
||||
pubkey: &[u8; 32],
|
||||
contact_type: u8,
|
||||
flags: u8,
|
||||
out_path_len: u8,
|
||||
name: &str,
|
||||
last_advert: u32,
|
||||
) -> Result<()> {
|
||||
self.send_raw(&protocol::build_add_contact(
|
||||
pubkey,
|
||||
contact_type,
|
||||
flags,
|
||||
out_path_len,
|
||||
name,
|
||||
last_advert,
|
||||
))
|
||||
.await?;
|
||||
let frame = self.recv_frame_timeout(READ_TIMEOUT).await?;
|
||||
if frame.code == protocol::RESP_ERR {
|
||||
anyhow::bail!("Add contact 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.
|
||||
/// /dev/mesh-radio is a stable udev symlink (see 99-mesh-radio.rules).
|
||||
const SERIAL_CANDIDATES: &[&str] = &[
|
||||
"/dev/mesh-radio",
|
||||
"/dev/ttyUSB0",
|
||||
"/dev/ttyUSB1",
|
||||
"/dev/ttyUSB2",
|
||||
"/dev/ttyACM0",
|
||||
"/dev/ttyACM1",
|
||||
"/dev/ttyACM2",
|
||||
];
|
||||
|
||||
const SKIP_SERIAL_MODEL_SUBSTRINGS: &[&str] = &["Sierra_Wireless", "Z-Wave", "Zooz"];
|
||||
|
||||
fn likely_non_mesh_serial_device(path: &str) -> bool {
|
||||
let Some(name) = Path::new(path).file_name().and_then(|s| s.to_str()) else {
|
||||
return false;
|
||||
};
|
||||
let by_id = Path::new("/dev/serial/by-id");
|
||||
let Ok(entries) = std::fs::read_dir(by_id) else {
|
||||
return false;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let file_name = entry.file_name().to_string_lossy().to_string();
|
||||
if !SKIP_SERIAL_MODEL_SUBSTRINGS
|
||||
.iter()
|
||||
.any(|needle| file_name.contains(needle))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if let Ok(target) = std::fs::read_link(entry.path()) {
|
||||
if target.file_name().and_then(|s| s.to_str()) == Some(name) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Scan for serial devices that could be Meshcore radios.
|
||||
/// Returns paths to existing serial device files.
|
||||
///
|
||||
/// Dedupes by canonical (symlink-resolved) target: `/dev/mesh-radio` is a
|
||||
/// stable udev symlink to whatever `/dev/ttyUSB*`/`/dev/ttyACM*` node the
|
||||
/// primary radio currently enumerates as, so both names always pointed at
|
||||
/// the same candidate list entry and both passed this scan — confirmed live
|
||||
/// 2026-07-23, this made an already-connected, working radio (connected via
|
||||
/// its `/dev/mesh-radio` alias) simultaneously appear as a second, separate
|
||||
/// "detected but unclaimed" device under its raw `/dev/ttyUSBn` name. The
|
||||
/// hot-swap UI's active-session guard compares path strings, so it didn't
|
||||
/// recognize the two aliases as the same port, showed the "device detected"
|
||||
/// modal for a radio that was already set up, and probing it there opened
|
||||
/// (and DTR/RTS-reset) the exact port the live session was mid-conversation
|
||||
/// with — a continuous, UI-driven reset loop that only ran while that view
|
||||
/// was open (matches the reported "stops when I leave, resumes when I come
|
||||
/// back"). SERIAL_CANDIDATES lists `/dev/mesh-radio` first, so it wins the
|
||||
/// dedup and is what's reported when both alias and target are present.
|
||||
/// (Independently re-discovered and fixed on main 2026-07-26 — both sides
|
||||
/// of the 2026-07-28 merge carried an equivalent implementation.)
|
||||
pub async fn detect_serial_devices() -> Vec<String> {
|
||||
let mut devices = Vec::new();
|
||||
let mut seen_real_paths = std::collections::HashSet::new();
|
||||
for path in SERIAL_CANDIDATES {
|
||||
if tokio::fs::metadata(path).await.is_ok() {
|
||||
if likely_non_mesh_serial_device(path) {
|
||||
debug!(path = %path, "Skipping known non-mesh serial device");
|
||||
continue;
|
||||
}
|
||||
let real_path = tokio::fs::canonicalize(path)
|
||||
.await
|
||||
.unwrap_or_else(|_| std::path::PathBuf::from(path));
|
||||
if !seen_real_paths.insert(real_path.clone()) {
|
||||
debug!(path = %path, real_path = %real_path.display(), "Skipping duplicate alias for an already-listed device");
|
||||
continue;
|
||||
}
|
||||
devices.push(path.to_string());
|
||||
}
|
||||
}
|
||||
devices
|
||||
}
|
||||
|
||||
/// USB identity of a detected serial port, read from sysfs — lets the UI
|
||||
/// show the actual board (native-USB boards like T-Deck/RAK4631 report their
|
||||
/// name in `product`; bridge chips like CP2102/CH340 only identify the chip,
|
||||
/// so vid:pid is the fallback signal).
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct DetectedDeviceInfo {
|
||||
pub path: String,
|
||||
pub vid: Option<String>,
|
||||
pub pid: Option<String>,
|
||||
pub product: Option<String>,
|
||||
pub manufacturer: Option<String>,
|
||||
/// Unix epoch seconds of the /dev node's creation — udev recreates the
|
||||
/// node on every plug, so this changes on each replug. The UI keys its
|
||||
/// "Not Now" dismissals on (path, plugged_at): swapping a stick (or
|
||||
/// unplug/replug faster than a status poll) invalidates old dismissals
|
||||
/// and the setup modal fires again, per the hot-swap UX (2026-07-22).
|
||||
pub plugged_at: Option<u64>,
|
||||
}
|
||||
|
||||
/// Like `detect_serial_devices`, but with USB metadata per port.
|
||||
pub async fn detect_serial_devices_info() -> Vec<DetectedDeviceInfo> {
|
||||
let mut out = Vec::new();
|
||||
for path in detect_serial_devices().await {
|
||||
let usb = usb_info_for_tty(&path).await;
|
||||
// Birth time (btime), falling back to inode-change time (ctime) —
|
||||
// NOT mtime: a tty node's mtime bumps on every open()/write, so with
|
||||
// mtime here each probe/session open minted a "new" plugged_at, the
|
||||
// UI's (path, plugged_at) dismissal key never matched again, and the
|
||||
// setup modal re-fired forever on a device that never left the port
|
||||
// (observed live on a test node 2026-07-28). btime/ctime only
|
||||
// change when udev (re)creates/chowns the node — i.e. on real plugs.
|
||||
let plugged_at = tokio::fs::metadata(&path).await.ok().and_then(|m| {
|
||||
m.created()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_secs())
|
||||
.or_else(|| {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
u64::try_from(m.ctime()).ok()
|
||||
})
|
||||
});
|
||||
out.push(DetectedDeviceInfo {
|
||||
path,
|
||||
vid: usb.0,
|
||||
pid: usb.1,
|
||||
product: usb.2,
|
||||
manufacturer: usb.3,
|
||||
plugged_at,
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Resolve a tty path (following the /dev/mesh-radio symlink) to its USB
|
||||
/// device sysfs node and read idVendor/idProduct/product/manufacturer.
|
||||
/// Best-effort: any miss returns None fields (e.g. non-USB UARTs).
|
||||
async fn usb_info_for_tty(
|
||||
path: &str,
|
||||
) -> (
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
) {
|
||||
let resolved = tokio::fs::canonicalize(path)
|
||||
.await
|
||||
.unwrap_or_else(|_| std::path::PathBuf::from(path));
|
||||
let Some(name) = resolved.file_name().and_then(|n| n.to_str()) else {
|
||||
return (None, None, None, None);
|
||||
};
|
||||
// /sys/class/tty/<name>/device -> .../usbN/N-M/N-M:1.0/(ttyUSBx|tty). Walk
|
||||
// up from the device link until a directory with idVendor appears.
|
||||
let mut dir = std::path::PathBuf::from(format!("/sys/class/tty/{name}/device"));
|
||||
for _ in 0..6 {
|
||||
if tokio::fs::metadata(dir.join("idVendor")).await.is_ok() {
|
||||
let read = |f: &str| {
|
||||
let p = dir.join(f);
|
||||
async move {
|
||||
tokio::fs::read_to_string(p)
|
||||
.await
|
||||
.ok()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
};
|
||||
return (
|
||||
read("idVendor").await,
|
||||
read("idProduct").await,
|
||||
read("product").await,
|
||||
read("manufacturer").await,
|
||||
);
|
||||
}
|
||||
dir.push("..");
|
||||
let Ok(canon) = tokio::fs::canonicalize(&dir).await else {
|
||||
break;
|
||||
};
|
||||
dir = canon;
|
||||
}
|
||||
(None, None, None, None)
|
||||
}
|
||||
|
||||
/// 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,290 @@
|
||||
//! Per-peer session manager for Double Ratchet state persistence.
|
||||
//!
|
||||
//! Each peer gets a separate ratchet session stored on disk at
|
||||
//! `{data_dir}/ratchet/{did_hash}.json`. Sessions are loaded lazily
|
||||
//! on first message and saved after each encrypt/decrypt operation.
|
||||
|
||||
use super::ratchet::RatchetState;
|
||||
use anyhow::{Context, Result};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
const RATCHET_DIR: &str = "ratchet";
|
||||
|
||||
/// Thread-safe manager for per-peer ratchet sessions.
|
||||
pub struct SessionManager {
|
||||
sessions: RwLock<HashMap<String, RatchetState>>,
|
||||
data_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl SessionManager {
|
||||
/// Create a new session manager. Does not load sessions from disk yet.
|
||||
pub fn new(data_dir: &Path) -> Self {
|
||||
Self {
|
||||
sessions: RwLock::new(HashMap::new()),
|
||||
data_dir: data_dir.to_path_buf(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Hash a DID to a filesystem-safe filename (16 hex chars).
|
||||
fn did_hash(did: &str) -> String {
|
||||
let hash = Sha256::digest(did.as_bytes());
|
||||
hex::encode(&hash[..8])
|
||||
}
|
||||
|
||||
/// Path to a session file for a given DID.
|
||||
fn session_path(&self, did: &str) -> PathBuf {
|
||||
self.data_dir
|
||||
.join(RATCHET_DIR)
|
||||
.join(format!("{}.json", Self::did_hash(did)))
|
||||
}
|
||||
|
||||
/// Load a session from disk if it exists.
|
||||
async fn load_session(&self, did: &str) -> Result<Option<RatchetState>> {
|
||||
let path = self.session_path(did);
|
||||
if !path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let content = tokio::fs::read_to_string(&path)
|
||||
.await
|
||||
.context("Failed to read ratchet session")?;
|
||||
let state: RatchetState =
|
||||
serde_json::from_str(&content).context("Failed to deserialize ratchet session")?;
|
||||
debug!(did = %did, "Loaded ratchet session from disk");
|
||||
Ok(Some(state))
|
||||
}
|
||||
|
||||
/// Save a session to disk.
|
||||
async fn save_session_to_disk(&self, did: &str, state: &RatchetState) -> Result<()> {
|
||||
let dir = self.data_dir.join(RATCHET_DIR);
|
||||
tokio::fs::create_dir_all(&dir)
|
||||
.await
|
||||
.context("Failed to create ratchet directory")?;
|
||||
let path = self.session_path(did);
|
||||
let tmp_path = path.with_extension("tmp");
|
||||
let content =
|
||||
serde_json::to_string_pretty(state).context("Failed to serialize ratchet session")?;
|
||||
// Atomic write: write to temp file, then rename
|
||||
tokio::fs::write(&tmp_path, content)
|
||||
.await
|
||||
.context("Failed to write temporary ratchet state")?;
|
||||
tokio::fs::rename(&tmp_path, &path)
|
||||
.await
|
||||
.context("Failed to atomically rename ratchet state file")?;
|
||||
debug!(did = %did, "Saved ratchet session to disk (atomic)");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if a ratchet session exists for a peer (in memory or on disk).
|
||||
pub async fn has_session(&self, did: &str) -> bool {
|
||||
let sessions = self.sessions.read().await;
|
||||
if sessions.contains_key(did) {
|
||||
return true;
|
||||
}
|
||||
self.session_path(did).exists()
|
||||
}
|
||||
|
||||
/// Encrypt a message for a peer using their ratchet session.
|
||||
/// Loads the session from disk if not in memory.
|
||||
pub async fn encrypt_for_peer(
|
||||
&self,
|
||||
did: &str,
|
||||
plaintext: &[u8],
|
||||
) -> Result<super::ratchet::RatchetMessage> {
|
||||
let mut sessions = self.sessions.write().await;
|
||||
|
||||
// Lazy load from disk if not in memory
|
||||
if !sessions.contains_key(did) {
|
||||
if let Some(state) = self.load_session(did).await? {
|
||||
sessions.insert(did.to_string(), state);
|
||||
} else {
|
||||
anyhow::bail!("No ratchet session for peer {}", did);
|
||||
}
|
||||
}
|
||||
|
||||
let state = sessions
|
||||
.get_mut(did)
|
||||
.ok_or_else(|| anyhow::anyhow!("Session disappeared"))?;
|
||||
|
||||
let message = state.encrypt(plaintext)?;
|
||||
|
||||
// Save updated state after encryption (chain key advanced)
|
||||
drop(sessions);
|
||||
let sessions = self.sessions.read().await;
|
||||
if let Some(state) = sessions.get(did) {
|
||||
if let Err(e) = self.save_session_to_disk(did, state).await {
|
||||
warn!(did = %did, error = %e, "Failed to save session after encrypt");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(message)
|
||||
}
|
||||
|
||||
/// Decrypt a message from a peer using their ratchet session.
|
||||
pub async fn decrypt_from_peer(
|
||||
&self,
|
||||
did: &str,
|
||||
message: &super::ratchet::RatchetMessage,
|
||||
) -> Result<Vec<u8>> {
|
||||
let mut sessions = self.sessions.write().await;
|
||||
|
||||
// Lazy load from disk if not in memory
|
||||
if !sessions.contains_key(did) {
|
||||
if let Some(state) = self.load_session(did).await? {
|
||||
sessions.insert(did.to_string(), state);
|
||||
} else {
|
||||
anyhow::bail!("No ratchet session for peer {}", did);
|
||||
}
|
||||
}
|
||||
|
||||
let state = sessions
|
||||
.get_mut(did)
|
||||
.ok_or_else(|| anyhow::anyhow!("Session disappeared"))?;
|
||||
|
||||
let plaintext = state.decrypt(message)?;
|
||||
|
||||
// Save updated state after decryption
|
||||
drop(sessions);
|
||||
let sessions = self.sessions.read().await;
|
||||
if let Some(state) = sessions.get(did) {
|
||||
if let Err(e) = self.save_session_to_disk(did, state).await {
|
||||
warn!(did = %did, error = %e, "Failed to save session after decrypt");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(plaintext)
|
||||
}
|
||||
|
||||
/// Store a ratchet session for a peer (in memory and on disk).
|
||||
#[allow(dead_code)]
|
||||
pub async fn store_session(&self, did: &str, state: RatchetState) -> Result<()> {
|
||||
self.save_session_to_disk(did, &state).await?;
|
||||
let mut sessions = self.sessions.write().await;
|
||||
sessions.insert(did.to_string(), state);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a ratchet session for a peer (from memory and disk).
|
||||
#[allow(dead_code)]
|
||||
pub async fn remove_session(&self, did: &str) -> Result<()> {
|
||||
let mut sessions = self.sessions.write().await;
|
||||
sessions.remove(did);
|
||||
let path = self.session_path(did);
|
||||
if path.exists() {
|
||||
tokio::fs::remove_file(&path)
|
||||
.await
|
||||
.context("Failed to remove ratchet session file")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get session info for a peer (for RPC status endpoint).
|
||||
pub async fn session_info(&self, did: &str) -> Option<SessionInfo> {
|
||||
let sessions = self.sessions.read().await;
|
||||
if let Some(state) = sessions.get(did) {
|
||||
return Some(SessionInfo {
|
||||
has_session: true,
|
||||
forward_secrecy: true,
|
||||
message_count: state.total_sent(),
|
||||
ratchet_generation: state.generation(),
|
||||
});
|
||||
}
|
||||
// Check disk
|
||||
if self.session_path(did).exists() {
|
||||
Some(SessionInfo {
|
||||
has_session: true,
|
||||
forward_secrecy: true,
|
||||
message_count: 0, // Would need to load to get exact count
|
||||
ratchet_generation: 0,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Summary info about a ratchet session (returned via RPC).
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct SessionInfo {
|
||||
pub has_session: bool,
|
||||
pub forward_secrecy: bool,
|
||||
pub message_count: u32,
|
||||
pub ratchet_generation: u32,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::mesh::crypto;
|
||||
use crate::mesh::ratchet::RatchetState;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_session_store_and_load() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mgr = SessionManager::new(dir.path());
|
||||
|
||||
let root_key = [42u8; 32];
|
||||
let (_spk_secret, spk_public) = crypto::generate_x25519_ephemeral();
|
||||
let state = RatchetState::init_as_sender(root_key, &spk_public).unwrap();
|
||||
|
||||
let did = "did:key:z6MkTestSession";
|
||||
mgr.store_session(did, state).await.unwrap();
|
||||
|
||||
assert!(mgr.has_session(did).await);
|
||||
|
||||
// Drop and reload
|
||||
let mgr2 = SessionManager::new(dir.path());
|
||||
assert!(mgr2.has_session(did).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_encrypt_decrypt_through_manager() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let alice_mgr = SessionManager::new(dir.path());
|
||||
|
||||
let dir2 = tempfile::tempdir().unwrap();
|
||||
let bob_mgr = SessionManager::new(dir2.path());
|
||||
|
||||
let root_key = [55u8; 32];
|
||||
let (spk_secret, spk_public) = crypto::generate_x25519_ephemeral();
|
||||
|
||||
let alice_state = RatchetState::init_as_sender(root_key, &spk_public).unwrap();
|
||||
let bob_state = RatchetState::init_as_receiver(root_key, spk_secret, spk_public);
|
||||
|
||||
let alice_did = "did:key:z6MkAlice";
|
||||
let bob_did = "did:key:z6MkBob";
|
||||
|
||||
alice_mgr.store_session(bob_did, alice_state).await.unwrap();
|
||||
bob_mgr.store_session(alice_did, bob_state).await.unwrap();
|
||||
|
||||
// Alice encrypts
|
||||
let msg = alice_mgr
|
||||
.encrypt_for_peer(bob_did, b"Hello via manager")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Bob decrypts
|
||||
let plain = bob_mgr.decrypt_from_peer(alice_did, &msg).await.unwrap();
|
||||
assert_eq!(plain, b"Hello via manager");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_remove_session() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mgr = SessionManager::new(dir.path());
|
||||
|
||||
let root_key = [33u8; 32];
|
||||
let (_, spk_public) = crypto::generate_x25519_ephemeral();
|
||||
let state = RatchetState::init_as_sender(root_key, &spk_public).unwrap();
|
||||
|
||||
let did = "did:key:z6MkRemoveMe";
|
||||
mgr.store_session(did, state).await.unwrap();
|
||||
assert!(mgr.has_session(did).await);
|
||||
|
||||
mgr.remove_session(did).await.unwrap();
|
||||
assert!(!mgr.has_session(did).await);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
// WIP mesh/transport protocol — suppress dead code warnings
|
||||
#![allow(dead_code)]
|
||||
//! Steganographic encoding for mesh messages.
|
||||
//!
|
||||
//! Transforms typed message envelopes into formats that resemble innocuous
|
||||
//! sensor data on the wire. Provides plausible deniability — traffic analysis
|
||||
//! sees weather readings or industrial sensor data, not Bitcoin transactions.
|
||||
//!
|
||||
//! Wire format:
|
||||
//! - Normal: `[0x02] [CBOR envelope]` (existing)
|
||||
//! - Stego: `[0xAA] [mode: 1 byte] [stego-encoded data]`
|
||||
//!
|
||||
//! The 0xAA prefix distinguishes steganographic frames from typed (0x02) and
|
||||
//! plain text (0x00) messages. Both sender and receiver must use the same mode.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Wire prefix for steganographic messages.
|
||||
pub const STEGO_MARKER: u8 = 0xAA;
|
||||
|
||||
/// Steganography mode — how real payload bytes are disguised on the wire.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub enum SteganographyMode {
|
||||
/// No steganography — standard 0x02 typed envelope.
|
||||
#[default]
|
||||
Normal,
|
||||
/// Payload disguised as weather station telemetry.
|
||||
/// Format: repeating 8-byte "readings" (temp, humidity, pressure, wind, flags).
|
||||
WeatherStation,
|
||||
/// Payload disguised as industrial sensor network data.
|
||||
/// Format: repeating 6-byte "samples" (voltage, current, vibration, status).
|
||||
SensorNetwork,
|
||||
}
|
||||
|
||||
impl SteganographyMode {
|
||||
pub fn from_u8(v: u8) -> Option<Self> {
|
||||
match v {
|
||||
0 => Some(Self::Normal),
|
||||
1 => Some(Self::WeatherStation),
|
||||
2 => Some(Self::SensorNetwork),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Weather Station Encoding ──────────────────────────────────────────
|
||||
//
|
||||
// Each 8-byte "reading" encodes 5 bytes of real payload data:
|
||||
// [temp_hi: u8] [temp_lo: u8] [humidity: u8] [pressure_hi: u8] [pressure_lo: u8]
|
||||
// [wind_speed: u8] [wind_dir: u8] [flags: u8]
|
||||
//
|
||||
// Real data bytes map as:
|
||||
// byte0 → temp_hi (offset by 200 to look like -50.0°C to +5.5°C range)
|
||||
// byte1 → humidity (modulo 100)
|
||||
// byte2 → pressure_hi (offset by 900 for 900-1155 hPa range)
|
||||
// byte3 → wind_speed (modulo 60 for 0-59 m/s)
|
||||
// byte4 → flags (lower 5 bits = data, upper 3 bits = plausible status flags)
|
||||
//
|
||||
// temp_lo, pressure_lo, wind_dir are derived (not payload data) for realism.
|
||||
// Overhead: 8 bytes per 5 payload bytes = 60% efficiency.
|
||||
|
||||
const WEATHER_REAL_BYTES_PER_BLOCK: usize = 5;
|
||||
const WEATHER_WIRE_BYTES_PER_BLOCK: usize = 8;
|
||||
|
||||
fn encode_weather_block(data: &[u8]) -> [u8; WEATHER_WIRE_BYTES_PER_BLOCK] {
|
||||
let mut block = [0u8; 8];
|
||||
let b0 = *data.first().unwrap_or(&0);
|
||||
let b1 = *data.get(1).unwrap_or(&0);
|
||||
let b2 = *data.get(2).unwrap_or(&0);
|
||||
let b3 = *data.get(3).unwrap_or(&0);
|
||||
let b4 = *data.get(4).unwrap_or(&0);
|
||||
|
||||
// temp: b0 mapped to plausible range, fractional derived from b1
|
||||
block[0] = b0.wrapping_add(200); // temp_hi — wraps around, decoded by subtracting 200
|
||||
block[1] = b1 ^ 0x55; // temp_lo — XOR mask, recoverable
|
||||
// humidity: b1 stored directly (0-255 maps to 0-100% with modular interpretation)
|
||||
block[2] = b1;
|
||||
// pressure: b2 offset into 900-1155 range
|
||||
block[3] = b2;
|
||||
block[4] = b3 ^ 0x33; // pressure_lo — XOR mask
|
||||
// wind: b3 modular
|
||||
block[5] = b3;
|
||||
// wind direction: derived from b4 (0-359 degrees as single byte = 0-255 → *1.41)
|
||||
block[6] = b4 ^ 0xAA; // XOR mask
|
||||
// flags: b4 with upper bits set for realism (battery OK, GPS lock, etc.)
|
||||
block[7] = (b4 & 0x1F) | 0xC0; // upper 2 bits always set
|
||||
|
||||
block
|
||||
}
|
||||
|
||||
fn decode_weather_block(
|
||||
block: &[u8; WEATHER_WIRE_BYTES_PER_BLOCK],
|
||||
) -> [u8; WEATHER_REAL_BYTES_PER_BLOCK] {
|
||||
let mut data = [0u8; 5];
|
||||
data[0] = block[0].wrapping_sub(200);
|
||||
data[1] = block[2]; // humidity field stores b1 directly
|
||||
data[2] = block[3]; // pressure_hi stores b2 directly
|
||||
data[3] = block[5]; // wind_speed stores b3 directly
|
||||
data[4] = block[6] ^ 0xAA; // wind_dir XOR back
|
||||
data
|
||||
}
|
||||
|
||||
// ─── Sensor Network Encoding ───────────────────────────────────────────
|
||||
//
|
||||
// Each 6-byte "sample" encodes 4 bytes of real payload data:
|
||||
// [voltage_hi: u8] [voltage_lo: u8] [current: u8]
|
||||
// [vibration: u8] [phase: u8] [status: u8]
|
||||
//
|
||||
// Real data bytes map as:
|
||||
// byte0 → voltage_hi
|
||||
// byte1 → current
|
||||
// byte2 → vibration
|
||||
// byte3 → status (lower 4 bits = data, upper 4 = plausible status)
|
||||
//
|
||||
// voltage_lo and phase are derived for realism.
|
||||
// Overhead: 6 bytes per 4 payload bytes = 67% efficiency.
|
||||
|
||||
const SENSOR_REAL_BYTES_PER_BLOCK: usize = 4;
|
||||
const SENSOR_WIRE_BYTES_PER_BLOCK: usize = 6;
|
||||
|
||||
fn encode_sensor_block(data: &[u8]) -> [u8; SENSOR_WIRE_BYTES_PER_BLOCK] {
|
||||
let mut block = [0u8; 6];
|
||||
let b0 = *data.first().unwrap_or(&0);
|
||||
let b1 = *data.get(1).unwrap_or(&0);
|
||||
let b2 = *data.get(2).unwrap_or(&0);
|
||||
let b3 = *data.get(3).unwrap_or(&0);
|
||||
|
||||
block[0] = b0; // voltage_hi
|
||||
block[1] = b0 ^ b1; // voltage_lo (derived, recoverable)
|
||||
block[2] = b1; // current
|
||||
block[3] = b2; // vibration
|
||||
block[4] = b2.wrapping_add(b3); // phase (derived)
|
||||
block[5] = (b3 & 0x0F) | 0x80; // status: upper nibble = "operational"
|
||||
|
||||
block
|
||||
}
|
||||
|
||||
fn decode_sensor_block(
|
||||
block: &[u8; SENSOR_WIRE_BYTES_PER_BLOCK],
|
||||
) -> [u8; SENSOR_REAL_BYTES_PER_BLOCK] {
|
||||
let mut data = [0u8; 4];
|
||||
data[0] = block[0]; // voltage_hi = b0
|
||||
data[1] = block[2]; // current = b1
|
||||
data[2] = block[3]; // vibration = b2
|
||||
data[3] = (block[5] & 0x0F) | (block[4].wrapping_sub(block[3]) & 0xF0);
|
||||
// Recover b3: lower 4 bits from status, but we only stored lower 4.
|
||||
// Full b3 recovery: block[4] = b2 + b3, so b3 = block[4] - block[3]
|
||||
data[3] = block[4].wrapping_sub(block[3]);
|
||||
data
|
||||
}
|
||||
|
||||
// ─── Public API ────────────────────────────────────────────────────────
|
||||
|
||||
/// Encode raw payload bytes using steganographic mode.
|
||||
/// Returns: `[0xAA] [mode_byte] [length_hi] [length_lo] [encoded_blocks...]`
|
||||
///
|
||||
/// The length field stores the original payload size (up to 65535 bytes)
|
||||
/// so the decoder knows how many real bytes to extract.
|
||||
pub fn encode(mode: SteganographyMode, payload: &[u8]) -> Result<Vec<u8>> {
|
||||
if mode == SteganographyMode::Normal {
|
||||
anyhow::bail!("Cannot steganographically encode in Normal mode");
|
||||
}
|
||||
if payload.len() > 0xFFFF {
|
||||
anyhow::bail!("Payload too large for steganographic encoding");
|
||||
}
|
||||
|
||||
let len = payload.len() as u16;
|
||||
let mut output = vec![
|
||||
STEGO_MARKER,
|
||||
mode as u8,
|
||||
(len >> 8) as u8,
|
||||
(len & 0xFF) as u8,
|
||||
];
|
||||
|
||||
match mode {
|
||||
SteganographyMode::WeatherStation => {
|
||||
for chunk in payload.chunks(WEATHER_REAL_BYTES_PER_BLOCK) {
|
||||
// Pad short final chunk with zeros
|
||||
let mut padded = [0u8; WEATHER_REAL_BYTES_PER_BLOCK];
|
||||
padded[..chunk.len()].copy_from_slice(chunk);
|
||||
output.extend_from_slice(&encode_weather_block(&padded));
|
||||
}
|
||||
}
|
||||
SteganographyMode::SensorNetwork => {
|
||||
for chunk in payload.chunks(SENSOR_REAL_BYTES_PER_BLOCK) {
|
||||
let mut padded = [0u8; SENSOR_REAL_BYTES_PER_BLOCK];
|
||||
padded[..chunk.len()].copy_from_slice(chunk);
|
||||
output.extend_from_slice(&encode_sensor_block(&padded));
|
||||
}
|
||||
}
|
||||
SteganographyMode::Normal => unreachable!(),
|
||||
}
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
/// Decode a steganographic frame back to raw payload bytes.
|
||||
/// Input must start with `0xAA`.
|
||||
pub fn decode(data: &[u8]) -> Result<(SteganographyMode, Vec<u8>)> {
|
||||
if data.len() < 4 {
|
||||
anyhow::bail!("Stego frame too short: {} bytes", data.len());
|
||||
}
|
||||
if data[0] != STEGO_MARKER {
|
||||
anyhow::bail!("Not a stego frame (expected 0xAA, got 0x{:02x})", data[0]);
|
||||
}
|
||||
|
||||
let mode = SteganographyMode::from_u8(data[1])
|
||||
.ok_or_else(|| anyhow::anyhow!("Unknown stego mode: 0x{:02x}", data[1]))?;
|
||||
let original_len = ((data[2] as usize) << 8) | (data[3] as usize);
|
||||
let encoded_data = &data[4..];
|
||||
|
||||
let mut payload = Vec::with_capacity(original_len);
|
||||
|
||||
match mode {
|
||||
SteganographyMode::WeatherStation => {
|
||||
for block_bytes in encoded_data.chunks(WEATHER_WIRE_BYTES_PER_BLOCK) {
|
||||
if block_bytes.len() < WEATHER_WIRE_BYTES_PER_BLOCK {
|
||||
break;
|
||||
}
|
||||
let block: [u8; WEATHER_WIRE_BYTES_PER_BLOCK] = block_bytes
|
||||
.try_into()
|
||||
.context("Invalid weather block size")?;
|
||||
let decoded = decode_weather_block(&block);
|
||||
payload.extend_from_slice(&decoded);
|
||||
}
|
||||
}
|
||||
SteganographyMode::SensorNetwork => {
|
||||
for block_bytes in encoded_data.chunks(SENSOR_WIRE_BYTES_PER_BLOCK) {
|
||||
if block_bytes.len() < SENSOR_WIRE_BYTES_PER_BLOCK {
|
||||
break;
|
||||
}
|
||||
let block: [u8; SENSOR_WIRE_BYTES_PER_BLOCK] = block_bytes
|
||||
.try_into()
|
||||
.context("Invalid sensor block size")?;
|
||||
let decoded = decode_sensor_block(&block);
|
||||
payload.extend_from_slice(&decoded);
|
||||
}
|
||||
}
|
||||
SteganographyMode::Normal => {
|
||||
anyhow::bail!("Normal mode cannot appear in stego frame");
|
||||
}
|
||||
}
|
||||
|
||||
// Truncate to original length (removes padding from last block)
|
||||
payload.truncate(original_len);
|
||||
Ok((mode, payload))
|
||||
}
|
||||
|
||||
/// Encode a typed envelope wire bytes using steganography.
|
||||
/// Input: standard wire bytes starting with 0x02 (TYPED_MESSAGE_MARKER).
|
||||
/// Output: stego wire bytes starting with 0xAA.
|
||||
pub fn encode_typed_wire(mode: SteganographyMode, typed_wire: &[u8]) -> Result<Vec<u8>> {
|
||||
if typed_wire.is_empty() || typed_wire[0] != super::message_types::TYPED_MESSAGE_MARKER {
|
||||
anyhow::bail!("Input is not a typed message (expected 0x02 prefix)");
|
||||
}
|
||||
// Encode the entire typed wire frame (including the 0x02 marker) as payload
|
||||
encode(mode, typed_wire)
|
||||
}
|
||||
|
||||
/// Decode a stego frame back to typed envelope wire bytes.
|
||||
/// Returns the original bytes with 0x02 prefix restored.
|
||||
pub fn decode_typed_wire(stego_data: &[u8]) -> Result<Vec<u8>> {
|
||||
let (_mode, payload) = decode(stego_data)?;
|
||||
if payload.is_empty() || payload[0] != super::message_types::TYPED_MESSAGE_MARKER {
|
||||
anyhow::bail!("Decoded stego payload is not a typed message");
|
||||
}
|
||||
Ok(payload)
|
||||
}
|
||||
|
||||
/// Calculate the wire overhead for a given mode and payload size.
|
||||
pub fn wire_size(mode: SteganographyMode, payload_len: usize) -> usize {
|
||||
let header = 4; // 0xAA + mode + len_hi + len_lo
|
||||
match mode {
|
||||
SteganographyMode::Normal => payload_len,
|
||||
SteganographyMode::WeatherStation => {
|
||||
let blocks = payload_len.div_ceil(WEATHER_REAL_BYTES_PER_BLOCK);
|
||||
header + blocks * WEATHER_WIRE_BYTES_PER_BLOCK
|
||||
}
|
||||
SteganographyMode::SensorNetwork => {
|
||||
let blocks = payload_len.div_ceil(SENSOR_REAL_BYTES_PER_BLOCK);
|
||||
header + blocks * SENSOR_WIRE_BYTES_PER_BLOCK
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Max real payload bytes that fit in a single 160-byte LoRa frame after stego.
|
||||
pub fn max_payload_per_frame(mode: SteganographyMode) -> usize {
|
||||
let frame_limit = 160usize;
|
||||
let header = 4;
|
||||
let available = frame_limit.saturating_sub(header);
|
||||
match mode {
|
||||
SteganographyMode::Normal => frame_limit - 1, // minus 0x02 marker
|
||||
SteganographyMode::WeatherStation => {
|
||||
let blocks = available / WEATHER_WIRE_BYTES_PER_BLOCK;
|
||||
blocks * WEATHER_REAL_BYTES_PER_BLOCK
|
||||
}
|
||||
SteganographyMode::SensorNetwork => {
|
||||
let blocks = available / SENSOR_WIRE_BYTES_PER_BLOCK;
|
||||
blocks * SENSOR_REAL_BYTES_PER_BLOCK
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_weather_roundtrip() {
|
||||
let original = vec![0x42, 0xFF, 0x00, 0xAB, 0x13];
|
||||
let encoded = encode(SteganographyMode::WeatherStation, &original).unwrap();
|
||||
assert_eq!(encoded[0], STEGO_MARKER);
|
||||
assert_eq!(encoded[1], SteganographyMode::WeatherStation as u8);
|
||||
let (mode, decoded) = decode(&encoded).unwrap();
|
||||
assert_eq!(mode, SteganographyMode::WeatherStation);
|
||||
assert_eq!(decoded, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sensor_roundtrip() {
|
||||
let original = vec![0x42, 0xFF, 0x00, 0xAB];
|
||||
let encoded = encode(SteganographyMode::SensorNetwork, &original).unwrap();
|
||||
assert_eq!(encoded[0], STEGO_MARKER);
|
||||
let (mode, decoded) = decode(&encoded).unwrap();
|
||||
assert_eq!(mode, SteganographyMode::SensorNetwork);
|
||||
assert_eq!(decoded, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_weather_multi_block() {
|
||||
// 12 bytes = 3 weather blocks (5+5+2 with padding)
|
||||
let original: Vec<u8> = (0..12).collect();
|
||||
let encoded = encode(SteganographyMode::WeatherStation, &original).unwrap();
|
||||
let (_, decoded) = decode(&encoded).unwrap();
|
||||
assert_eq!(decoded, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sensor_multi_block() {
|
||||
// 10 bytes = 3 sensor blocks (4+4+2 with padding)
|
||||
let original: Vec<u8> = (0..10).collect();
|
||||
let encoded = encode(SteganographyMode::SensorNetwork, &original).unwrap();
|
||||
let (_, decoded) = decode(&encoded).unwrap();
|
||||
assert_eq!(decoded, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_all_byte_values_weather() {
|
||||
let original: Vec<u8> = (0..=255).collect();
|
||||
let encoded = encode(SteganographyMode::WeatherStation, &original).unwrap();
|
||||
let (_, decoded) = decode(&encoded).unwrap();
|
||||
assert_eq!(decoded, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_all_byte_values_sensor() {
|
||||
let original: Vec<u8> = (0..=255).collect();
|
||||
let encoded = encode(SteganographyMode::SensorNetwork, &original).unwrap();
|
||||
let (_, decoded) = decode(&encoded).unwrap();
|
||||
assert_eq!(decoded, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_payload() {
|
||||
let encoded = encode(SteganographyMode::WeatherStation, &[]).unwrap();
|
||||
let (_, decoded) = decode(&encoded).unwrap();
|
||||
assert!(decoded.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wire_size_calculation() {
|
||||
// 5 bytes payload = 1 weather block = 4 header + 8 = 12
|
||||
assert_eq!(wire_size(SteganographyMode::WeatherStation, 5), 12);
|
||||
// 4 bytes payload = 1 sensor block = 4 header + 6 = 10
|
||||
assert_eq!(wire_size(SteganographyMode::SensorNetwork, 4), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_max_payload_per_frame() {
|
||||
let weather_max = max_payload_per_frame(SteganographyMode::WeatherStation);
|
||||
let sensor_max = max_payload_per_frame(SteganographyMode::SensorNetwork);
|
||||
// Verify the encoded output fits in 160 bytes
|
||||
let test_data = vec![0x42; weather_max];
|
||||
let encoded = encode(SteganographyMode::WeatherStation, &test_data).unwrap();
|
||||
assert!(
|
||||
encoded.len() <= 160,
|
||||
"Weather stego {} > 160",
|
||||
encoded.len()
|
||||
);
|
||||
|
||||
let test_data = vec![0x42; sensor_max];
|
||||
let encoded = encode(SteganographyMode::SensorNetwork, &test_data).unwrap();
|
||||
assert!(encoded.len() <= 160, "Sensor stego {} > 160", encoded.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normal_mode_rejects() {
|
||||
assert!(encode(SteganographyMode::Normal, &[1, 2, 3]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_typed_wire_roundtrip() {
|
||||
// Simulate a typed message wire frame
|
||||
let mut typed_wire = vec![0x02]; // TYPED_MESSAGE_MARKER
|
||||
typed_wire.extend_from_slice(&[0x01, 0x02, 0x03, 0x04, 0x05]);
|
||||
let stego = encode_typed_wire(SteganographyMode::WeatherStation, &typed_wire).unwrap();
|
||||
let recovered = decode_typed_wire(&stego).unwrap();
|
||||
assert_eq!(recovered, typed_wire);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
// WIP mesh/transport protocol — suppress dead code warnings
|
||||
#![allow(dead_code)]
|
||||
//! 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,
|
||||
/// A Reticulum (RNS/LXMF) RNode, bridged via the host-supervised
|
||||
/// `reticulum-daemon` over its Unix-socket RPC — not driven in-process
|
||||
/// like the other two. See `mesh/reticulum.rs`.
|
||||
Reticulum,
|
||||
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::Reticulum => write!(f, "reticulum"),
|
||||
Self::Unknown => write!(f, "unknown"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Optional plain-TCP Reticulum interface, radio-less alternative to the
|
||||
/// serial-RNode path. Dev/verification surface for now (e.g. proving
|
||||
/// interop with Aurora's `RnsTcpInterface`/`RnsTcpServerInterface`, which is
|
||||
/// its default connectivity mode) — not exposed through `mesh.configure`/the
|
||||
/// frontend. `Server` is hard-gated to loopback in both the daemon and Rust
|
||||
/// (`reticulum::is_loopback_host`); WAN/LAN exposure is a separate, deliberate
|
||||
/// future decision (archy is otherwise Tor-first for inter-node traffic).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "mode", rename_all = "snake_case")]
|
||||
pub enum ReticulumTcpConfig {
|
||||
/// Bind a `TCPServerInterface`. `bind` is `host:port`, host must be
|
||||
/// loopback (127.0.0.1/::1/localhost).
|
||||
Server { bind: String },
|
||||
/// Dial one or more `TCPClientInterface` targets (`host:port`).
|
||||
Client { connect: Vec<String> },
|
||||
}
|
||||
|
||||
/// The per-message transport pill label for a radio-delivered message: the
|
||||
/// active device's own name, since one session owns exactly one device.
|
||||
/// Federation sends/receives are labelled "fips"/"tor" elsewhere — this only
|
||||
/// covers the radio-class transports.
|
||||
pub fn radio_transport_label(device_type: DeviceType) -> &'static str {
|
||||
match device_type {
|
||||
DeviceType::Meshcore => "meshcore",
|
||||
DeviceType::Meshtastic => "meshtastic",
|
||||
DeviceType::Reticulum => "reticulum",
|
||||
DeviceType::Unknown => "lora",
|
||||
}
|
||||
}
|
||||
|
||||
/// 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>,
|
||||
/// Routing key hex. For a radio (meshcore) peer this is the firmware
|
||||
/// contact public key used to address outbound DMs; for a federation-
|
||||
/// seeded peer it is the archipelago ed25519 key. Used for delivery, NOT
|
||||
/// for authentication — see `arch_pubkey_hex`.
|
||||
pub pubkey_hex: Option<String>,
|
||||
/// Verified archipelago ed25519 identity key hex, bound from a signed
|
||||
/// identity advert (`handle_identity_received`) or federation seeding.
|
||||
/// Unlike `pubkey_hex`, this is NEVER overwritten by `refresh_contacts`
|
||||
/// with the firmware routing key, so it stays stable for the `!ai` auth
|
||||
/// gate, envelope signature verification, and federation-trust matching.
|
||||
#[serde(default)]
|
||||
pub arch_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,
|
||||
/// Firmware advert timestamp (unix secs) of the contact's last advert, or
|
||||
/// 0 if unknown. Used to gauge reachability/recency in the UI.
|
||||
#[serde(default)]
|
||||
pub last_advert: u32,
|
||||
/// Best-effort "currently reachable" flag: the radio has a route to this
|
||||
/// contact (or it's a federation/identity peer reachable off-radio). A
|
||||
/// contact with no path and no recent advert is shown as unreachable.
|
||||
#[serde(default)]
|
||||
pub reachable: bool,
|
||||
/// Whether DMs to/from this peer are end-to-end (PKI / Curve25519) encrypted.
|
||||
/// Set for a Meshtastic peer once we know its real NodeInfo public key (the
|
||||
/// firmware then PKC-encrypts directed DMs), so the send path can show the
|
||||
/// E2E pill on a Sent DM to a PKC-capable stock peer, not only archy peers.
|
||||
#[serde(default)]
|
||||
pub pkc_capable: bool,
|
||||
/// Last known position (degrees), from a Meshtastic `POSITION_APP`
|
||||
/// broadcast. `None` until the peer has shared one.
|
||||
#[serde(default)]
|
||||
pub lat: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub lon: Option<f64>,
|
||||
/// This peer's advertised Lightning connection URI (`pubkey@host:port`).
|
||||
///
|
||||
/// Set ONLY from a received `LightningInfo` advertisement (or federation
|
||||
/// seeding in a later plan) — never inferred, never defaulted. `None` means
|
||||
/// "this peer has not told us it runs Lightning", which is what the
|
||||
/// channel-open picker uses to decide whether to offer it as a request
|
||||
/// target at all.
|
||||
///
|
||||
/// The advertisement is unauthenticated RF input, so this is a *request*
|
||||
/// target the operator chooses to act on, not a trusted identity. It is
|
||||
/// stored against the peer's authenticating key (`identity_pubkey_hex()`),
|
||||
/// never the firmware routing key (T-01-11).
|
||||
#[serde(default)]
|
||||
pub lightning_uri: Option<String>,
|
||||
}
|
||||
|
||||
impl MeshPeer {
|
||||
/// The key to use when AUTHENTICATING this peer (`!ai` trust/allowlist,
|
||||
/// envelope signature verification): the verified archipelago identity key
|
||||
/// if one is bound, otherwise the routing key. Never use the firmware
|
||||
/// routing key for auth when an archipelago identity is known — a radio
|
||||
/// peer's firmware key won't match its `nodes.json` archipelago key.
|
||||
pub fn identity_pubkey_hex(&self) -> Option<&str> {
|
||||
self.arch_pubkey_hex
|
||||
.as_deref()
|
||||
.or(self.pubkey_hex.as_deref())
|
||||
}
|
||||
}
|
||||
|
||||
/// 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>,
|
||||
/// Human-readable rendering — for text messages this is the raw text,
|
||||
/// for typed messages a short summary used as a fallback in lists.
|
||||
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,
|
||||
/// How this message actually traveled, for the per-message transport pill:
|
||||
/// "lora" (mesh radio), "fips", or "tor". `None` until known (a Sent
|
||||
/// federation message is finalized once the background send resolves the
|
||||
/// transport). Surfaced in the UI beside the E2E badge.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub transport: Option<String>,
|
||||
/// Typed-envelope label ("text", "invoice", "alert", "coordinate", ...).
|
||||
#[serde(default = "default_message_type")]
|
||||
pub message_type: String,
|
||||
/// Structured payload as JSON — populated for non-text typed messages.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub typed_payload: Option<serde_json::Value>,
|
||||
/// Hex-encoded sender pubkey. On Received: the peer's mesh public key
|
||||
/// (or first 6 bytes / full key as available). On Sent: None today,
|
||||
/// populated later when we own the key. Combined with `sender_seq`
|
||||
/// this forms a stable cross-transport MessageKey that reactions,
|
||||
/// replies, edits, and read-receipts can reference without relying
|
||||
/// on the local `id` field (which is only meaningful to one node).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub sender_pubkey: Option<String>,
|
||||
/// Per-sender monotonic sequence from the typed envelope. Paired with
|
||||
/// `sender_pubkey` to form the stable MessageKey.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub sender_seq: Option<u64>,
|
||||
}
|
||||
|
||||
fn default_message_type() -> String {
|
||||
"text".to_string()
|
||||
}
|
||||
|
||||
/// 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,
|
||||
/// Operator-configured LoRa region (e.g. "EU_868"), for the Device tab
|
||||
/// (#8) — composed in by `MeshService::status()` from `MeshConfig`, not
|
||||
/// part of the live session state itself.
|
||||
#[serde(default)]
|
||||
pub region: Option<String>,
|
||||
}
|
||||
|
||||
/// 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],
|
||||
},
|
||||
/// Block header received from an internet-connected mesh peer.
|
||||
BlockHeaderReceived {
|
||||
height: u64,
|
||||
hash: String,
|
||||
},
|
||||
/// Emergency or dead-man alert received from a peer.
|
||||
AlertReceived {
|
||||
alert_type: String,
|
||||
message: String,
|
||||
from_contact_id: u32,
|
||||
},
|
||||
/// TX relay completed (response received from internet peer).
|
||||
TxRelayCompleted {
|
||||
request_id: u64,
|
||||
txid: Option<String>,
|
||||
error: Option<String>,
|
||||
},
|
||||
/// Lightning relay completed (response received from internet peer).
|
||||
LightningRelayCompleted {
|
||||
request_id: u64,
|
||||
payment_hash: Option<String>,
|
||||
error: Option<String>,
|
||||
},
|
||||
/// An AI query arrived from a peer and was accepted for answering (#50).
|
||||
AssistQueryReceived {
|
||||
from_contact_id: u32,
|
||||
prompt: String,
|
||||
},
|
||||
/// A local-AI answer finished sending back to the asker (or failed) (#50).
|
||||
AssistResponseReady {
|
||||
req_id: u64,
|
||||
to_contact_id: u32,
|
||||
error: Option<String>,
|
||||
},
|
||||
/// A local-AI answer to a `!ai`-in-chat query, to be delivered back into
|
||||
/// the 1:1 thread via the transport-aware `MeshService::send_message`
|
||||
/// (Tor for federation peers, LoRa for radio peers). The mesh listener
|
||||
/// emits this because it can't route over federation itself — the signing
|
||||
/// key and Tor client live on MeshService. Consumed at the server layer.
|
||||
AssistChatReply {
|
||||
contact_id: u32,
|
||||
text: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn peer(arch: Option<&str>, routing: Option<&str>) -> MeshPeer {
|
||||
MeshPeer {
|
||||
contact_id: 1,
|
||||
advert_name: "Test".into(),
|
||||
did: None,
|
||||
pubkey_hex: routing.map(|s| s.to_string()),
|
||||
arch_pubkey_hex: arch.map(|s| s.to_string()),
|
||||
x25519_pubkey: None,
|
||||
rssi: None,
|
||||
snr: None,
|
||||
last_heard: String::new(),
|
||||
hops: 0,
|
||||
last_advert: 0,
|
||||
reachable: false,
|
||||
pkc_capable: false,
|
||||
lat: None,
|
||||
lon: None,
|
||||
lightning_uri: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_prefers_bound_archipelago_key_over_firmware_routing_key() {
|
||||
// A radio peer that sent an identity advert: routing key is the firmware
|
||||
// contact key, but auth must use the bound archipelago key.
|
||||
let p = peer(Some("archkey"), Some("firmwarekey"));
|
||||
assert_eq!(p.identity_pubkey_hex(), Some("archkey"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_falls_back_to_routing_key_when_no_advert() {
|
||||
// A plain peer with no archipelago identity bound: fall back to whatever
|
||||
// key we have (federation peers carry the arch key in pubkey_hex).
|
||||
let p = peer(None, Some("firmwarekey"));
|
||||
assert_eq!(p.identity_pubkey_hex(), Some("firmwarekey"));
|
||||
assert_eq!(peer(None, None).identity_pubkey_hex(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_style_routing_update_does_not_change_identity() {
|
||||
// Simulates refresh_contacts: pubkey_hex (routing) is rewritten to a new
|
||||
// firmware key while arch_pubkey_hex (identity) is preserved.
|
||||
let mut p = peer(Some("archkey"), Some("firmware-old"));
|
||||
p.pubkey_hex = Some("firmware-new".into());
|
||||
assert_eq!(p.identity_pubkey_hex(), Some("archkey"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
// WIP mesh/transport protocol — suppress dead code warnings
|
||||
#![allow(dead_code)]
|
||||
//! X3DH (Extended Triple Diffie-Hellman) key agreement for mesh sessions.
|
||||
//!
|
||||
//! Implements the Signal protocol's X3DH using existing Ed25519/X25519 identity
|
||||
//! infrastructure. Produces a shared root key that initializes the Double Ratchet.
|
||||
//!
|
||||
//! Protocol flow:
|
||||
//! 1. Alice publishes prekey bundle (identity key + signed prekey + one-time prekeys)
|
||||
//! 2. Bob fetches bundle, performs 3-way ECDH, sends initial message
|
||||
//! 3. Both derive identical root key via HKDF-SHA256
|
||||
|
||||
use super::crypto;
|
||||
use anyhow::{Context, Result};
|
||||
use ed25519_dalek::Signer;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use zeroize::Zeroize;
|
||||
|
||||
/// Info string for HKDF domain separation.
|
||||
const X3DH_INFO: &[u8] = b"ArchipelagoX3DH_v1";
|
||||
|
||||
/// Salt for HKDF (all zeros per Signal spec).
|
||||
const X3DH_SALT: [u8; 32] = [0u8; 32];
|
||||
|
||||
/// A signed prekey (rotated periodically).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SignedPrekey {
|
||||
pub id: u32,
|
||||
#[serde(with = "hex_array")]
|
||||
pub public: [u8; 32],
|
||||
/// Ed25519 signature of the public key bytes.
|
||||
#[serde(with = "hex_vec")]
|
||||
pub signature: Vec<u8>,
|
||||
}
|
||||
|
||||
/// A one-time prekey (consumed on first use).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OneTimePrekey {
|
||||
pub id: u32,
|
||||
#[serde(with = "hex_array")]
|
||||
pub public: [u8; 32],
|
||||
}
|
||||
|
||||
/// Published prekey bundle for initiating sessions.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PrekeyBundle {
|
||||
/// Ed25519 identity public key (verifying key).
|
||||
#[serde(with = "hex_array")]
|
||||
pub identity_key: [u8; 32],
|
||||
/// X25519 identity public key (derived from Ed25519).
|
||||
#[serde(with = "hex_array")]
|
||||
pub x25519_identity: [u8; 32],
|
||||
/// Signed prekey for DH.
|
||||
pub signed_prekey: SignedPrekey,
|
||||
/// Available one-time prekeys.
|
||||
pub one_time_prekeys: Vec<OneTimePrekey>,
|
||||
}
|
||||
|
||||
/// X3DH output: shared root key for initializing Double Ratchet.
|
||||
pub struct X3dhOutput {
|
||||
pub root_key: [u8; 32],
|
||||
/// The signed prekey used (needed for receiver to identify which session).
|
||||
pub signed_prekey_id: u32,
|
||||
/// The one-time prekey consumed (if any).
|
||||
pub one_time_prekey_id: Option<u32>,
|
||||
}
|
||||
|
||||
impl Drop for X3dhOutput {
|
||||
fn drop(&mut self) {
|
||||
self.root_key.zeroize();
|
||||
}
|
||||
}
|
||||
|
||||
/// Secret-side prekey data (kept by the bundle publisher).
|
||||
pub struct PrekeySecrets {
|
||||
pub signed_prekey_secret: [u8; 32],
|
||||
pub signed_prekey_id: u32,
|
||||
pub one_time_secrets: Vec<(u32, [u8; 32])>,
|
||||
}
|
||||
|
||||
impl Drop for PrekeySecrets {
|
||||
fn drop(&mut self) {
|
||||
self.signed_prekey_secret.zeroize();
|
||||
for (_, secret) in &mut self.one_time_secrets {
|
||||
secret.zeroize();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a prekey bundle and corresponding secrets.
|
||||
pub fn generate_prekey_bundle(
|
||||
identity_signing_key: &ed25519_dalek::SigningKey,
|
||||
num_one_time_prekeys: u32,
|
||||
) -> Result<(PrekeyBundle, PrekeySecrets)> {
|
||||
let identity_key = identity_signing_key.verifying_key().to_bytes();
|
||||
let x25519_identity = crypto::ed25519_pubkey_to_x25519(&identity_key)?;
|
||||
|
||||
// Generate signed prekey
|
||||
let (spk_secret, spk_public) = crypto::generate_x25519_ephemeral();
|
||||
// KEY-05: source named. This is a 4-byte prekey *identifier*, not key
|
||||
// material — the X25519 secret is the line above — so it is drawn unguarded:
|
||||
// the degenerate predicate's false-positive bound does not hold below 12
|
||||
// bytes. See the classification table in
|
||||
// docs/security/KEY-05-ENTROPY-ENFORCEMENT.md.
|
||||
let spk_id: u32 = rand::RngCore::next_u32(&mut rand::rngs::OsRng);
|
||||
let signature = identity_signing_key.sign(&spk_public);
|
||||
|
||||
let signed_prekey = SignedPrekey {
|
||||
id: spk_id,
|
||||
public: spk_public,
|
||||
signature: signature.to_bytes().to_vec(),
|
||||
};
|
||||
|
||||
// Generate one-time prekeys
|
||||
let mut one_time_prekeys = Vec::with_capacity(num_one_time_prekeys as usize);
|
||||
let mut one_time_secrets = Vec::with_capacity(num_one_time_prekeys as usize);
|
||||
for _ in 0..num_one_time_prekeys {
|
||||
let (otk_secret, otk_public) = crypto::generate_x25519_ephemeral();
|
||||
// KEY-05: source named; unguarded for the same reason as `spk_id` above.
|
||||
let otk_id: u32 = rand::RngCore::next_u32(&mut rand::rngs::OsRng);
|
||||
one_time_prekeys.push(OneTimePrekey {
|
||||
id: otk_id,
|
||||
public: otk_public,
|
||||
});
|
||||
one_time_secrets.push((otk_id, otk_secret));
|
||||
}
|
||||
|
||||
let bundle = PrekeyBundle {
|
||||
identity_key,
|
||||
x25519_identity,
|
||||
signed_prekey,
|
||||
one_time_prekeys,
|
||||
};
|
||||
|
||||
let secrets = PrekeySecrets {
|
||||
signed_prekey_secret: spk_secret,
|
||||
signed_prekey_id: spk_id,
|
||||
one_time_secrets,
|
||||
};
|
||||
|
||||
Ok((bundle, secrets))
|
||||
}
|
||||
|
||||
/// Verify a prekey bundle's signed prekey signature.
|
||||
pub fn verify_bundle(bundle: &PrekeyBundle) -> Result<()> {
|
||||
use ed25519_dalek::{Signature, VerifyingKey};
|
||||
|
||||
let verifying_key = VerifyingKey::from_bytes(&bundle.identity_key)
|
||||
.context("Invalid identity key in prekey bundle")?;
|
||||
let signature = Signature::from_slice(&bundle.signed_prekey.signature)
|
||||
.context("Invalid signature in prekey bundle")?;
|
||||
|
||||
verifying_key
|
||||
.verify_strict(&bundle.signed_prekey.public, &signature)
|
||||
.context("Prekey bundle signature verification failed")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Initiator side: perform X3DH to derive a shared root key.
|
||||
///
|
||||
/// Called by the party starting a new session (Bob initiates to Alice).
|
||||
/// Returns the X3DH output and the ephemeral public key that must be sent
|
||||
/// to the receiver alongside the first encrypted message.
|
||||
pub fn initiate(
|
||||
our_x25519_secret: &[u8; 32],
|
||||
their_bundle: &PrekeyBundle,
|
||||
) -> Result<(X3dhOutput, [u8; 32])> {
|
||||
// Verify the bundle's signed prekey signature
|
||||
verify_bundle(their_bundle)?;
|
||||
|
||||
// Generate ephemeral keypair for this session
|
||||
let (eph_secret, eph_public) = crypto::generate_x25519_ephemeral();
|
||||
|
||||
// Three (or four) DH operations:
|
||||
// DH1 = X25519(our_identity_x25519, their_signed_prekey)
|
||||
let dh1 = crypto::x25519_shared_secret(our_x25519_secret, &their_bundle.signed_prekey.public);
|
||||
// DH2 = X25519(ephemeral_secret, their_identity_x25519)
|
||||
let dh2 = crypto::x25519_shared_secret(&eph_secret, &their_bundle.x25519_identity);
|
||||
// DH3 = X25519(ephemeral_secret, their_signed_prekey)
|
||||
let dh3 = crypto::x25519_shared_secret(&eph_secret, &their_bundle.signed_prekey.public);
|
||||
|
||||
// Concatenate DH results
|
||||
let mut ikm = Vec::with_capacity(32 * 4);
|
||||
ikm.extend_from_slice(&dh1);
|
||||
ikm.extend_from_slice(&dh2);
|
||||
ikm.extend_from_slice(&dh3);
|
||||
|
||||
// DH4 with one-time prekey if available
|
||||
let otk_id = if let Some(otk) = their_bundle.one_time_prekeys.first() {
|
||||
let dh4 = crypto::x25519_shared_secret(&eph_secret, &otk.public);
|
||||
ikm.extend_from_slice(&dh4);
|
||||
Some(otk.id)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Derive root key via HKDF
|
||||
let root_key = crypto::hkdf_sha256_32(&X3DH_SALT, &ikm, X3DH_INFO)?;
|
||||
|
||||
// Zeroize intermediate material
|
||||
ikm.zeroize();
|
||||
|
||||
let output = X3dhOutput {
|
||||
root_key,
|
||||
signed_prekey_id: their_bundle.signed_prekey.id,
|
||||
one_time_prekey_id: otk_id,
|
||||
};
|
||||
|
||||
Ok((output, eph_public))
|
||||
}
|
||||
|
||||
/// Receiver side: perform X3DH to derive the same shared root key.
|
||||
///
|
||||
/// Called when receiving the first message of a new session from an initiator.
|
||||
pub fn respond(
|
||||
our_signed_prekey_secret: &[u8; 32],
|
||||
our_x25519_identity_secret: &[u8; 32],
|
||||
our_one_time_secret: Option<&[u8; 32]>,
|
||||
their_identity_x25519: &[u8; 32],
|
||||
their_ephemeral_public: &[u8; 32],
|
||||
) -> Result<X3dhOutput> {
|
||||
// Mirror the initiator's DH operations:
|
||||
// DH1 = X25519(our_signed_prekey_secret, their_identity_x25519)
|
||||
let dh1 = crypto::x25519_shared_secret(our_signed_prekey_secret, their_identity_x25519);
|
||||
// DH2 = X25519(our_identity_x25519_secret, their_ephemeral)
|
||||
let dh2 = crypto::x25519_shared_secret(our_x25519_identity_secret, their_ephemeral_public);
|
||||
// DH3 = X25519(our_signed_prekey_secret, their_ephemeral)
|
||||
let dh3 = crypto::x25519_shared_secret(our_signed_prekey_secret, their_ephemeral_public);
|
||||
|
||||
let mut ikm = Vec::with_capacity(32 * 4);
|
||||
ikm.extend_from_slice(&dh1);
|
||||
ikm.extend_from_slice(&dh2);
|
||||
ikm.extend_from_slice(&dh3);
|
||||
|
||||
if let Some(otk_secret) = our_one_time_secret {
|
||||
let dh4 = crypto::x25519_shared_secret(otk_secret, their_ephemeral_public);
|
||||
ikm.extend_from_slice(&dh4);
|
||||
}
|
||||
|
||||
let root_key = crypto::hkdf_sha256_32(&X3DH_SALT, &ikm, X3DH_INFO)?;
|
||||
ikm.zeroize();
|
||||
|
||||
Ok(X3dhOutput {
|
||||
root_key,
|
||||
signed_prekey_id: 0, // Not needed on receiver side
|
||||
one_time_prekey_id: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Encode a prekey bundle to CBOR bytes for mesh transmission.
|
||||
pub fn encode_bundle(bundle: &PrekeyBundle) -> Result<Vec<u8>> {
|
||||
let mut buf = Vec::new();
|
||||
ciborium::into_writer(bundle, &mut buf).context("Failed to CBOR-encode prekey bundle")?;
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
/// Decode a prekey bundle from CBOR bytes.
|
||||
pub fn decode_bundle(data: &[u8]) -> Result<PrekeyBundle> {
|
||||
ciborium::from_reader(data).context("Failed to CBOR-decode prekey bundle")
|
||||
}
|
||||
|
||||
// ─── Hex serialization helpers ──────────────────────────────────────────
|
||||
|
||||
mod hex_array {
|
||||
use serde::{Deserialize, Deserializer, Serializer};
|
||||
|
||||
pub fn serialize<S: Serializer>(bytes: &[u8; 32], s: S) -> Result<S::Ok, S::Error> {
|
||||
s.serialize_str(&hex::encode(bytes))
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<[u8; 32], D::Error> {
|
||||
let s = String::deserialize(d)?;
|
||||
let bytes = hex::decode(&s).map_err(serde::de::Error::custom)?;
|
||||
if bytes.len() != 32 {
|
||||
return Err(serde::de::Error::custom("expected 32 bytes"));
|
||||
}
|
||||
let mut arr = [0u8; 32];
|
||||
arr.copy_from_slice(&bytes);
|
||||
Ok(arr)
|
||||
}
|
||||
}
|
||||
|
||||
mod hex_vec {
|
||||
use serde::{Deserialize, Deserializer, Serializer};
|
||||
|
||||
pub fn serialize<S: Serializer>(bytes: &Vec<u8>, s: S) -> Result<S::Ok, S::Error> {
|
||||
s.serialize_str(&hex::encode(bytes))
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<u8>, D::Error> {
|
||||
let s = String::deserialize(d)?;
|
||||
hex::decode(&s).map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ed25519_dalek::SigningKey;
|
||||
use rand::rngs::OsRng;
|
||||
|
||||
#[test]
|
||||
fn test_generate_and_verify_bundle() {
|
||||
let signing_key = SigningKey::generate(&mut OsRng);
|
||||
let (bundle, _secrets) = generate_prekey_bundle(&signing_key, 5).unwrap();
|
||||
|
||||
assert_eq!(bundle.one_time_prekeys.len(), 5);
|
||||
assert!(verify_bundle(&bundle).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_x3dh_both_sides_derive_same_key() {
|
||||
let alice_signing = SigningKey::generate(&mut OsRng);
|
||||
let bob_signing = SigningKey::generate(&mut OsRng);
|
||||
|
||||
// Alice publishes bundle
|
||||
let (bundle, secrets) = generate_prekey_bundle(&alice_signing, 3).unwrap();
|
||||
|
||||
// Bob initiates X3DH
|
||||
let bob_x25519_secret = crypto::ed25519_secret_to_x25519(&bob_signing);
|
||||
let (bob_output, bob_ephemeral) = initiate(&bob_x25519_secret, &bundle).unwrap();
|
||||
|
||||
// Alice responds
|
||||
let alice_x25519_secret = crypto::ed25519_secret_to_x25519(&alice_signing);
|
||||
let bob_x25519_public =
|
||||
crypto::ed25519_pubkey_to_x25519(&bob_signing.verifying_key().to_bytes()).unwrap();
|
||||
|
||||
let otk_secret = secrets.one_time_secrets.first().map(|(_, s)| s);
|
||||
let alice_output = respond(
|
||||
&secrets.signed_prekey_secret,
|
||||
&alice_x25519_secret,
|
||||
otk_secret,
|
||||
&bob_x25519_public,
|
||||
&bob_ephemeral,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Both should derive the same root key
|
||||
assert_eq!(bob_output.root_key, alice_output.root_key);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_x3dh_without_one_time_prekey() {
|
||||
let alice_signing = SigningKey::generate(&mut OsRng);
|
||||
let bob_signing = SigningKey::generate(&mut OsRng);
|
||||
|
||||
// Alice publishes bundle with zero one-time prekeys
|
||||
let (bundle, secrets) = generate_prekey_bundle(&alice_signing, 0).unwrap();
|
||||
|
||||
let bob_x25519_secret = crypto::ed25519_secret_to_x25519(&bob_signing);
|
||||
let (bob_output, bob_ephemeral) = initiate(&bob_x25519_secret, &bundle).unwrap();
|
||||
|
||||
let alice_x25519_secret = crypto::ed25519_secret_to_x25519(&alice_signing);
|
||||
let bob_x25519_public =
|
||||
crypto::ed25519_pubkey_to_x25519(&bob_signing.verifying_key().to_bytes()).unwrap();
|
||||
|
||||
let alice_output = respond(
|
||||
&secrets.signed_prekey_secret,
|
||||
&alice_x25519_secret,
|
||||
None,
|
||||
&bob_x25519_public,
|
||||
&bob_ephemeral,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(bob_output.root_key, alice_output.root_key);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bundle_cbor_roundtrip() {
|
||||
let signing_key = SigningKey::generate(&mut OsRng);
|
||||
let (bundle, _) = generate_prekey_bundle(&signing_key, 3).unwrap();
|
||||
|
||||
let encoded = encode_bundle(&bundle).unwrap();
|
||||
let decoded = decode_bundle(&encoded).unwrap();
|
||||
|
||||
assert_eq!(decoded.identity_key, bundle.identity_key);
|
||||
assert_eq!(decoded.signed_prekey.id, bundle.signed_prekey.id);
|
||||
assert_eq!(decoded.one_time_prekeys.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tampered_bundle_fails_verification() {
|
||||
let signing_key = SigningKey::generate(&mut OsRng);
|
||||
let (mut bundle, _) = generate_prekey_bundle(&signing_key, 1).unwrap();
|
||||
|
||||
// Tamper with signed prekey public key
|
||||
bundle.signed_prekey.public[0] ^= 0xFF;
|
||||
|
||||
assert!(verify_bundle(&bundle).is_err());
|
||||
}
|
||||
|
||||
/// KEY-05: the prekey bundle crosses the wire to other nodes, so the entropy
|
||||
/// migration must be provably source-only. This pins the serialised field
|
||||
/// **set, types and ordering** — a later refactor that reshapes the bundle
|
||||
/// while "just" touching the RNG fails here rather than silently breaking
|
||||
/// every peer that already holds the old shape.
|
||||
#[test]
|
||||
fn prekey_bundle_wire_shape_unchanged() {
|
||||
let signing_key = SigningKey::generate(&mut OsRng);
|
||||
let (bundle, _secrets) = generate_prekey_bundle(&signing_key, 2).unwrap();
|
||||
let json = serde_json::to_string(&bundle).unwrap();
|
||||
|
||||
// Ordering. Checked against the emitted *string*, not a parsed
|
||||
// `serde_json::Value`: `Value`'s map is a `BTreeMap` unless the
|
||||
// `preserve_order` feature happens to be unified on, so a `Value` would
|
||||
// silently assert alphabetical order instead of declaration order. The
|
||||
// serialised text is what actually goes on the wire.
|
||||
let pos = |k: &str| json.find(k).unwrap_or_else(|| panic!("missing field {k}"));
|
||||
assert!(pos("\"identity_key\"") < pos("\"x25519_identity\""));
|
||||
assert!(pos("\"x25519_identity\"") < pos("\"signed_prekey\""));
|
||||
assert!(pos("\"signed_prekey\"") < pos("\"one_time_prekeys\""));
|
||||
|
||||
// Field set and types.
|
||||
let value: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||
let obj = value.as_object().expect("bundle serialises as an object");
|
||||
let mut keys: Vec<&str> = obj.keys().map(String::as_str).collect();
|
||||
keys.sort_unstable();
|
||||
assert_eq!(
|
||||
keys,
|
||||
vec![
|
||||
"identity_key",
|
||||
"one_time_prekeys",
|
||||
"signed_prekey",
|
||||
"x25519_identity"
|
||||
]
|
||||
);
|
||||
|
||||
// Both identity fields stay 32-byte values hex-encoded to 64 chars.
|
||||
assert_eq!(obj["identity_key"].as_str().unwrap().len(), 64);
|
||||
assert_eq!(obj["x25519_identity"].as_str().unwrap().len(), 64);
|
||||
|
||||
let spk = obj["signed_prekey"].as_object().unwrap();
|
||||
let mut spk_keys: Vec<&str> = spk.keys().map(String::as_str).collect();
|
||||
spk_keys.sort_unstable();
|
||||
assert_eq!(spk_keys, vec!["id", "public", "signature"]);
|
||||
assert!(spk["id"].is_u64(), "prekey id must remain an unsigned int");
|
||||
assert!(
|
||||
u32::try_from(spk["id"].as_u64().unwrap()).is_ok(),
|
||||
"prekey id must still fit u32"
|
||||
);
|
||||
assert_eq!(spk["public"].as_str().unwrap().len(), 64);
|
||||
|
||||
let otks = obj["one_time_prekeys"].as_array().unwrap();
|
||||
assert_eq!(otks.len(), 2);
|
||||
let mut otk_keys: Vec<&str> = otks[0]
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.keys()
|
||||
.map(String::as_str)
|
||||
.collect();
|
||||
otk_keys.sort_unstable();
|
||||
assert_eq!(otk_keys, vec!["id", "public"]);
|
||||
assert!(otks[0]["id"].is_u64());
|
||||
assert!(u32::try_from(otks[0]["id"].as_u64().unwrap()).is_ok());
|
||||
assert_eq!(otks[0]["public"].as_str().unwrap().len(), 64);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user