fix(mesh): Reticulum garbage-text + reconnect churn + signal bars + node naming/HTTPS

- reticulum.rs: send_text_msg was lossy-UTF8-mangling binary CBOR control
  envelopes (ReadReceipt etc.) before sending as LXMF text; base64-encode
  with a marker instead, decoded losslessly on receive.
- typed_messages.rs: mesh.send-read-receipt fired automatically on every
  chat view with no is_archy_peer gate, so viewing a message from a stock
  (non-archy) LXMF peer auto-sent it an undecodable control envelope,
  surfacing as garbage text right after whatever it just sent. Now a no-op
  for non-archy peers.
- mesh/listener/mod.rs: RX_STALL_TIMEOUT was 300s and forced a full
  auto-detect reconnect on any otherwise-healthy but quiet mesh link
  (visible as "Connecting..." flapping); this also wiped Reticulum's
  in-memory peer-address table every cycle, breaking messaging with peers
  who hadn't re-announced in the window. Bumped to 1800s.
- reticulum.rs: persist the peer prefix/dest-hash/display-name table to
  disk so a restart doesn't force every peer back to "Anonymous Peer"
  until they re-announce.
- decode.rs/frames.rs: Meshcore was discarding the SNR its wire format
  carries; wire it onto the peer record. Mesh.vue's signalBars() now falls
  back to SNR-based bars when RSSI is unavailable (always true for
  Meshcore); Reticulum has neither and correctly stays at 0/"no data".
- system/handlers.rs, dispatcher.rs: new system.get-hostname RPC + cert
  regeneration (with a proper SAN) whenever server.set-name changes the
  hostname, so HTTPS doesn't add a mismatch warning on top of the
  self-signed one after a rename.
- AccountInfoSection.vue: surface the mDNS hostname + http/https links in
  Settings (HTTPS needed for mic/camera secure-context features) — never
  forced, both keep working.
- build-auto-installer-iso.sh: ship avahi-daemon so .local names actually
  resolve on the LAN, and give the self-signed cert a real SAN instead of
  a bare CN, both at image-build and install-time-fallback.
- Mesh.vue/MediaLightbox.vue/mesh-styles.css: mic/attach-stack no longer
  closes on a plain hover-past; mesh images open in the shared lightbox
  and have a real download button; lightbox close button moves to
  bottom-center on mobile instead of under the status bar; mesh device
  panel gets the same height/padding as its sibling tabs.

Verified: 108/108 mesh unit tests, deployed + confirmed healthy on
.116/.198/.228 (matching binary hash across all three), live Reticulum
messaging confirmed working end-to-end post-deploy.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-07-01 10:42:20 -04:00
co-authored by Claude Sonnet 5
parent 99cd82ab0a
commit bebf3bae10
12 changed files with 486 additions and 37 deletions
@@ -421,6 +421,7 @@ impl RpcHandler {
"server.set-name" => self.handle_server_set_name(params).await,
// System monitoring
"system.get-hostname" => self.handle_system_get_hostname().await,
"system.stats" => self.handle_system_stats().await,
"system.processes" => self.handle_system_processes().await,
"system.temperature" => self.handle_system_temperature().await,
@@ -933,6 +933,15 @@ impl RpcHandler {
let svc = service
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
// Read receipts are fired automatically just by viewing a chat (no
// explicit user action), unlike every other typed send here — so a
// stock (non-archy) peer that can't decode a TypedEnvelope at all
// (e.g. a phone running plain Sideband) would otherwise get a raw
// control envelope shoved at it the moment its message is viewed,
// surfacing as garbage text right after whatever it just sent.
if !svc.is_archy_peer(contact_id).await {
return Ok(serde_json::json!({ "sent": false, "reason": "not an archy peer" }));
}
let seq = svc.next_send_seq(contact_id).await;
let payload = message_types::encode_payload(&receipt)?;
let envelope = TypedEnvelope::new(MeshMessageType::ReadReceipt, payload).with_seq(seq);
@@ -47,6 +47,17 @@ impl RpcHandler {
}
};
// Keep the self-signed HTTPS cert's SAN in sync with the new hostname —
// best-effort, never blocks the rename itself. Without this the cert
// stays pinned to whatever name was set at install time, so browsers
// hit a hostname-mismatch warning on top of the usual self-signed one
// the moment a node is renamed.
if hostname_updated {
if let Err(e) = regenerate_tls_cert(&hostname).await {
warn!(hostname = %hostname, "TLS cert regen after rename failed: {}", e);
}
}
info!("Server name updated to: {}", name);
// Push the new name to federation peers in background
@@ -66,6 +77,21 @@ impl RpcHandler {
}))
}
/// system.get-hostname — Current OS hostname + the mDNS `.local` name it
/// resolves to on the LAN (avahi-daemon advertises `<hostname>.local`).
/// Lets Settings show users where to reach this node over HTTPS for
/// features (mic/camera access) that require a secure context.
pub(in crate::api::rpc) async fn handle_system_get_hostname(&self) -> Result<serde_json::Value> {
let hostname = tokio::fs::read_to_string("/etc/hostname")
.await
.map(|s| s.trim().to_string())
.unwrap_or_else(|_| "archipelago".to_string());
Ok(serde_json::json!({
"hostname": hostname,
"mdns_hostname": format!("{hostname}.local"),
}))
}
/// system.stats — CPU usage, RAM used/total, disk used/total, uptime, load average
pub(in crate::api::rpc) async fn handle_system_stats(&self) -> Result<serde_json::Value> {
debug!("Getting system stats");
@@ -319,6 +345,63 @@ async fn set_system_hostname(hostname: &str) -> Result<()> {
Ok(())
}
/// Regenerate the self-signed HTTPS cert (`/etc/archipelago/ssl/archipelago.{crt,key}`)
/// with a SAN covering `hostname`, `hostname.local`, `localhost`, and 127.0.0.1, then
/// reload nginx so it picks up the new cert. Still self-signed (browsers will warn
/// on first visit regardless), but avoids stacking a hostname-mismatch warning on
/// top once a node has been renamed away from the install-time default.
async fn regenerate_tls_cert(hostname: &str) -> Result<()> {
let subj = format!("/C=XX/ST=Bitcoin/L=Node/O=Archipelago/CN={hostname}");
let san = format!("subjectAltName=DNS:{hostname},DNS:{hostname}.local,DNS:localhost,IP:127.0.0.1");
let output = tokio::process::Command::new("/usr/bin/sudo")
.args([
"-n",
"/usr/bin/openssl",
"req",
"-x509",
"-nodes",
"-days",
"3650",
"-newkey",
"rsa:2048",
"-keyout",
"/etc/archipelago/ssl/archipelago.key",
"-out",
"/etc/archipelago/ssl/archipelago.crt",
"-subj",
&subj,
"-addext",
&san,
])
.output()
.await
.context("Failed to run openssl")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
anyhow::bail!(
"{}",
if stderr.is_empty() {
"openssl cert regen failed".to_string()
} else {
stderr
}
);
}
let reload = tokio::process::Command::new("/usr/bin/sudo")
.args(["-n", "/usr/bin/systemctl", "reload", "nginx"])
.output()
.await
.context("Failed to reload nginx")?;
if !reload.status.success() {
let stderr = String::from_utf8_lossy(&reload.stderr).trim().to_string();
anyhow::bail!("nginx reload failed: {}", stderr);
}
Ok(())
}
impl RpcHandler {
/// system.factory-reset — Wipe all user data, remove containers, and restart.
/// Only preserves the data_dir itself (recreated empty on restart).
@@ -381,6 +381,17 @@ fn meshtastic_peer_from_prefix(sender_prefix: &str) -> Option<(u32, String, Stri
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>,
+3 -2
View File
@@ -5,7 +5,7 @@ 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,
try_decrypt_base64, try_decrypt_ratchet_base64, update_peer_snr,
};
use super::dispatch::handle_typed_message;
use super::MeshState;
@@ -66,10 +66,11 @@ pub(super) async fn handle_frame(
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)) => {
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) {
+14 -4
View File
@@ -33,10 +33,20 @@ const SYNC_INTERVAL: Duration = Duration::from_secs(10);
/// 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 this same file already has a targeted fix for). 5 minutes is well
/// above the existing 60s advert / 10s sync cadence, so a healthy link never
/// trips it.
const RX_STALL_TIMEOUT: Duration = Duration::from_secs(300);
/// 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;
+169 -9
View File
@@ -25,6 +25,7 @@ use super::message_types::{self, ContentInlinePayload, MeshMessageType, TypedEnv
use super::protocol::{self, InboundFrame, ParsedContact};
use super::types::DeviceInfo;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::path::Path;
@@ -48,6 +49,16 @@ const KISS_CMD_MCU: u8 = 0x49;
const PROBE_BAUD: u32 = 115200;
const PROBE_READ_TIMEOUT: Duration = Duration::from_millis(800);
/// Prefix marking an LXMF `content` string as base64 of a raw binary
/// typed-envelope payload rather than literal text. LXMF `content` travels
/// as a JSON string over the daemon RPC bridge, so a binary CBOR envelope
/// sent through `send_text_msg` as-is (single-frame path — anything chunked
/// is already base64'd before it gets here) must be lossily reinterpreted as
/// UTF-8 or it corrupts the wire and surfaces as garbage text on receipt.
/// Real plain-text chat never starts with this (its first byte would have
/// to be the `TypedEnvelope` marker 0x02), so the check is unambiguous.
const RETICULUM_BINARY_CONTENT_MARKER: &str = "\u{0}b64:";
/// Path to the supervised daemon binary. In production this is the bundled
/// PyInstaller artifact shipped beside `/usr/local/bin/archipelago` (Phase 1
/// packaging); during development it can point at the venv's interpreter
@@ -111,6 +122,18 @@ struct ReticulumPeer {
reachable: bool,
}
/// On-disk shape of `ReticulumPeer` — `[u8; 16]` can't be a JSON object key,
/// so this stores the dest hash as hex instead. Persisted so a peer's
/// prefix->dest-hash mapping and display name survive a restart instead of
/// requiring a fresh announce every time (both `prefix_to_hash` and `peers`
/// are otherwise pure in-memory state rebuilt empty on every reconnect).
#[derive(Serialize, Deserialize)]
struct PersistedReticulumPeer {
dest_hash_hex: String,
display_name: String,
arch_pubkey_hex: Option<String>,
}
/// Bridge handle to one supervised `reticulum-daemon` instance, one per active
/// Reticulum (RNode) radio. Implements the same method shapes
/// `MeshRadioDevice` calls on `MeshcoreDevice`/`MeshtasticDevice`.
@@ -127,6 +150,9 @@ pub struct ReticulumLink {
/// announces and `get_contacts`.
prefix_to_hash: HashMap<[u8; 6], [u8; 16]>,
peers: HashMap<[u8; 16], ReticulumPeer>,
/// Where `peers.json` lives (`{data_dir}/reticulum/peers.json`) — set once
/// at spawn, used by `persist_peers`/`load_persisted_peers`.
peers_file: std::path::PathBuf,
inbound: std::collections::VecDeque<InboundFrame>,
/// Monotonic correlation id for `send_resource` RPC calls — purely for
/// matching `resource_progress`/`resource_sent`/`resource_failed` events
@@ -248,7 +274,7 @@ impl ReticulumLink {
"Reticulum daemon ready"
);
Ok(Self {
let mut link = Self {
device_path: path.to_string(),
socket_path,
child,
@@ -258,9 +284,68 @@ impl ReticulumLink {
display_name,
prefix_to_hash: HashMap::new(),
peers: HashMap::new(),
peers_file: runtime_dir.join("peers.json"),
inbound: std::collections::VecDeque::new(),
resource_id_counter: 0,
})
};
link.load_persisted_peers();
Ok(link)
}
/// Repopulate `peers`/`prefix_to_hash` from disk so a restart doesn't
/// force every peer back to "Anonymous Peer" / unreachable until they
/// happen to re-announce or message us again.
fn load_persisted_peers(&mut self) {
let Ok(bytes) = std::fs::read(&self.peers_file) else {
return;
};
let Ok(persisted) = serde_json::from_slice::<Vec<PersistedReticulumPeer>>(&bytes) else {
warn!(path = %self.peers_file.display(), "Failed to parse persisted Reticulum peers");
return;
};
for p in persisted {
let Ok(hash) = parse_hash16(&p.dest_hash_hex) else {
continue;
};
let prefix: [u8; 6] = hash[..6].try_into().unwrap();
self.prefix_to_hash.insert(prefix, hash);
self.peers.insert(
hash,
ReticulumPeer {
dest_hash: hash,
display_name: p.display_name,
arch_pubkey_hex: p.arch_pubkey_hex,
// Reachability is a live property, not a persisted fact —
// start conservative and let the first real event refresh it.
reachable: false,
},
);
}
info!(count = self.peers.len(), "Loaded persisted Reticulum peers");
}
/// Best-effort sync write of the current peer table — called after any
/// insert that adds/renames a peer. Infrequent (announces/first-contact,
/// not per-message) so a blocking write here is a fine trade for keeping
/// this out of the async runtime's plumbing.
fn persist_peers(&self) {
let persisted: Vec<PersistedReticulumPeer> = self
.peers
.values()
.map(|p| PersistedReticulumPeer {
dest_hash_hex: hex::encode(p.dest_hash),
display_name: p.display_name.clone(),
arch_pubkey_hex: p.arch_pubkey_hex.clone(),
})
.collect();
match serde_json::to_vec(&persisted) {
Ok(bytes) => {
if let Err(e) = std::fs::write(&self.peers_file, bytes) {
warn!(path = %self.peers_file.display(), "Failed to persist Reticulum peers: {}", e);
}
}
Err(e) => warn!("Failed to serialize Reticulum peers: {}", e),
}
}
fn next_resource_id(&mut self) -> String {
@@ -319,10 +404,21 @@ impl ReticulumLink {
hex::encode(dest_pubkey_prefix)
)
})?;
// Typed-envelope payloads (ReadReceipt/Reaction/etc. — anything small
// enough for the single-frame path) are raw binary CBOR, not text.
// `from_utf8_lossy` would irreversibly mangle them since `content`
// round-trips as a JSON string; base64 instead so receive can recover
// the exact original bytes.
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
let content = if TypedEnvelope::is_typed(payload) {
format!("{RETICULUM_BINARY_CONTENT_MARKER}{}", B64.encode(payload))
} else {
String::from_utf8_lossy(payload).into_owned()
};
self.send_rpc(serde_json::json!({
"cmd": "send",
"dest_hash": hex::encode(dest_hash),
"content": String::from_utf8_lossy(payload),
"content": content,
"method": "direct",
}))
.await
@@ -560,6 +656,7 @@ impl ReticulumLink {
arch_pubkey_hex: None,
reachable: true,
});
self.persist_peers();
}
Some("recv") => {
let Some(source_hex) = ev.get("source_hash").and_then(Value::as_str) else {
@@ -570,6 +667,19 @@ impl ReticulumLink {
};
let prefix: [u8; 6] = source_hash[..6].try_into().unwrap();
self.prefix_to_hash.insert(prefix, source_hash);
// A peer that messages us without ever announcing still needs
// to survive a restart — give it a placeholder name (the real
// one, if any, arrives via a later "announce" and overwrites
// this) so its routing entry alone doesn't get lost.
if let std::collections::hash_map::Entry::Vacant(e) = self.peers.entry(source_hash) {
e.insert(ReticulumPeer {
dest_hash: source_hash,
display_name: format!("Reticulum {}", hex::encode(&source_hash[..4])),
arch_pubkey_hex: None,
reachable: true,
});
self.persist_peers();
}
// A stock LXMF client (Sideband/NomadNet — not an archy peer)
// carries photos/files in native LXMF fields, not our own
@@ -615,12 +725,17 @@ impl ReticulumLink {
}
}
let content = ev
.get("content")
.and_then(Value::as_str)
.unwrap_or("")
.as_bytes()
.to_vec();
let content_str = ev.get("content").and_then(Value::as_str).unwrap_or("");
let content = match content_str.strip_prefix(RETICULUM_BINARY_CONTENT_MARKER) {
Some(b64) => match B64.decode(b64) {
Ok(bytes) => bytes,
Err(e) => {
warn!("Failed to decode binary Reticulum content: {}", e);
Vec::new()
}
},
None => content_str.as_bytes().to_vec(),
};
self.inbound.push_back(build_synthetic_frame(&prefix, &content));
}
Some("resource_recv") => {
@@ -632,6 +747,15 @@ impl ReticulumLink {
};
let prefix: [u8; 6] = source_hash[..6].try_into().unwrap();
self.prefix_to_hash.insert(prefix, source_hash);
if let std::collections::hash_map::Entry::Vacant(e) = self.peers.entry(source_hash) {
e.insert(ReticulumPeer {
dest_hash: source_hash,
display_name: format!("Reticulum {}", hex::encode(&source_hash[..4])),
arch_pubkey_hex: None,
reachable: true,
});
self.persist_peers();
}
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
let Some(data) = ev
.get("data_b64")
@@ -860,6 +984,42 @@ mod tests {
assert!(!contains_detect_resp(&stream));
}
/// Regression test for the peer-identity persistence fix: a dest hash
/// round-tripped through `PersistedReticulumPeer` (hex on disk) via
/// `hex::encode`/`parse_hash16` — the exact pair `persist_peers`/
/// `load_persisted_peers` use — must come back byte-for-byte identical,
/// or a restart would silently corrupt peer routing instead of just
/// losing it.
#[test]
fn persisted_peer_hex_roundtrip() {
let hash: [u8; 16] = [
0x18, 0x70, 0x74, 0x4d, 0x7c, 0x35, 0xa9, 0x2c, 0xf0, 0x61, 0xfb, 0x81, 0x0e, 0xf3,
0x41, 0x65,
];
let persisted = vec![PersistedReticulumPeer {
dest_hash_hex: hex::encode(hash),
display_name: "zazaticulum".to_string(),
arch_pubkey_hex: Some("abcdef".to_string()),
}];
let dir = std::env::temp_dir().join(format!("archy-reticulum-peers-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("peers.json");
std::fs::write(&path, serde_json::to_vec(&persisted).unwrap()).unwrap();
let loaded: Vec<PersistedReticulumPeer> =
serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0].display_name, "zazaticulum");
assert_eq!(loaded[0].arch_pubkey_hex.as_deref(), Some("abcdef"));
let round_tripped_hash = parse_hash16(&loaded[0].dest_hash_hex).unwrap();
assert_eq!(round_tripped_hash, hash);
let prefix: [u8; 6] = round_tripped_hash[..6].try_into().unwrap();
assert_eq!(prefix, hash[..6]);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn contact_id_masks_high_bit_and_avoids_zero() {
let hash_high_bit = {