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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user