feat: v1.2.0-alpha — E2E encrypted mesh relay, steganography, relay status polling

Phase 5 mesh networking:
- E2E encrypted TX relay (X25519 + ChaCha20-Poly1305) — non-Archy nodes
  relay encrypted blobs transparently via Meshcore native routing
- Steganographic encoding modes (WeatherStation, SensorNetwork) — traffic
  looks like sensor data on the wire, 0xAA marker, configurable per-node
- Pre-flight Bitcoin Core health check on relay node — specific error codes
  (bitcoin_unreachable, bitcoin_syncing, tx_rejected) instead of generic fails
- mesh.relay-status RPC endpoint — frontend polls for relay result every 3s
- On-Chain / Lightning tabs in Off-Grid Bitcoin panel
- Archy Peers vs Mesh Broadcast relay mode selector
- Mesh view fills viewport (no page scroll), internal panel scrolling
- Version bump to 1.2.0-alpha

Also includes: deploy hardening, container fixes, IndeedHub updates,
boot screen, dashboard improvements, MASTER_PLAN task tracking

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-17 23:56:37 +00:00
parent d1ac098edb
commit f273816405
48 changed files with 3432 additions and 438 deletions

View File

@@ -751,16 +751,21 @@ impl RpcHandler {
return Err(anyhow::anyhow!("Failed to sign TX: {}", msg));
}
// raw_final_tx is the hex-encoded signed transaction — ready for broadcast
let raw_final_tx = body.get("raw_final_tx")
// raw_final_tx from LND is base64-encoded — decode to hex for Bitcoin RPC
let raw_final_tx_b64 = body.get("raw_final_tx")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("No raw_final_tx in response"))?
.to_string();
.ok_or_else(|| anyhow::anyhow!("No raw_final_tx in response"))?;
info!(addr, amount_sats, tx_len = raw_final_tx.len(), "Created raw TX for mesh relay (NOT broadcast)");
use base64::Engine;
let tx_bytes = base64::engine::general_purpose::STANDARD
.decode(raw_final_tx_b64)
.context("Failed to decode raw_final_tx base64")?;
let raw_tx_hex = hex::encode(&tx_bytes);
info!(addr, amount_sats, tx_len = raw_tx_hex.len(), "Created raw TX for mesh relay (NOT broadcast)");
Ok(serde_json::json!({
"raw_tx_hex": raw_final_tx,
"raw_tx_hex": raw_tx_hex,
"amount_sats": amount_sats,
"addr": addr,
"broadcast": false,

View File

@@ -423,6 +423,10 @@ impl RpcHandler {
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing tx_hex"))?;
let relay_mode = params["relay_mode"]
.as_str()
.unwrap_or("archy");
if tx_hex.len() < 20 || tx_hex.len() > 200_000 {
anyhow::bail!("Invalid tx_hex length");
}
@@ -440,30 +444,83 @@ impl RpcHandler {
let wire = crate::mesh::bitcoin_relay::build_tx_relay_request(tx_hex, request_id)?;
// Send ONLY to Archipelago peers (Archy-* nodes), not broadcast to all devices
let peers = svc.peers().await;
let mut sent_count = 0u32;
for peer in &peers {
if !peer.advert_name.starts_with("Archy-") { continue; }
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]);
let _ = svc.shared_state()
.cmd_tx
.send(crate::mesh::listener::MeshCommand::SendRaw {
dest_pubkey_prefix: prefix,
payload: wire.clone(),
})
.await;
sent_count += 1;
if relay_mode == "broadcast" {
// Broadcast mode: send on channel 0 (all mesh nodes relay)
// Still encrypted — only Archy nodes can decrypt and broadcast the TX
let shared_state = svc.shared_state();
let shared_secrets = shared_state.shared_secrets.read().await;
// Encrypt with first available Archy peer's shared secret
// (any Archy node that receives it can try decrypting)
let payload = shared_secrets.values().next()
.and_then(|secret| {
crate::mesh::crypto::encrypt(secret, &wire).ok().map(|ct| {
let mut encrypted = Vec::with_capacity(1 + ct.len());
encrypted.push(crate::mesh::message_types::ENCRYPTED_TYPED_MARKER);
encrypted.extend_from_slice(&ct);
encrypted
})
})
.unwrap_or_else(|| wire.clone());
drop(shared_secrets);
{
use base64::Engine;
let b64 = base64::engine::general_purpose::STANDARD.encode(&payload);
let _ = shared_state
.cmd_tx
.send(crate::mesh::listener::MeshCommand::BroadcastChannel {
channel: 0,
payload: b64.into_bytes(),
})
.await;
}
sent_count = 1;
info!(request_id, tx_len = tx_hex.len(), "TX relay broadcast on mesh channel 0 (encrypted)");
} else {
// Archy mode: E2E encrypted per-peer, direct to known Archy nodes
let peers = svc.peers().await;
let shared_state = svc.shared_state();
let shared_secrets = shared_state.shared_secrets.read().await;
for peer in &peers {
if !peer.advert_name.starts_with("Archy-") { continue; }
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]);
let payload = if let Some(secret) = shared_secrets.get(&peer.contact_id) {
match crate::mesh::crypto::encrypt(secret, &wire) {
Ok(ciphertext) => {
let mut encrypted = Vec::with_capacity(1 + ciphertext.len());
encrypted.push(crate::mesh::message_types::ENCRYPTED_TYPED_MARKER);
encrypted.extend_from_slice(&ciphertext);
encrypted
}
Err(_) => wire.clone(),
}
} else {
wire.clone()
};
let _ = svc.shared_state()
.cmd_tx
.send(crate::mesh::listener::MeshCommand::SendRaw {
dest_pubkey_prefix: prefix,
payload,
})
.await;
sent_count += 1;
}
}
}
}
drop(shared_secrets);
info!(request_id, tx_len = tx_hex.len(), archy_peers = sent_count, "TX relay sent to Archy peers (E2E encrypted)");
}
info!(request_id, tx_len = tx_hex.len(), archy_peers = sent_count, "TX relay sent to Archy peers only");
Ok(serde_json::json!({
"request_id": request_id,
"queued": true,
@@ -471,6 +528,47 @@ impl RpcHandler {
}))
}
/// mesh.relay-status — Check the status of a pending or completed TX relay.
pub(super) async fn handle_mesh_relay_status(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let request_id = params["request_id"]
.as_u64()
.ok_or_else(|| anyhow::anyhow!("Missing request_id"))?;
let service = self.mesh_service.read().await;
let svc = service.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
// Check completed results first
if let Some(result) = svc.relay_tracker.get_result(request_id).await {
return Ok(serde_json::json!({
"status": if result.txid.is_some() { "confirmed" } else { "failed" },
"request_id": result.request_id,
"txid": result.txid,
"error": result.error,
"error_code": result.error_code,
"completed_at": result.completed_at,
}));
}
// Check if still pending
if svc.relay_tracker.is_pending(request_id).await {
return Ok(serde_json::json!({
"status": "pending",
"request_id": request_id,
}));
}
// Unknown — either expired or never existed
Ok(serde_json::json!({
"status": "unknown",
"request_id": request_id,
}))
}
/// mesh.block-headers — Get cached block headers received from mesh peers.
pub(super) async fn handle_mesh_block_headers(
&self,
@@ -529,8 +627,10 @@ impl RpcHandler {
bolt11, amount_sats, request_id,
)?;
// Send ONLY to Archipelago peers, not broadcast
// Send to Archipelago peers — E2E encrypted per-peer
let peers = svc.peers().await;
let shared_state = svc.shared_state();
let shared_secrets = shared_state.shared_secrets.read().await;
let mut sent_count = 0u32;
for peer in &peers {
if !peer.advert_name.starts_with("Archy-") { continue; }
@@ -539,11 +639,26 @@ impl RpcHandler {
if pk_bytes.len() >= 6 {
let mut prefix = [0u8; 6];
prefix.copy_from_slice(&pk_bytes[..6]);
let payload = if let Some(secret) = shared_secrets.get(&peer.contact_id) {
match crate::mesh::crypto::encrypt(secret, &wire) {
Ok(ciphertext) => {
let mut encrypted = Vec::with_capacity(1 + ciphertext.len());
encrypted.push(crate::mesh::message_types::ENCRYPTED_TYPED_MARKER);
encrypted.extend_from_slice(&ciphertext);
encrypted
}
Err(_) => wire.clone(),
}
} else {
wire.clone()
};
let _ = svc.shared_state()
.cmd_tx
.send(crate::mesh::listener::MeshCommand::SendRaw {
dest_pubkey_prefix: prefix,
payload: wire.clone(),
payload,
})
.await;
sent_count += 1;
@@ -551,8 +666,9 @@ impl RpcHandler {
}
}
}
drop(shared_secrets);
info!(request_id, amount_sats, archy_peers = sent_count, "Lightning relay sent to Archy peers only");
info!(request_id, amount_sats, archy_peers = sent_count, "Lightning relay sent (E2E encrypted)");
Ok(serde_json::json!({
"request_id": request_id,
"queued": true,
@@ -670,4 +786,80 @@ impl RpcHandler {
"one_time_prekeys": bundle.one_time_prekeys.len(),
}))
}
// ─── Radio Diagnostics ─────────────────────────────────────────────
/// mesh.test-send — Send test payloads of various sizes to diagnose radio link.
/// Sends plain text markers that the receiver can count.
pub(super) async fn handle_mesh_test_send(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let contact_id = params["contact_id"]
.as_u64()
.ok_or_else(|| anyhow::anyhow!("Missing contact_id"))? as u32;
// Test modes: "ping" (small), "medium" (80 bytes), "large" (150 bytes), "chunked" (400 bytes)
let mode = params["mode"].as_str().unwrap_or("ping");
let count = params["count"].as_u64().unwrap_or(3) as usize;
let service = self.mesh_service.read().await;
let svc = service.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
let mut sent = 0usize;
let test_id = chrono::Utc::now().timestamp() as u32;
for i in 0..count {
let payload = match mode {
"ping" => format!("MESHTEST:{}:{}:PING", test_id, i),
"medium" => format!("MESHTEST:{}:{}:{}", test_id, i, "X".repeat(60)),
"large" => format!("MESHTEST:{}:{}:{}", test_id, i, "X".repeat(130)),
"chunked" => {
// Send a TypedEnvelope that requires chunking (>140 base64 chars)
let fake_tx = "0".repeat(400); // simulates TX hex
let wire = crate::mesh::bitcoin_relay::build_tx_relay_request(&fake_tx, test_id as u64 + i as u64)?;
// Send via SendRaw which handles base64 + chunking
let peers = svc.peers().await;
if let Some(peer) = peers.iter().find(|p| p.contact_id == 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]);
let _ = svc.shared_state().cmd_tx.send(
crate::mesh::listener::MeshCommand::SendRaw {
dest_pubkey_prefix: prefix,
payload: wire,
},
).await;
sent += 1;
}
}
}
}
// Delay between chunked sends
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
continue;
}
_ => format!("MESHTEST:{}:{}:UNKNOWN", test_id, i),
};
// Send as plain text for ping/medium/large
let msg = svc.send_message(contact_id, &payload).await?;
sent += 1;
info!(test_id, seq = i, mode, len = payload.len(), "Test message sent");
// Small delay between sends
tokio::time::sleep(std::time::Duration::from_millis(1000)).await;
}
Ok(serde_json::json!({
"test_id": test_id,
"mode": mode,
"sent": sent,
"count": count,
}))
}
}

View File

@@ -655,11 +655,13 @@ impl RpcHandler {
"mesh.rotate-prekeys" => self.handle_mesh_rotate_prekeys().await,
// Phase 4: Off-grid Bitcoin operations
"mesh.relay-tx" => self.handle_mesh_relay_tx(params).await,
"mesh.relay-status" => self.handle_mesh_relay_status(params).await,
"mesh.block-headers" => self.handle_mesh_block_headers(params).await,
"mesh.relay-lightning" => self.handle_mesh_relay_lightning(params).await,
"mesh.deadman-status" => self.handle_mesh_deadman_status().await,
"mesh.deadman-configure" => self.handle_mesh_deadman_configure(params).await,
"mesh.deadman-checkin" => self.handle_mesh_deadman_checkin().await,
"mesh.test-send" => self.handle_mesh_test_send(params).await,
// Transport layer (unified routing)
"transport.status" => self.handle_transport_status().await,