fix(mesh): first-class Reticulum — probe boot-race, live config apply, name propagation, daemon-death detection

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 <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-07-28 16:36:52 -04:00
parent 537c52d11c
commit a8c4694c36
11 changed files with 677 additions and 132 deletions

View File

@ -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,

View File

@ -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");

View File

@ -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<serde_json::Value> {
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<serde_json::Value> {
let service = self.mesh_service.read().await;

View File

@ -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();

View File

@ -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
};

View File

@ -270,6 +270,7 @@ async fn auto_detect_and_open(
our_x25519_pubkey_hex: &str,
device_kind: Option<DeviceType>,
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<DeviceType>,
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::<String>()
),
};
// 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::<String>()
} else {
let short_did = our_did.chars().skip(8).take(8).collect::<String>();
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 {

View File

@ -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<MeshConfig> {
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<MeshState> {
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,

View File

@ -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<String>,
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<Self> {
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<Self> {
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<Self> {
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<Self> {
// 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<Option<InboundFrame>> {
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::<Value>(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<u8>,
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<String>,
app_data_text: Option<String>,
is_legacy_blob: bool,
) -> Option<String> {
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 = [

View File

@ -610,12 +610,23 @@ pub async fn detect_serial_devices_info() -> Vec<DetectedDeviceInfo> {
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,

View File

@ -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);

View File

@ -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":"<hex16>","content":"","title":"","method":"direct|opportunistic"}
{"cmd":"announce"}
{"cmd":"set_name","name":""}
{"cmd":"status"}
{"cmd":"send_resource","id":"<correlation>","dest_hash":"<hex16>","data_b64":""}
{"cmd":"shutdown"}
out: {"event":"ready","dest_hash":"<hex16>","display_name":""}
{"event":"recv","source_hash":"<hex16>","content":"","title":"","fields":{},"app_data":"<hex>","rssi":n,"snr":n,"stamp":t}
{"event":"announce","dest_hash":"<hex16>","app_data":"<hex>"}
{"event":"announce","dest_hash":"<hex16>","app_data":"<hex>","display_name":""|null,"archy_blob":"ARCHY:2:…"|null}
{"event":"delivered","dest_hash":"<hex16>","state":"delivered|failed","id":"<hex>"}
{"event":"status","connected":bool,"dest_hash":"<hex16>","interfaces":[]}
{"event":"resource_progress","id":"<correlation>","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):