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:
parent
537c52d11c
commit
a8c4694c36
@ -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,
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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
|
||||
};
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 = [
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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):
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user