From a8c4694c3600829ab817623de8dd6042d41c140e Mon Sep 17 00:00:00 2001 From: archipelago Date: Tue, 28 Jul 2026 16:36:52 -0400 Subject: [PATCH] =?UTF-8?q?fix(mesh):=20first-class=20Reticulum=20?= =?UTF-8?q?=E2=80=94=20probe=20boot-race,=20live=20config=20apply,=20name?= =?UTF-8?q?=20propagation,=20daemon-death=20detection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root causes found and fixed after live debugging on archi-dev-box (all verified on real RNode hardware, archi-dev-box <-> archy-x250-dev E2E): - probe_rnode raced the board's own boot: opening the port pulses DTR/RTS through USB-UART bridges (CP2102/Heltec V3), the ESP32 power-cycles and spends ~2.5-3s in boot ROM, and the KISS DETECT written 300ms after open landed in the void — so an RNode could NEVER connect on these boards. Now: immediate probe (fast path), then drain-until-quiet boot settle and a second DETECT with a fresh response window. - MeshService::configure() only restarted the listener on enable/disable — device_kind/device_path/advert_name/RF-param changes were silent no-ops until a full process restart (the setup modal's apply/keep-as-is did nothing). Material config changes now bounce the listener; the open sequence races the shutdown signal so stop() no longer burns the full 15s timeout mid-probe; mesh.configure applies in the background instead of stalling every status poll behind the service write-lock. - The mesh name was write-only: config.advert_name had no reader, server.set-name never reached the mesh service, and Reticulum's set_advert_name was a no-op (daemon display name fixed at spawn, and the ARCHY:2 announce blob REPLACED the LXMF display name — every archy node was anonymous on RNS). Now: advert_name > server name precedence feeds the session, renames restart it live, the daemon gets --display-name at spawn plus a set_name RPC verb, and announces carry the LXMF-standard msgpack name with the identity blob appended as an extra list element stock clients (Sideband/NomadNet) ignore. - Dead reticulum-daemon was invisible for up to 30min (RX-stall watchdog): child exit / RPC-EOF now fails try_recv_frame so the session reconnects. - Setup modal re-trigger loop: plugged_at used the tty node's mtime, which bumps on every open — each probe invalidated the dismissal key. Use btime/ctime (only change on real plugs). - ARCHY:2 identity adverts (re-emitted every 60s over Reticulum) stomped the federation twin's real name with a synthetic Archy-… placeholder and nulled its position; blob-only announces no longer assert a name, blob strings can never become display names, and stale blob names are healed at peers.json load. - mesh.broadcast on Meshtastic sent heartbeat+time only (no identity); SendAdvert now also fires a want_response NodeInfo broadcast. - New mesh.refresh RPC: actively re-queries the radio contact table (the UI Refresh button previously only re-read server caches). - Reticulum peers now track last_advert (announce time) and mark existing peers reachable on inbound traffic. - Boot auto-enable no longer force-enables mesh when an operator explicitly disabled it (only fires when no config file exists). Co-Authored-By: Claude Fable 5 --- core/archipelago/src/api/rpc/dispatcher.rs | 1 + .../archipelago/src/api/rpc/mesh/messaging.rs | 32 +- core/archipelago/src/api/rpc/mesh/status.rs | 22 ++ .../src/api/rpc/system/handlers.rs | 22 ++ core/archipelago/src/mesh/listener/decode.rs | 20 +- core/archipelago/src/mesh/listener/session.rs | 160 ++++++--- core/archipelago/src/mesh/mod.rs | 92 +++++- core/archipelago/src/mesh/reticulum.rs | 303 +++++++++++++++--- core/archipelago/src/mesh/serial.rs | 23 +- core/archipelago/src/server.rs | 9 +- reticulum-daemon/reticulum_daemon.py | 125 ++++++-- 11 files changed, 677 insertions(+), 132 deletions(-) diff --git a/core/archipelago/src/api/rpc/dispatcher.rs b/core/archipelago/src/api/rpc/dispatcher.rs index b8a974d7..fd4686d9 100644 --- a/core/archipelago/src/api/rpc/dispatcher.rs +++ b/core/archipelago/src/api/rpc/dispatcher.rs @@ -392,6 +392,7 @@ impl RpcHandler { "mesh.status" => self.handle_mesh_status().await, "mesh.probe-device" => self.handle_mesh_probe_device(params).await, "mesh.peers" => self.handle_mesh_peers().await, + "mesh.refresh" => self.handle_mesh_refresh().await, "mesh.messages" => self.handle_mesh_messages(params).await, "mesh.debug-dump" => self.handle_mesh_debug_dump().await, "mesh.send" => self.handle_mesh_send(params).await, diff --git a/core/archipelago/src/api/rpc/mesh/messaging.rs b/core/archipelago/src/api/rpc/mesh/messaging.rs index 3d4da867..23facdc3 100644 --- a/core/archipelago/src/api/rpc/mesh/messaging.rs +++ b/core/archipelago/src/api/rpc/mesh/messaging.rs @@ -1,6 +1,7 @@ use super::super::RpcHandler; use crate::mesh; use anyhow::Result; +use std::sync::Arc; use tracing::info; impl RpcHandler { @@ -131,7 +132,14 @@ impl RpcHandler { config.broadcast_identity = broadcast; } if let Some(name) = params.get("advert_name").and_then(|v| v.as_str()) { - config.advert_name = Some(name.to_string()); + // Empty clears the custom mesh name (falls back to the server + // name) — without this, a name could be set but never unset. + let trimmed = name.trim(); + config.advert_name = if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + }; } if let Some(announce) = params .get("announce_block_headers") @@ -202,10 +210,24 @@ impl RpcHandler { mesh::save_config(&self.config.data_dir, &config).await?; - // If we have a running service, update its config - let mut service = self.mesh_service.write().await; - if let Some(svc) = service.as_mut() { - svc.configure(config.clone()).await?; + // Apply to the running service in the background: configure() may + // stop+start the listener (config changes now restart the session so + // they actually take effect), and that can take seconds when the old + // session is mid-probe. Holding the service write-lock for that long + // inside this handler stalled every concurrent mesh.status/mesh.peers + // poll behind it — the UI froze and nginx surfaced 502s. The config is + // already persisted above; the UI observes progress via mesh.status. + { + let service_arc = Arc::clone(&self.mesh_service); + let config_for_apply = config.clone(); + tokio::spawn(async move { + let mut service = service_arc.write().await; + if let Some(svc) = service.as_mut() { + if let Err(e) = svc.configure(config_for_apply).await { + tracing::error!("Applying mesh config to running service failed: {e:#}"); + } + } + }); } info!("Mesh config updated"); diff --git a/core/archipelago/src/api/rpc/mesh/status.rs b/core/archipelago/src/api/rpc/mesh/status.rs index 2f004ffd..5dac2a2f 100644 --- a/core/archipelago/src/api/rpc/mesh/status.rs +++ b/core/archipelago/src/api/rpc/mesh/status.rs @@ -110,6 +110,28 @@ impl RpcHandler { Ok(serde_json::to_value(probe)?) } + /// mesh.refresh — Actively refresh discovery state: re-query the radio's + /// contact table and re-announce ourselves so quiet-but-alive neighbours + /// answer. This is what the UI's Refresh button calls — before it existed + /// the button only re-read server caches and never touched the radio. + pub(in crate::api::rpc) async fn handle_mesh_refresh(&self) -> Result { + let service = self.mesh_service.read().await; + let Some(svc) = service.as_ref() else { + return Ok(serde_json::json!({ "refreshed": false, "device_connected": false })); + }; + let status = svc.status().await; + if status.device_connected { + let state = svc.shared_state(); + let _ = state + .send_cmd(crate::mesh::listener::MeshCommand::RefreshContacts) + .await; + } + Ok(serde_json::json!({ + "refreshed": status.device_connected, + "device_connected": status.device_connected, + })) + } + /// mesh.peers — List discovered mesh peers. pub(in crate::api::rpc) async fn handle_mesh_peers(&self) -> Result { let service = self.mesh_service.read().await; diff --git a/core/archipelago/src/api/rpc/system/handlers.rs b/core/archipelago/src/api/rpc/system/handlers.rs index 508d4f0f..27b4e273 100644 --- a/core/archipelago/src/api/rpc/system/handlers.rs +++ b/core/archipelago/src/api/rpc/system/handlers.rs @@ -61,6 +61,28 @@ impl RpcHandler { info!("Server name updated to: {}", name); + // Propagate to the mesh: the listener advertises the server name (when + // no explicit mesh advert_name overrides it), but it was only read at + // process startup — a rename never reached the radio/RNS until the + // next full restart. Push it into the service and bounce the listener + // in the background (the restart re-probes the radio, which can take + // seconds — don't block the rename response on it). + { + let mesh_arc = self.mesh_service_arc(); + let name_for_mesh = name.clone(); + tokio::spawn(async move { + let mut guard = mesh_arc.write().await; + if let Some(svc) = guard.as_mut() { + svc.set_server_name(Some(name_for_mesh)); + if svc.config().advert_name.is_none() { + if let Err(e) = svc.restart_listener_if_running().await { + warn!("Mesh listener restart after rename failed: {}", e); + } + } + } + }); + } + // Push the new name to federation peers in background let data_dir = self.config.data_dir.clone(); let state_manager = self.state_manager.clone(); diff --git a/core/archipelago/src/mesh/listener/decode.rs b/core/archipelago/src/mesh/listener/decode.rs index cba2f7bf..3687f1f8 100644 --- a/core/archipelago/src/mesh/listener/decode.rs +++ b/core/archipelago/src/mesh/listener/decode.rs @@ -578,7 +578,7 @@ pub(super) async fn handle_identity_received( .insert(contact_id, shared_secret); // Update peer record - let peer = MeshPeer { + 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. @@ -607,6 +607,24 @@ pub(super) async fn handle_identity_received( 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; + } + } peers.insert(contact_id, peer.clone()); is_new }; diff --git a/core/archipelago/src/mesh/listener/session.rs b/core/archipelago/src/mesh/listener/session.rs index f85d9170..d05f59b5 100644 --- a/core/archipelago/src/mesh/listener/session.rs +++ b/core/archipelago/src/mesh/listener/session.rs @@ -270,6 +270,7 @@ async fn auto_detect_and_open( our_x25519_pubkey_hex: &str, device_kind: Option, skip_path: Option<&str>, + advert_name: Option<&str>, ) -> Result<(String, MeshRadioDevice, DeviceInfo)> { let mut paths = super::super::serial::detect_serial_devices().await; // When falling back from a just-failed preferred path, don't probe that @@ -304,6 +305,7 @@ async fn auto_detect_and_open( data_dir, Some(our_ed_pubkey_hex), Some(our_x25519_pubkey_hex), + advert_name, ) .await { @@ -465,6 +467,7 @@ async fn open_preferred_path( our_ed_pubkey_hex: &str, our_x25519_pubkey_hex: &str, device_kind: Option, + advert_name: Option<&str>, ) -> Result<(MeshRadioDevice, DeviceInfo)> { // Pinned: try only the configured firmware and surface its own error — // never fall through to (and inject probe bytes into) another firmware's @@ -497,6 +500,7 @@ async fn open_preferred_path( data_dir, Some(our_ed_pubkey_hex), Some(our_x25519_pubkey_hex), + advert_name, ) .await .context("Could not open preferred path as Reticulum")?; @@ -519,6 +523,7 @@ async fn open_preferred_path( data_dir, Some(our_ed_pubkey_hex), Some(our_x25519_pubkey_hex), + advert_name, ) .await { @@ -556,6 +561,7 @@ async fn open_reticulum_tcp( data_dir: &Path, our_ed_pubkey_hex: &str, our_x25519_pubkey_hex: &str, + advert_name: Option<&str>, ) -> Result<(String, MeshRadioDevice, DeviceInfo)> { let mut dev = match cfg { ReticulumTcpConfig::Server { bind } => ReticulumLink::open_tcp_server( @@ -563,6 +569,7 @@ async fn open_reticulum_tcp( data_dir, Some(our_ed_pubkey_hex), Some(our_x25519_pubkey_hex), + advert_name, ) .await .context("Could not open Reticulum TCP server interface")?, @@ -571,6 +578,7 @@ async fn open_reticulum_tcp( data_dir, Some(our_ed_pubkey_hex), Some(our_x25519_pubkey_hex), + advert_name, ) .await .context("Could not open Reticulum TCP client interface")?, @@ -987,49 +995,93 @@ pub(super) async fn run_mesh_session( // auto-detect. TCP mode is additive/dev-only; it never changes behavior // for existing serial/RNode deployments where `reticulum_tcp` is None. // + // The name we present on the mesh: the operator's configured mesh name / + // server name, falling back to a DID fragment. Computed BEFORE the open + // sequence because Reticulum needs it at daemon-spawn time — the RNS + // announce carries it from the very first announce. (Meshcore/Meshtastic + // still receive it via set_advert_name after connect, below.) + let desired_advert_name: String = match server_name { + // Meshcore firmware limits advert names — truncate to 20 chars. + Some(name) => name.chars().take(20).collect(), + None => format!( + "Archy-{}", + our_did.chars().skip(8).take(8).collect::() + ), + }; + // The whole open sequence runs under PORT_OPEN_LOCK so an RPC probe // can't interleave its own handshakes on the same tty (see the lock's // doc comment). Held only until the device is opened, then released. - let port_guard = PORT_OPEN_LOCK.lock().await; - let (device_path, mut device, device_info) = if let Some(tcp_cfg) = &reticulum_tcp { - open_reticulum_tcp(tcp_cfg, data_dir, our_ed_pubkey_hex, our_x25519_pubkey_hex).await? - } else if let Some(path) = preferred_path { - match open_preferred_path( - path, - data_dir, - our_ed_pubkey_hex, - our_x25519_pubkey_hex, - device_kind, - ) - .await - { - Ok((dev, info)) => (path.to_string(), dev, info), - Err(e) => { - warn!( - "Preferred path {} probe failed: {} — trying auto-detect", - path, e - ); - auto_detect_and_open( - data_dir, - our_ed_pubkey_hex, - our_x25519_pubkey_hex, - device_kind, - Some(path), - ) - .await? + // + // The sequence is raced against the shutdown signal: probes/handshakes + // can take 10s+, and without this a stop() issued mid-probe (config + // change, disable, rename) always burned the full listener-shutdown + // timeout and ended in a hard abort — observed live on archi-dev-box + // 2026-07-28. Dropping the open future mid-probe is safe: it holds no + // session state yet and the port guard/serial handle close with it. + let open_fut = async { + let port_guard = PORT_OPEN_LOCK.lock().await; + let result = if let Some(tcp_cfg) = &reticulum_tcp { + open_reticulum_tcp( + tcp_cfg, + data_dir, + our_ed_pubkey_hex, + our_x25519_pubkey_hex, + Some(&desired_advert_name), + ) + .await + } else if let Some(path) = preferred_path { + match open_preferred_path( + path, + data_dir, + our_ed_pubkey_hex, + our_x25519_pubkey_hex, + device_kind, + Some(&desired_advert_name), + ) + .await + { + Ok((dev, info)) => Ok((path.to_string(), dev, info)), + Err(e) => { + warn!( + "Preferred path {} probe failed: {} — trying auto-detect", + path, e + ); + auto_detect_and_open( + data_dir, + our_ed_pubkey_hex, + our_x25519_pubkey_hex, + device_kind, + Some(path), + Some(&desired_advert_name), + ) + .await + } } - } - } else { - auto_detect_and_open( - data_dir, - our_ed_pubkey_hex, - our_x25519_pubkey_hex, - device_kind, - None, - ) - .await? + } else { + auto_detect_and_open( + data_dir, + our_ed_pubkey_hex, + our_x25519_pubkey_hex, + device_kind, + None, + Some(&desired_advert_name), + ) + .await + }; + drop(port_guard); + result + }; + let (device_path, mut device, device_info) = tokio::select! { + res = open_fut => res?, + _ = shutdown.changed() => { + if *shutdown.borrow() { + info!("Shutdown requested during device open — ending session"); + return Ok(()); + } + anyhow::bail!("shutdown signal changed during device open"); + } }; - drop(port_guard); // Update status { @@ -1176,19 +1228,14 @@ pub(super) async fn run_mesh_session( } } - // Set advert name to the server's human-readable name (e.g. "ThinkPad"), - // falling back to the DID fragment if no name is configured. Skipped in - // keep-as-is mode — the radio keeps the name it came with (already - // reflected in status from the connect handshake). - if manage_radio { - let advert_name = if let Some(name) = server_name { - // Meshcore firmware limits advert names — truncate to 20 chars - name.chars().take(20).collect::() - } else { - let short_did = our_did.chars().skip(8).take(8).collect::(); - format!("Archy-{}", short_did) - }; - if let Err(e) = device.set_advert_name(&advert_name).await { + // Set advert name to the configured mesh/server name (computed above). + // Skipped in keep-as-is mode for radio-held names — the radio keeps the + // name it came with (already reflected in status from the connect + // handshake). Reticulum is exempt from keep-as-is: its display name + // lives in OUR daemon (the RNode holds no name), so "keep as is" has + // nothing to preserve and an unnamed node would be anonymous on RNS. + if manage_radio || matches!(device, MeshRadioDevice::Reticulum(_)) { + if let Err(e) = device.set_advert_name(&desired_advert_name).await { warn!("Failed to set advert name: {}", e); } else { // Reflect the post-set name in MeshStatus too so the UI can filter @@ -1196,7 +1243,7 @@ pub(super) async fn run_mesh_session( // still carries whatever pre-set name the firmware reported and the // self-filter never matches. let mut status = state.status.write().await; - status.self_advert_name = Some(advert_name.clone()); + status.self_advert_name = Some(desired_advert_name.clone()); } } @@ -1489,6 +1536,15 @@ async fn handle_send_command( } else { *consecutive_write_failures = 0; } + // The self-advert alone is a no-op for discovery on Meshtastic + // (heartbeat + time carry no identity) — the NodeInfo broadcast + // is what makes peers learn/refresh us. want_response=true so + // neighbours answer with their own NodeInfo: the user pressed + // Broadcast to be seen AND to see who's out there. No-op on + // Meshcore/Reticulum, whose self-advert already carries identity. + if let Err(e) = device.send_nodeinfo_advert(true).await { + warn!("Failed to send NodeInfo advert: {}", e); + } } MeshCommand::RebootRadio { seconds } => { if let Err(e) = device.reboot(seconds).await { diff --git a/core/archipelago/src/mesh/mod.rs b/core/archipelago/src/mesh/mod.rs index 54191ee7..48cbb314 100644 --- a/core/archipelago/src/mesh/mod.rs +++ b/core/archipelago/src/mesh/mod.rs @@ -477,6 +477,31 @@ impl Default for MeshConfig { } } +/// Whether a mesh config file has ever been written for this node — lets the +/// boot path distinguish "operator explicitly disabled mesh" (file exists, +/// enabled=false) from "never configured" (no file), which is the only case +/// radio auto-enable should touch. +pub fn config_file_exists(data_dir: &Path) -> bool { + data_dir.join(MESH_CONFIG_FILE).exists() +} + +/// True when `new` differs from `old` in any field a running mesh session +/// captured by value at spawn (device path/kind, advert name, region, PHY +/// params, channel, manage_radio, TCP interface) — i.e. when applying `new` +/// to a live service requires a listener restart. Fields the session reads +/// live from shared state (broadcast flags, assistant settings, steganography +/// mode, …) deliberately don't trigger a restart. +fn session_config_changed(old: &MeshConfig, new: &MeshConfig) -> bool { + old.device_path != new.device_path + || old.device_kind != new.device_kind + || old.advert_name != new.advert_name + || old.lora_region != new.lora_region + || old.lora_radio_params != new.lora_radio_params + || old.channel_name != new.channel_name + || old.manage_radio != new.manage_radio + || old.reticulum_tcp != new.reticulum_tcp +} + pub async fn load_config(data_dir: &Path) -> Result { let path = data_dir.join(MESH_CONFIG_FILE); if !path.exists() { @@ -764,7 +789,11 @@ impl MeshService { self.our_ed_pubkey_hex.clone(), self.our_x25519_secret, self.our_x25519_pubkey_hex.clone(), - self.server_name.clone(), + // The mesh-page "Name on the mesh" (config.advert_name) wins over + // the server name — it existed as write-only config with no reader + // until this line, which is why renaming on the Mesh page never + // changed anything on the air. + self.config.advert_name.clone().or_else(|| self.server_name.clone()), self.config.lora_region.clone(), self.config.lora_radio_params, self.config.channel_name.clone(), @@ -2091,6 +2120,7 @@ impl MeshService { save_config(&self.data_dir, &config).await?; let was_enabled = self.config.enabled; + let needs_session_restart = session_config_changed(&self.config, &config); self.config = config.clone(); // Update the status to reflect new config @@ -2115,11 +2145,31 @@ impl MeshService { status.firmware_version = None; status.self_node_id = None; status.peer_count = 0; + } else if config.enabled && was_enabled && needs_session_restart { + info!("Mesh session config changed — restarting listener to apply"); + self.stop().await; + self.start()?; } Ok(()) } + /// The service's current (last-applied) config. + pub fn config(&self) -> &MeshConfig { + &self.config + } + + /// Restart the listener (if running) so it picks up out-of-band state a + /// spawn captured by value — currently the server name pushed by + /// `server.set-name`. + pub async fn restart_listener_if_running(&mut self) -> Result<()> { + if self.listener_handle.is_some() { + self.stop().await; + self.start()?; + } + Ok(()) + } + /// Get a reference to shared state (for RPC handlers). pub fn shared_state(&self) -> Arc { Arc::clone(&self.state) @@ -2241,6 +2291,46 @@ async fn bitcoin_rpc_getblockheader_by_height( mod tests { use super::*; + #[test] + fn session_config_change_detection() { + let base = MeshConfig::default(); + + // Same config → no restart. + assert!(!session_config_changed(&base, &base.clone())); + + // Every session-captured field individually triggers a restart. + let mut c = base.clone(); + c.device_kind = Some(types::DeviceType::Reticulum); + assert!(session_config_changed(&base, &c)); + + let mut c = base.clone(); + c.device_path = Some("/dev/ttyUSB0".into()); + assert!(session_config_changed(&base, &c)); + + let mut c = base.clone(); + c.advert_name = Some("RNode Shaza".into()); + assert!(session_config_changed(&base, &c)); + + let mut c = base.clone(); + c.manage_radio = !base.manage_radio; + assert!(session_config_changed(&base, &c)); + + let mut c = base.clone(); + c.lora_region = Some("EU_868".into()); + assert!(session_config_changed(&base, &c)); + + let mut c = base.clone(); + c.channel_name = Some("private-net".into()); + assert!(session_config_changed(&base, &c)); + + // Live-read fields must NOT force a session restart. + let mut c = base.clone(); + c.broadcast_identity = !base.broadcast_identity; + c.announce_block_headers = !base.announce_block_headers; + c.assistant_enabled = !base.assistant_enabled; + assert!(!session_config_changed(&base, &c)); + } + fn mk_peer(contact_id: u32, name: &str, arch: Option<&str>, reachable: bool) -> MeshPeer { MeshPeer { contact_id, diff --git a/core/archipelago/src/mesh/reticulum.rs b/core/archipelago/src/mesh/reticulum.rs index 4a6459c5..1ffb6d85 100644 --- a/core/archipelago/src/mesh/reticulum.rs +++ b/core/archipelago/src/mesh/reticulum.rs @@ -121,6 +121,7 @@ fn daemon_command( identity_key: &Path, archy_ed_pubkey_hex: Option<&str>, archy_x25519_pubkey_hex: Option<&str>, + display_name: Option<&str>, ) -> Command { let bin = std::env::var("ARCHY_RETICULUM_DAEMON_BIN") .unwrap_or_else(|_| "/usr/local/bin/archy-reticulum-daemon".to_string()); @@ -159,6 +160,15 @@ fn daemon_command( .arg("--archy-x25519-pubkey-hex") .arg(x); } + // The RNS-visible display name (what Sideband/NomadNet/other archy nodes + // show for us). Without this the daemon falls back to its argparse + // default and every archy node announces the same anonymous name. + if let Some(name) = display_name { + let name = name.trim(); + if !name.is_empty() { + cmd.arg("--display-name").arg(name); + } + } // Run the daemon as its own process-group leader. The packaged binary is // a PyInstaller one-file bootloader that forks the real Python process; // making it a group leader lets shutdown signal the WHOLE group so the @@ -207,6 +217,10 @@ struct ReticulumPeer { /// `bind_federation_twins`, which those two transports rely on instead). arch_pubkey_hex: Option, reachable: bool, + /// Unix time of the last announce heard from this peer over the air. + /// In-memory only (a persisted value would be stale by definition) — + /// `0` after a restart until the peer re-announces. + last_advert_at: u64, } /// On-disk shape of `ReticulumPeer` — `[u8; 16]` can't be a JSON object key, @@ -245,6 +259,11 @@ pub struct ReticulumLink { /// matching `resource_progress`/`resource_sent`/`resource_failed` events /// back to a log line; sends are fire-and-forget (see `send_resource`). resource_id_counter: u64, + /// Set when the daemon's RPC socket closes or its process exits. Once + /// true, `try_recv_frame` returns an error so the session loop tears + /// down and the outer reconnect loop respawns the daemon — without this + /// a dead daemon was invisible until the 30-minute RX-stall watchdog. + daemon_gone: bool, } impl ReticulumLink { @@ -269,6 +288,7 @@ impl ReticulumLink { data_dir: &Path, our_ed_pubkey_hex: Option<&str>, our_x25519_pubkey_hex: Option<&str>, + display_name: Option<&str>, ) -> Result { probe_rnode(path) .await @@ -278,6 +298,7 @@ impl ReticulumLink { data_dir, our_ed_pubkey_hex, our_x25519_pubkey_hex, + display_name, ) .await } @@ -290,6 +311,7 @@ impl ReticulumLink { data_dir: &Path, our_ed_pubkey_hex: Option<&str>, our_x25519_pubkey_hex: Option<&str>, + display_name: Option<&str>, ) -> Result { let host = bind.rsplit_once(':').map(|(h, _)| h).unwrap_or(bind); anyhow::ensure!( @@ -302,6 +324,7 @@ impl ReticulumLink { data_dir, our_ed_pubkey_hex, our_x25519_pubkey_hex, + display_name, ) .await } @@ -313,6 +336,7 @@ impl ReticulumLink { data_dir: &Path, our_ed_pubkey_hex: Option<&str>, our_x25519_pubkey_hex: Option<&str>, + display_name: Option<&str>, ) -> Result { anyhow::ensure!( !targets.is_empty(), @@ -323,6 +347,7 @@ impl ReticulumLink { data_dir, our_ed_pubkey_hex, our_x25519_pubkey_hex, + display_name, ) .await } @@ -332,6 +357,7 @@ impl ReticulumLink { data_dir: &Path, our_ed_pubkey_hex: Option<&str>, our_x25519_pubkey_hex: Option<&str>, + display_name: Option<&str>, ) -> Result { // Keep the RPC socket under the archipelago-owned data dir (not the // shared system temp dir) so its access is bounded by the same @@ -379,6 +405,7 @@ impl ReticulumLink { &identity_key, our_ed_pubkey_hex, our_x25519_pubkey_hex, + display_name, ); cmd.env("TMPDIR", &tmp_dir); let child = cmd @@ -450,6 +477,7 @@ impl ReticulumLink { peers_file: runtime_dir.join("peers.json"), inbound: std::collections::VecDeque::new(), resource_id_counter: 0, + daemon_gone: false, }; link.load_persisted_peers(); Ok(link) @@ -472,15 +500,25 @@ impl ReticulumLink { }; let prefix: [u8; 6] = hash[..6].try_into().unwrap(); self.prefix_to_hash.insert(prefix, hash); + // Heal names persisted by pre-2026-07-28 builds, which could + // store a raw `ARCHY:…` identity blob as the display name (seen + // live on archi-dev-box). Blob-only announces assert no name, so + // nothing would ever overwrite it — swap in the placeholder. + let display_name = if p.display_name.starts_with("ARCHY:") { + format!("Reticulum {}", hex::encode(&hash[..4])) + } else { + p.display_name + }; self.peers.insert( hash, ReticulumPeer { dest_hash: hash, - display_name: p.display_name, + 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, + last_advert_at: 0, }, ); } @@ -533,10 +571,12 @@ impl ReticulumLink { } pub async fn set_advert_name(&mut self, name: &str) -> Result<()> { - // The daemon's display_name is fixed at spawn time (CLI arg); changing - // it live would require an RPC verb we haven't added. Track locally so - // `advert_name()` reflects the caller's intent even though the - // RNS-visible name doesn't change until the daemon restarts. + // Live rename: the daemon's `set_name` verb updates the LXMF delivery + // destination's display_name and re-announces, so peers pick the new + // name up on their next announce receipt. Also tracked locally so + // `advert_name()` reflects it immediately. + self.send_rpc(serde_json::json!({"cmd": "set_name", "name": name})) + .await?; self.display_name = Some(name.to_string()); Ok(()) } @@ -685,7 +725,7 @@ impl ReticulumLink { .map(|p| ParsedContact { public_key_hex: hex::encode(p.dest_hash), advert_name: p.display_name.clone(), - last_advert: 0, + last_advert: p.last_advert_at as u32, // Deliberately not 1 ("friend"/meshcore type), so the // meshcore-only auto-heal `reset_contact_path` loop in // `refresh_contacts` (session.rs) skips these — RNS does its @@ -718,6 +758,12 @@ impl ReticulumLink { pub async fn try_recv_frame(&mut self) -> Result> { self.drain_events().await; + if self.daemon_gone { + // Surface the dead daemon as a hard error so run_mesh_session + // bails and the outer reconnect loop respawns it, instead of + // idling on an empty queue until the RX-stall watchdog fires. + anyhow::bail!("reticulum-daemon is gone (process exited or RPC socket closed)"); + } Ok(self.inbound.pop_front()) } @@ -743,6 +789,15 @@ impl ReticulumLink { /// Drain any buffered daemon events (non-blocking) and translate them into /// peer-table updates / synthetic InboundFrames. async fn drain_events(&mut self) { + // A daemon that died without closing the socket cleanly (SIGKILL, + // OOM) leaves the socket readable-with-EOF or just silent — poll the + // child's exit status too so death is never mistaken for quiet. + if !self.daemon_gone { + if let Ok(Some(status)) = self.child.try_wait() { + warn!(%status, "reticulum-daemon process exited"); + self.daemon_gone = true; + } + } loop { let mut line = String::new(); let read = @@ -750,10 +805,16 @@ impl ReticulumLink { .await; let n = match read { Ok(Ok(n)) => n, - _ => break, // timeout (no data) or read error — stop draining + Ok(Err(e)) => { + warn!("Reticulum daemon RPC read failed: {}", e); + self.daemon_gone = true; + break; + } + Err(_) => break, // timeout — no data buffered }; if n == 0 { warn!("Reticulum daemon RPC connection closed"); + self.daemon_gone = true; break; } let Ok(ev) = serde_json::from_str::(line.trim()) else { @@ -775,6 +836,23 @@ impl ReticulumLink { }; let prefix: [u8; 6] = hash[..6].try_into().unwrap(); self.prefix_to_hash.insert(prefix, hash); + // Current daemons decode the LXMF announce app_data themselves + // and hand us clean fields: `display_name` (LXMF-standard + // msgpack name, Sideband-interoperable) and `archy_blob` (the + // `ARCHY:n:` identity string, carried as an extra msgpack list + // element stock clients ignore). The raw `app_data` text path + // below remains for announces from pre-upgrade archy nodes, + // whose app_data was EITHER the blob OR a bare-utf8 name. + let explicit_name = ev + .get("display_name") + .and_then(Value::as_str) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + let explicit_blob = ev + .get("archy_blob") + .and_then(Value::as_str) + .map(str::to_string) + .filter(|s| !s.is_empty()); let app_data_text = ev .get("app_data") .and_then(Value::as_str) @@ -794,12 +872,17 @@ impl ReticulumLink { // now carry the same `arch_pubkey_hex`, instead of relying on // `bind_federation_twins`'s advert_name matching, which never // matches here — see `display_name` below. - let parsed_identity = app_data_text + let legacy_identity = app_data_text .as_deref() .and_then(protocol::parse_identity_broadcast); - let is_identity_blob = parsed_identity.is_some(); - if is_identity_blob { - let text = app_data_text.clone().unwrap(); + let is_legacy_blob = legacy_identity.is_some(); + let identity_blob_text = explicit_blob.or_else(|| { + app_data_text.clone().filter(|_| is_legacy_blob) + }); + let parsed_identity = identity_blob_text + .as_deref() + .and_then(protocol::parse_identity_broadcast); + if let Some(text) = identity_blob_text.as_deref().filter(|_| parsed_identity.is_some()) { let mut data = Vec::with_capacity(7 + text.len()); data.push(0); // channel index — unused by the identity path data.extend_from_slice(&prefix); @@ -812,23 +895,31 @@ impl ReticulumLink { } let arch_pubkey_hex = parsed_identity.map(|(_did, ed_pubkey, _x25519)| ed_pubkey); - let display_name = app_data_text - .filter(|_| !is_identity_blob) - .unwrap_or_else(|| format!("Reticulum {}", hex::encode(&hash[..4]))); + let announced_name = + pick_announced_name(explicit_name, app_data_text, is_legacy_blob); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); self.peers .entry(hash) .and_modify(|p| { - p.display_name = display_name.clone(); + if let Some(name) = announced_name.clone() { + p.display_name = name; + } p.reachable = true; + p.last_advert_at = now; if arch_pubkey_hex.is_some() { p.arch_pubkey_hex = arch_pubkey_hex.clone(); } }) - .or_insert(ReticulumPeer { + .or_insert_with(|| ReticulumPeer { dest_hash: hash, - display_name, + display_name: announced_name + .unwrap_or_else(|| format!("Reticulum {}", hex::encode(&hash[..4]))), arch_pubkey_hex, reachable: true, + last_advert_at: now, }); self.persist_peers(); } @@ -844,16 +935,24 @@ impl ReticulumLink { // 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(); + // this) so its routing entry alone doesn't get lost. An + // existing entry is proof of life too: mark it reachable so a + // restart-restored (reachable=false) peer that DMs us doesn't + // stay red-dotted until its next announce. + match self.peers.entry(source_hash) { + std::collections::hash_map::Entry::Vacant(e) => { + e.insert(ReticulumPeer { + dest_hash: source_hash, + display_name: format!("Reticulum {}", hex::encode(&source_hash[..4])), + arch_pubkey_hex: None, + reachable: true, + last_advert_at: 0, + }); + self.persist_peers(); + } + std::collections::hash_map::Entry::Occupied(mut e) => { + e.get_mut().reachable = true; + } } // A stock LXMF client (Sideband/NomadNet — not an archy peer) @@ -932,15 +1031,20 @@ 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(); + match self.peers.entry(source_hash) { + std::collections::hash_map::Entry::Vacant(e) => { + e.insert(ReticulumPeer { + dest_hash: source_hash, + display_name: format!("Reticulum {}", hex::encode(&source_hash[..4])), + arch_pubkey_hex: None, + reachable: true, + last_advert_at: 0, + }); + self.persist_peers(); + } + std::collections::hash_map::Entry::Occupied(mut e) => { + e.get_mut().reachable = true; + } } use base64::{engine::general_purpose::STANDARD as B64, Engine as _}; let Some(data) = ev @@ -1089,8 +1193,10 @@ pub(crate) async fn probe_rnode(path: &str) -> Result<()> { // ESP32-S3 native-USB boards (Heltec V3/V4 etc. — no separate USB-UART // bridge chip) treat a DTR/RTS transition on open as a reset signal, the // same mechanism esptool uses to force bootloader entry. Deassert both - // and let the board settle before writing the probe, or the reboot eats - // the DETECT_RESP window below. + // before writing the probe. Boards behind a USB-UART bridge (CP2102 on + // the Heltec V3) get the reset pulse from the open() itself, before we + // can deassert anything — that case is handled by the boot-settle retry + // below. let _ = port.set_dtr(false); let _ = port.set_rts(false); tokio::time::sleep(Duration::from_millis(300)).await; @@ -1109,26 +1215,81 @@ pub(crate) async fn probe_rnode(path: &str) -> Result<()> { 0x00, KISS_FEND, ]; + // Attempt 1: probe immediately. A board that did NOT reset on open (it + // was already up — e.g. a re-probe of a running RNode) answers in well + // under a second, so the fast path stays fast. tokio::time::timeout(Duration::from_millis(500), port.write_all(&probe)) .await .context("RNode probe write timed out")? .context("RNode probe write failed")?; - let mut buf = [0u8; 256]; let mut seen = Vec::new(); - let deadline = tokio::time::Instant::now() + PROBE_READ_TIMEOUT; - while tokio::time::Instant::now() < deadline { + if await_detect_resp(&port, &mut buf, &mut seen, PROBE_READ_TIMEOUT).await { + return Ok(()); + } + + // No DETECT_RESP. If the open() power-cycled the board (verified live on + // a Heltec V3 RNode behind a CP2102: the ESP32 spends ~2.5-3s in boot + // ROM + app init and silently eats anything written meanwhile, so the + // first probe lands in the void), wait for its boot chatter to go quiet + // and probe once more with a fresh response window. + const BOOT_QUIET_WINDOW: Duration = Duration::from_millis(800); + const BOOT_SETTLE_MAX: Duration = Duration::from_secs(6); + let settle_deadline = tokio::time::Instant::now() + BOOT_SETTLE_MAX; + let mut last_data = tokio::time::Instant::now(); + while tokio::time::Instant::now() < settle_deadline { match tokio::time::timeout(Duration::from_millis(150), port.read(&mut buf)).await { Ok(Ok(n)) if n > 0 => { seen.extend_from_slice(&buf[..n]); + // A late DETECT_RESP to the first write still counts. if contains_detect_resp(&seen) { return Ok(()); } + last_data = tokio::time::Instant::now(); + } + _ => { + if last_data.elapsed() >= BOOT_QUIET_WINDOW { + break; + } + } + } + } + + tokio::time::timeout(Duration::from_millis(500), port.write_all(&probe)) + .await + .context("RNode probe rewrite timed out")? + .context("RNode probe rewrite failed")?; + seen.clear(); + if await_detect_resp(&port, &mut buf, &mut seen, PROBE_READ_TIMEOUT).await { + return Ok(()); + } + anyhow::bail!( + "No RNode DETECT_RESP within {:?} (incl. post-boot-settle retry)", + PROBE_READ_TIMEOUT + ) +} + +/// Read from `port` for up to `window`, accumulating into `seen`; true once +/// the KISS DETECT_RESP sequence shows up anywhere in the stream. +async fn await_detect_resp( + port: &serial2_tokio::SerialPort, + buf: &mut [u8], + seen: &mut Vec, + window: Duration, +) -> bool { + let deadline = tokio::time::Instant::now() + window; + while tokio::time::Instant::now() < deadline { + match tokio::time::timeout(Duration::from_millis(150), port.read(buf)).await { + Ok(Ok(n)) if n > 0 => { + seen.extend_from_slice(&buf[..n]); + if contains_detect_resp(seen) { + return true; + } } _ => continue, } } - anyhow::bail!("No RNode DETECT_RESP within {:?}", PROBE_READ_TIMEOUT) + false } /// Look for the `[FEND, CMD_DETECT, DETECT_RESP]` sequence anywhere in the @@ -1138,6 +1299,33 @@ fn contains_detect_resp(buf: &[u8]) -> bool { .any(|w| w == [KISS_FEND, KISS_CMD_DETECT, KISS_DETECT_RESP]) } +/// The display name an announce actually asserted, if any. +/// +/// Precedence: the daemon-decoded LXMF display name (`display_name` event +/// field), then — legacy peers only — bare-utf8 app_data that wasn't an +/// identity blob. The bare-utf8 fallback must actually look like text: +/// lossy-decoded msgpack (a new-format announce whose name the daemon failed +/// to decode) is full of U+FFFD/control chars and would otherwise become a +/// mojibake display name. `None` (e.g. a blob-only legacy announce) means +/// "no name asserted" and must NOT stomp a previously-learned name. +fn pick_announced_name( + explicit_name: Option, + app_data_text: Option, + is_legacy_blob: bool, +) -> Option { + explicit_name + // A legacy blob-only announce utf8-decodes cleanly, so LXMF's + // display_name_from_app_data hands the daemon the ENTIRE `ARCHY:…` + // string as a "name" — seen live from a pre-upgrade Framework PT. + // An identity blob is never a display name. + .filter(|s| !s.starts_with("ARCHY:")) + .or_else(|| { + app_data_text + .filter(|_| !is_legacy_blob) + .filter(|s| !s.chars().any(|c| c.is_control() || c == '\u{FFFD}')) + }) +} + impl Drop for ReticulumLink { fn drop(&mut self) { // Group-wide SIGTERM with a delayed SIGKILL backstop (`terminate_group`). @@ -1154,6 +1342,37 @@ impl Drop for ReticulumLink { mod tests { use super::*; + #[test] + fn announced_name_precedence() { + // Daemon-decoded LXMF name always wins. + assert_eq!( + pick_announced_name( + Some("RNode Shaza".into()), + Some("ARCHY:2:aa:bb".into()), + true + ), + Some("RNode Shaza".to_string()) + ); + // Legacy bare-utf8 name (old daemon, no explicit field). + assert_eq!( + pick_announced_name(None, Some("zaza".into()), false), + Some("zaza".to_string()) + ); + // Legacy blob-only announce asserts NO name (must not stomp). + assert_eq!( + pick_announced_name(None, Some("ARCHY:2:aa:bb".into()), true), + None + ); + // Lossy-decoded msgpack must not become a mojibake name. + assert_eq!( + pick_announced_name(None, Some("\u{FFFD}\u{FFFD}Shaza\u{FFFD}".into()), false), + None + ); + assert_eq!(pick_announced_name(None, Some("has\u{1}ctl".into()), false), None); + // Nothing at all. + assert_eq!(pick_announced_name(None, None, false), None); + } + #[test] fn detect_resp_found_in_kiss_stream() { let stream = [ diff --git a/core/archipelago/src/mesh/serial.rs b/core/archipelago/src/mesh/serial.rs index dd7634bf..3645714f 100644 --- a/core/archipelago/src/mesh/serial.rs +++ b/core/archipelago/src/mesh/serial.rs @@ -610,12 +610,23 @@ pub async fn detect_serial_devices_info() -> Vec { let mut out = Vec::new(); for path in detect_serial_devices().await { let usb = usb_info_for_tty(&path).await; - let plugged_at = tokio::fs::metadata(&path) - .await - .ok() - .and_then(|m| m.modified().ok()) - .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) - .map(|d| d.as_secs()); + // 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 archi-dev-box 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, diff --git a/core/archipelago/src/server.rs b/core/archipelago/src/server.rs index ecc04456..82c4cbf4 100644 --- a/core/archipelago/src/server.rs +++ b/core/archipelago/src/server.rs @@ -294,8 +294,13 @@ impl Server { .await .unwrap_or_default(); - // Auto-enable mesh if a radio is detected and no config exists yet - if !mesh_config.enabled { + // Auto-enable mesh if a radio is detected and no config exists + // yet. Only on a genuinely missing config: an existing file + // with enabled=false is an explicit operator decision (e.g. + // via mesh.configure) and force-re-enabling it on every boot + // made "disable mesh" impossible on any node with a radio + // plugged in. + if !mesh_config.enabled && !crate::mesh::config_file_exists(&data_dir) { let devices = crate::mesh::detect_devices().await; if !devices.is_empty() { info!("📡 Auto-detected mesh radio: {:?} — enabling mesh", devices); diff --git a/reticulum-daemon/reticulum_daemon.py b/reticulum-daemon/reticulum_daemon.py index d31c4c73..65e8dc63 100644 --- a/reticulum-daemon/reticulum_daemon.py +++ b/reticulum-daemon/reticulum_daemon.py @@ -14,12 +14,13 @@ Security posture (see the plan's "most secure way" section): RPC (one JSON object per line, both directions): in : {"cmd":"send","dest_hash":"","content":"…","title":"…","method":"direct|opportunistic"} {"cmd":"announce"} + {"cmd":"set_name","name":"…"} {"cmd":"status"} {"cmd":"send_resource","id":"","dest_hash":"","data_b64":"…"} {"cmd":"shutdown"} out: {"event":"ready","dest_hash":"","display_name":"…"} {"event":"recv","source_hash":"","content":"…","title":"…","fields":{…},"app_data":"","rssi":n,"snr":n,"stamp":t} - {"event":"announce","dest_hash":"","app_data":""} + {"event":"announce","dest_hash":"","app_data":"","display_name":"…"|null,"archy_blob":"ARCHY:2:…"|null} {"event":"delivered","dest_hash":"","state":"delivered|failed","id":""} {"event":"status","connected":bool,"dest_hash":"","interfaces":[…]} {"event":"resource_progress","id":"","transferred":n,"total":n} @@ -227,31 +228,47 @@ class ReticulumDaemon: if self.delivery_destination is not None: self.delivery_destination.announce(app_data=self._announce_app_data()) - def _announce_app_data(self) -> bytes: - """Carry the Archy identity so peers bind this RNS destination onto the - existing contact, the same way a meshcore/Meshtastic identity advert does. - - Reuses the exact ``ARCHY:2:{ed25519_hex}:{x25519_hex}`` wire format the - Rust side already parses (``protocol::parse_identity_broadcast``) and - binds via ``handle_identity_received``/``bind_federation_twins`` — so a - Reticulum-carried identity merges into the SAME conversation as the - meshcore/Meshtastic/federation twins of the same Archy node, satisfying - cross-protocol DM convergence. The keys are the node's real Archipelago - ed25519/x25519 pubkeys (passed in by the Rust side, which already has - them) — NOT this daemon's internally-HKDF-derived RNS keys, which exist - only to make the RNS destination hash deterministic and are never - themselves treated as an Archy identity. - - Falls back to a plain display-name string (undetected as an identity - blob — no `ARCHY:2:` prefix) if the Archy pubkeys weren't supplied, e.g. - a dev/selftest run with no `--archy-ed-pubkey-hex`. - """ + def _archy_identity_blob(self): + """The ``ARCHY:2:{ed25519_hex}:{x25519_hex}`` identity string the Rust + side parses (``protocol::parse_identity_broadcast``) and binds via + ``handle_identity_received`` — so a Reticulum-carried identity merges + into the SAME conversation as the meshcore/Meshtastic/federation twins + of the same Archy node. The keys are the node's real Archipelago + pubkeys (passed in by the Rust side) — NOT this daemon's + internally-HKDF-derived RNS keys, which exist only to make the RNS + destination hash deterministic. ``None`` when the pubkeys weren't + supplied (dev/selftest run).""" if self.args.archy_ed_pubkey_hex and self.args.archy_x25519_pubkey_hex: return ( f"ARCHY:2:{self.args.archy_ed_pubkey_hex}:" f"{self.args.archy_x25519_pubkey_hex}" ).encode("ascii") - return (self.args.display_name or "").encode("utf-8") + return None + + def _announce_app_data(self) -> bytes: + """LXMF-standard announce app_data — msgpack ``[display_name, + stamp_cost, supported_functionality]`` via the router, so Sideband/ + NomadNet/MeshChat (and upgraded archy nodes) all see our real display + name — with the Archy identity blob appended as an EXTRA list element. + Stock clients only read the elements they know ([0]/[1]), so the blob + rides along invisibly instead of replacing the name the way the old + blob-only app_data did (which left every archy node nameless on RNS). + """ + import RNS.vendor.umsgpack as msgpack + app_data = self.router.get_announce_app_data(self.delivery_destination.hash) + blob = self._archy_identity_blob() + if blob is None: + return app_data + try: + peer_data = msgpack.unpackb(app_data) + if not isinstance(peer_data, list): + raise ValueError("unexpected announce app_data shape") + peer_data.append(blob) + return msgpack.packb(peer_data) + except Exception: + # Never let announce formatting kill announcing entirely — fall + # back to the legacy blob-only format (identity binding > name). + return blob # ---- RNS-thread callbacks → asyncio ---- def _on_lxmf_delivery(self, message): @@ -328,6 +345,15 @@ class ReticulumDaemon: self._send(req) elif cmd == "announce": self.announce() + elif cmd == "set_name": + # Live rename: update the LXMF delivery destination's display name + # (what get_announce_app_data reads) and re-announce immediately so + # peers learn the new name without waiting for the next advert tick. + name = (req.get("name") or "").strip() + if name and self.delivery_destination is not None: + self.args.display_name = name + self.delivery_destination.display_name = name + self.announce() elif cmd == "status": self._broadcast(self._status()) elif cmd == "send_resource": @@ -521,10 +547,41 @@ class _AnnounceHandler: self.receive_path_responses = True def received_announce(self, destination_hash, announced_identity, app_data): + # Decode what we can here (both the LXMF-standard display name and our + # appended ARCHY identity blob — see _announce_app_data) so the Rust + # side gets clean typed fields instead of re-implementing msgpack. + display_name = None + archy_blob = None + raw = app_data or b"" + try: + import LXMF + display_name = LXMF.display_name_from_app_data(raw) + # A legacy blob-only announce is plain ascii, so LXMF's decoder + # returns the whole ARCHY identity blob as a "name" — drop it. + if display_name and display_name.startswith("ARCHY:"): + display_name = None + except Exception: + display_name = None + try: + if raw[:1] and ((0x90 <= raw[0] <= 0x9F) or raw[0] == 0xDC): + import RNS.vendor.umsgpack as msgpack + peer_data = msgpack.unpackb(raw) + if isinstance(peer_data, list): + for el in peer_data[3:]: + if isinstance(el, bytes) and el.startswith(b"ARCHY:"): + archy_blob = el.decode("ascii", "ignore") + break + elif raw.startswith(b"ARCHY:"): + # Legacy (pre-upgrade archy node): app_data IS the blob. + archy_blob = raw.decode("ascii", "ignore") + except Exception: + archy_blob = None self.daemon._emit_threadsafe({ "event": "announce", "dest_hash": destination_hash.hex(), - "app_data": (app_data or b"").hex(), + "app_data": raw.hex(), + "display_name": display_name, + "archy_blob": archy_blob, }) @@ -604,8 +661,30 @@ def main(argv=None) -> int: if args.selftest: args.no_radio = True daemon.bring_up() + # Announce app_data round-trip: the LXMF-standard msgpack name must be + # decodable by stock clients AND (with archy keys set) the appended + # identity blob must survive as an extra list element — this is the + # exact wire contract the Rust announce handler and Sideband both + # depend on, so verify it here where there's a real router to build it. + import LXMF as _LXMF + import RNS.vendor.umsgpack as _msgpack + args.archy_ed_pubkey_hex = args.archy_ed_pubkey_hex or "ab" * 32 + args.archy_x25519_pubkey_hex = args.archy_x25519_pubkey_hex or "cd" * 32 + app_data = daemon._announce_app_data() + decoded_name = _LXMF.display_name_from_app_data(app_data) + assert decoded_name == args.display_name, ( + f"announce name round-trip failed: {decoded_name!r} != {args.display_name!r}" + ) + blob_elems = [e for e in _msgpack.unpackb(app_data)[3:] + if isinstance(e, bytes) and e.startswith(b"ARCHY:")] + assert blob_elems, "identity blob missing from announce app_data" + # Live rename: set_name must change what the next announce carries. + daemon.delivery_destination.display_name = "selftest-renamed" + renamed = _LXMF.display_name_from_app_data(daemon._announce_app_data()) + assert renamed == "selftest-renamed", f"rename round-trip failed: {renamed!r}" print(f"selftest ok — dest_hash={daemon.dest_hash_hex} " - f"display_name={args.display_name!r} lxmf_router=up") + f"display_name={args.display_name!r} lxmf_router=up " + f"announce_app_data=verified set_name=verified") return 0 for sig in (signal.SIGINT, signal.SIGTERM):