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).