Compare commits

..
Author SHA1 Message Date
Archipelago 170a12b99d Archipelago — open-source initial import 2026-08-12 10:55:49 +00:00
73 changed files with 4609 additions and 1386 deletions
+50
View File
@@ -84,6 +84,15 @@ version = "1.0.100"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]] [[package]]
name = "arc-swap" name = "arc-swap"
version = "1.9.1" version = "1.9.1"
@@ -161,6 +170,7 @@ dependencies = [
"uuid", "uuid",
"zbase32", "zbase32",
"zeroize", "zeroize",
"zip",
] ]
[[package]] [[package]]
@@ -1175,6 +1185,17 @@ version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]] [[package]]
name = "derive_builder" name = "derive_builder"
version = "0.20.2" version = "0.20.2"
@@ -6895,8 +6916,37 @@ dependencies = [
"syn 2.0.114", "syn 2.0.114",
] ]
[[package]]
name = "zip"
version = "2.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
dependencies = [
"arbitrary",
"crc32fast",
"crossbeam-utils",
"displaydoc",
"flate2",
"indexmap",
"memchr",
"thiserror 2.0.18",
"zopfli",
]
[[package]] [[package]]
name = "zmij" name = "zmij"
version = "1.0.16" version = "1.0.16"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfcd145825aace48cff44a8844de64bf75feec3080e0aa5cdbde72961ae51a65" checksum = "dfcd145825aace48cff44a8844de64bf75feec3080e0aa5cdbde72961ae51a65"
[[package]]
name = "zopfli"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
+4
View File
@@ -108,6 +108,10 @@ bytes = "1"
# Mesh networking (Meshcore serial protocol over USB LoRa radios) # Mesh networking (Meshcore serial protocol over USB LoRa radios)
serial2-tokio = "0.1" serial2-tokio = "0.1"
# LoRa radio firmware flashing: Meshtastic ships per-board images inside a
# per-platform release zip (see mesh/flash.rs).
zip = { version = "2", default-features = false, features = ["deflate"] }
# Double Ratchet key derivation (Phase 3: encrypted mesh messaging) # Double Ratchet key derivation (Phase 3: encrypted mesh messaging)
hkdf = "0.12.4" hkdf = "0.12.4"
@@ -465,6 +465,7 @@ impl RpcHandler {
signing_key.as_ref().map(|i| i.signing_key()), signing_key.as_ref().map(|i| i.signing_key()),
Some(&peer.pubkey), Some(&peer.pubkey),
data.server_info.name.as_deref(), data.server_info.name.as_deref(),
Some(&self.config.data_dir),
) )
.await .await
} }
+5 -2
View File
@@ -244,8 +244,6 @@ impl RpcHandler {
// OpenWrt / TollGate // OpenWrt / TollGate
"openwrt.scan" => self.handle_openwrt_scan(params).await, "openwrt.scan" => self.handle_openwrt_scan(params).await,
"openwrt.get-status" => self.handle_openwrt_get_status(params).await, "openwrt.get-status" => self.handle_openwrt_get_status(params).await,
"openwrt.forget" => self.handle_openwrt_forget().await,
"openwrt.set-password" => self.handle_openwrt_set_password(params).await,
"openwrt.provision-tollgate" => self.handle_openwrt_provision_tollgate(params).await, "openwrt.provision-tollgate" => self.handle_openwrt_provision_tollgate(params).await,
"openwrt.scan-wifi" => self.handle_openwrt_scan_wifi(params).await, "openwrt.scan-wifi" => self.handle_openwrt_scan_wifi(params).await,
"openwrt.configure-wan" => self.handle_openwrt_configure_wan(params).await, "openwrt.configure-wan" => self.handle_openwrt_configure_wan(params).await,
@@ -393,7 +391,12 @@ impl RpcHandler {
// Mesh networking (Meshcore LoRa) // Mesh networking (Meshcore LoRa)
"mesh.status" => self.handle_mesh_status().await, "mesh.status" => self.handle_mesh_status().await,
"mesh.probe-device" => self.handle_mesh_probe_device(params).await, "mesh.probe-device" => self.handle_mesh_probe_device(params).await,
"mesh.flash-list-firmware" => self.handle_mesh_flash_list_firmware(params).await,
"mesh.flash-device" => self.handle_mesh_flash_device(params).await,
"mesh.flash-status" => self.handle_mesh_flash_status().await,
"mesh.flash-cancel" => self.handle_mesh_flash_cancel().await,
"mesh.peers" => self.handle_mesh_peers().await, "mesh.peers" => self.handle_mesh_peers().await,
"mesh.refresh" => self.handle_mesh_refresh().await,
"mesh.messages" => self.handle_mesh_messages(params).await, "mesh.messages" => self.handle_mesh_messages(params).await,
"mesh.debug-dump" => self.handle_mesh_debug_dump().await, "mesh.debug-dump" => self.handle_mesh_debug_dump().await,
"mesh.send" => self.handle_mesh_send(params).await, "mesh.send" => self.handle_mesh_send(params).await,
@@ -866,7 +866,8 @@ impl RpcHandler {
) )
.service(crate::settings::transport::PeerService::Peers) .service(crate::settings::transport::PeerService::Peers)
.timeout(std::time::Duration::from_secs(30)) .timeout(std::time::Duration::from_secs(30))
.fips_timeout(std::time::Duration::from_secs(6)); .fips_timeout(std::time::Duration::from_secs(6))
.record_transport(&self.config.data_dir);
match req.send_json(&body).await { match req.send_json(&body).await {
Ok((resp, transport)) if resp.status().is_success() => { Ok((resp, transport)) if resp.status().is_success() => {
+8 -1
View File
@@ -13,7 +13,14 @@ use anyhow::Result;
impl RpcHandler { impl RpcHandler {
pub(super) async fn handle_fips_status(&self) -> Result<serde_json::Value> { pub(super) async fn handle_fips_status(&self) -> Result<serde_json::Value> {
let status = fips::FipsStatus::query(&self.config.data_dir).await; let status = fips::FipsStatus::query(&self.config.data_dir).await;
Ok(serde_json::to_value(status)?) let mut v = serde_json::to_value(status)?;
// Dial outcome counters (process-lifetime): how often peer dials
// used FIPS vs fell back to Tor, broken down by reason. This is
// the observability that makes "FIPS uptime" measurable.
if let Some(obj) = v.as_object_mut() {
obj.insert("dial_stats".to_string(), fips::telemetry::snapshot());
}
Ok(v)
} }
/// Everything the companion app needs to join this node's mesh, embedded /// Everything the companion app needs to join this node's mesh, embedded
+132
View File
@@ -0,0 +1,132 @@
use super::super::RpcHandler;
use crate::mesh;
use crate::mesh::flash::{self, FlashBoard, FlashJobStatus};
use crate::mesh::types::DeviceType;
use anyhow::Result;
fn parse_family(s: &str) -> Result<DeviceType> {
match s.trim().to_lowercase().as_str() {
"meshcore" => Ok(DeviceType::Meshcore),
"meshtastic" => Ok(DeviceType::Meshtastic),
"reticulum" | "rnode" => Ok(DeviceType::Reticulum),
other => anyhow::bail!("Unknown firmware family: {other} (expected meshcore|meshtastic|reticulum)"),
}
}
fn parse_board(s: &str) -> Result<FlashBoard> {
match s.trim().to_lowercase().as_str() {
"heltec-v3" | "heltec_v3" | "heltecv3" => Ok(FlashBoard::HeltecV3),
"heltec-v4" | "heltec_v4" | "heltecv4" => Ok(FlashBoard::HeltecV4),
other => anyhow::bail!("Unknown board: {other} (expected heltec-v3|heltec-v4)"),
}
}
impl RpcHandler {
/// mesh.flash-list-firmware — resolve the available firmware version(s)
/// for a given family. v1 only ever surfaces "latest".
pub(in crate::api::rpc) async fn handle_mesh_flash_list_firmware(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let family = params
.as_ref()
.and_then(|p| p.get("family"))
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing family"))?;
let family = parse_family(family)?;
let versions = flash::list_firmware(family).await?;
Ok(serde_json::json!({ "versions": versions }))
}
/// mesh.flash-device — erase and reflash a detected LoRa radio with the
/// latest firmware for the given family, defaulting to a full chip
/// erase before write. `board` is optional: if the port's USB vid:pid
/// unambiguously resolves to a known board, that's used; otherwise the
/// caller must supply it explicitly (see `flash::resolve_flash_board`'s
/// doc comment on why we refuse to guess).
pub(in crate::api::rpc) async fn handle_mesh_flash_device(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let path = params
.as_ref()
.and_then(|p| p.get("path"))
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing path"))?
.to_string();
let family = params
.as_ref()
.and_then(|p| p.get("family"))
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing family"))?;
let family = parse_family(family)?;
let detected = mesh::detect_devices().await;
anyhow::ensure!(
detected.iter().any(|d| d == &path),
"{path} is not a detected mesh-radio candidate port"
);
let board = match params
.as_ref()
.and_then(|p| p.get("board"))
.and_then(|v| v.as_str())
{
Some(explicit) => parse_board(explicit)?,
None => {
let info = mesh::detect_devices_info()
.await
.into_iter()
.find(|d| d.path == path);
info.as_ref()
.and_then(flash::resolve_flash_board)
.ok_or_else(|| {
anyhow::anyhow!(
"Could not auto-detect the board on {path} — specify board explicitly"
)
})?
}
};
flash::start_flash_job(
&self.flash_job,
&self.mesh_service_arc(),
self.config.data_dir.clone(),
path,
board,
family,
)
.await?;
Ok(serde_json::json!({ "started": true }))
}
/// mesh.flash-status — poll the current (or most recent) flash job.
pub(in crate::api::rpc) async fn handle_mesh_flash_status(&self) -> Result<serde_json::Value> {
let job = self.flash_job.read().await;
match job.as_ref() {
Some(j) => {
let status: FlashJobStatus = j.snapshot().await;
let mut value = serde_json::to_value(&status)?;
if let Some(obj) = value.as_object_mut() {
obj.insert("active".into(), (!status.done).into());
}
Ok(value)
}
None => Ok(serde_json::json!({ "active": false })),
}
}
/// mesh.flash-cancel — best-effort; only honored before erase/write has
/// started (see `FlashJob::cancel`'s doc comment).
pub(in crate::api::rpc) async fn handle_mesh_flash_cancel(&self) -> Result<serde_json::Value> {
let job = self.flash_job.read().await;
match job.as_ref() {
Some(j) => {
j.cancel().await?;
Ok(serde_json::json!({ "cancelled": true }))
}
None => anyhow::bail!("No flash job in progress"),
}
}
}
+26 -4
View File
@@ -1,6 +1,7 @@
use super::super::RpcHandler; use super::super::RpcHandler;
use crate::mesh; use crate::mesh;
use anyhow::Result; use anyhow::Result;
use std::sync::Arc;
use tracing::info; use tracing::info;
impl RpcHandler { impl RpcHandler {
@@ -131,7 +132,14 @@ impl RpcHandler {
config.broadcast_identity = broadcast; config.broadcast_identity = broadcast;
} }
if let Some(name) = params.get("advert_name").and_then(|v| v.as_str()) { 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 if let Some(announce) = params
.get("announce_block_headers") .get("announce_block_headers")
@@ -202,10 +210,24 @@ impl RpcHandler {
mesh::save_config(&self.config.data_dir, &config).await?; mesh::save_config(&self.config.data_dir, &config).await?;
// If we have a running service, update its config // Apply to the running service in the background: configure() may
let mut service = self.mesh_service.write().await; // 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 Some(svc) = service.as_mut() {
svc.configure(config.clone()).await?; if let Err(e) = svc.configure(config_for_apply).await {
tracing::error!("Applying mesh config to running service failed: {e:#}");
}
}
});
} }
info!("Mesh config updated"); info!("Mesh config updated");
+1
View File
@@ -1,5 +1,6 @@
mod assistant; mod assistant;
mod bitcoin_ops; mod bitcoin_ops;
mod flash;
mod messaging; mod messaging;
mod safety; mod safety;
mod status; mod status;
+51 -5
View File
@@ -101,15 +101,61 @@ impl RpcHandler {
detected.iter().any(|d| d == &path), detected.iter().any(|d| d == &path),
"{path} is not a detected mesh-radio candidate port" "{path} is not a detected mesh-radio candidate port"
); );
// Refuse to probe while a firmware flash is in flight. Confirmed
// live 2026-07-23: esptool ("multiple access on port?") and
// rnodeconf (OSError Errno 71 Protocol error on an RTS ioctl) both
// failed with symptoms consistent with a second process holding the
// same serial fd — the flash subprocess runs for minutes outside
// our own async runtime, so nothing previously stopped a concurrent
// `mesh.probe-device` call (e.g. the hot-swap modal's own re-probe)
// from opening the identical port at the same time and corrupting
// both operations' handshakes.
if let Some(job) = self.flash_job.read().await.as_ref() {
anyhow::ensure!(
job.snapshot().await.done,
"A firmware flash is in progress — refusing to probe the serial port until it finishes"
);
}
// Only hold the mesh_service lock long enough for the quick
// active-path guard check — NEVER across the actual probe, which
// can take 15-60s across its internal collision retries. Confirmed
// live 2026-07-23: holding this read lock for the full probe starved
// a concurrent firmware-flash job's MeshService::stop() (which needs
// the write lock) well past its own bounded timeout, surfacing as
// "Mesh listener did not release the serial port" even though
// stop() itself was fast.
{
let service = self.mesh_service.read().await; let service = self.mesh_service.read().await;
let probe = match service.as_ref() { if let Some(svc) = service.as_ref() {
Some(svc) => svc.probe_device(&path).await?, svc.ensure_probe_allowed(&path).await?;
// No mesh service yet (radio never enabled) — probe directly. }
None => mesh::listener::probe_device(&path).await?, }
}; let probe = mesh::listener::probe_device(&path).await?;
Ok(serde_json::to_value(probe)?) 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. /// mesh.peers — List discovered mesh peers.
pub(in crate::api::rpc) async fn handle_mesh_peers(&self) -> Result<serde_json::Value> { pub(in crate::api::rpc) async fn handle_mesh_peers(&self) -> Result<serde_json::Value> {
let service = self.mesh_service.read().await; let service = self.mesh_service.read().await;
@@ -821,6 +821,7 @@ impl RpcHandler {
.service(crate::settings::transport::PeerService::MeshFileSharing) .service(crate::settings::transport::PeerService::MeshFileSharing)
.timeout(std::time::Duration::from_secs(120)) .timeout(std::time::Duration::from_secs(120))
.fips_timeout(std::time::Duration::from_secs(8)) .fips_timeout(std::time::Duration::from_secs(8))
.record_transport(&self.config.data_dir)
.send_get() .send_get()
.await .await
.map_err(|e| anyhow::anyhow!("Fetch failed: {}", e))?; .map_err(|e| anyhow::anyhow!("Fetch failed: {}", e))?;
+4
View File
@@ -89,6 +89,9 @@ pub struct RpcHandler {
endpoint_rate_limiter: EndpointRateLimiter, endpoint_rate_limiter: EndpointRateLimiter,
response_cache: ResponseCache, response_cache: ResponseCache,
mesh_service: Arc<tokio::sync::RwLock<Option<crate::mesh::MeshService>>>, mesh_service: Arc<tokio::sync::RwLock<Option<crate::mesh::MeshService>>>,
/// LoRa radio firmware-flash job state, sibling to `mesh_service` — one
/// job at a time, since flashing needs exclusive access to the port.
flash_job: crate::mesh::flash::FlashJobHandle,
transport_router: Arc<tokio::sync::RwLock<Option<Arc<crate::transport::TransportRouter>>>>, transport_router: Arc<tokio::sync::RwLock<Option<Arc<crate::transport::TransportRouter>>>>,
/// Shared content-addressed blob store. Set by ApiHandler after construction /// Shared content-addressed blob store. Set by ApiHandler after construction
/// so mesh.send-content / mesh.fetch-content RPCs can reach it without a /// so mesh.send-content / mesh.fetch-content RPCs can reach it without a
@@ -160,6 +163,7 @@ impl RpcHandler {
endpoint_rate_limiter, endpoint_rate_limiter,
response_cache: ResponseCache::new(5), response_cache: ResponseCache::new(5),
mesh_service: Arc::new(tokio::sync::RwLock::new(None)), mesh_service: Arc::new(tokio::sync::RwLock::new(None)),
flash_job: crate::mesh::flash::new_job_handle(),
transport_router: Arc::new(tokio::sync::RwLock::new(None)), transport_router: Arc::new(tokio::sync::RwLock::new(None)),
blob_store: Arc::new(tokio::sync::RwLock::new(None)), blob_store: Arc::new(tokio::sync::RwLock::new(None)),
self_pubkey_hex: Arc::new(tokio::sync::RwLock::new(None)), self_pubkey_hex: Arc::new(tokio::sync::RwLock::new(None)),
+2
View File
@@ -137,6 +137,7 @@ impl RpcHandler {
None, None,
None, None,
None, None,
Some(&self.config.data_dir),
) )
.await?; .await?;
@@ -225,6 +226,7 @@ impl RpcHandler {
signing_key.as_ref().map(|i| i.signing_key()), signing_key.as_ref().map(|i| i.signing_key()),
Some(&req.from_pubkey), Some(&req.from_pubkey),
data.server_info.name.as_deref(), data.server_info.name.as_deref(),
Some(&self.config.data_dir),
) )
.await .await
{ {
-60
View File
@@ -165,66 +165,6 @@ impl RpcHandler {
})) }))
} }
/// Forget the saved router config — deletes `router_config.json` so the
/// app requires a fresh login next time. Takes no params.
pub(super) async fn handle_openwrt_forget(&self) -> Result<serde_json::Value> {
net_router::forget_router_config(&self.config.data_dir).await?;
Ok(serde_json::json!({ "ok": true }))
}
/// Set the router's SSH login password from the app — no manual SSH/
/// console session on the router required. Works for a fresh flash
/// (root has no password yet — connect with `current_password: ""`)
/// or to rotate an existing one. On success the new credentials are
/// persisted the same way a normal login does, so the app is fully
/// connected afterward with no separate "Connect" step needed.
///
/// Params: `{ "host": "192.168.1.1", "ssh_user": "root",
/// "current_password": "", "new_password": "..." }`
pub(super) async fn handle_openwrt_set_password(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let p = params.unwrap_or_default();
let host = p
.get("host")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("host is required"))?
.to_string();
let ssh_user = p
.get("ssh_user")
.and_then(|v| v.as_str())
.unwrap_or("root")
.to_string();
let current_password = p
.get("current_password")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let new_password = p
.get("new_password")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.ok_or_else(|| anyhow::anyhow!("new_password is required"))?
.to_string();
let router = Router::connect_password(&host, 22, &ssh_user, &current_password)?;
router.verify_openwrt()?;
router.set_password(&ssh_user, &new_password)?;
net_router::configure_router(
&self.config.data_dir,
net_router::RouterType::OpenWrt,
&host,
None,
Some(&ssh_user),
Some(&new_password),
)
.await?;
Ok(serde_json::json!({ "ok": true, "host": host }))
}
/// Provision TollGate on an OpenWrt router and create the "archipelago" SSID. /// Provision TollGate on an OpenWrt router and create the "archipelago" SSID.
/// ///
/// Params: `{ "host": "192.168.1.1", "ssh_user": "root", "ssh_password": "", /// Params: `{ "host": "192.168.1.1", "ssh_user": "root", "ssh_password": "",
+1
View File
@@ -133,6 +133,7 @@ impl RpcHandler {
Some(node_id.signing_key()), Some(node_id.signing_key()),
recipient_pubkey.as_deref(), recipient_pubkey.as_deref(),
node_name.as_deref(), node_name.as_deref(),
Some(&self.config.data_dir),
) )
.await?; .await?;
Ok(serde_json::json!({ "ok": true, "sent_to": onion })) Ok(serde_json::json!({ "ok": true, "sent_to": onion }))
@@ -61,6 +61,28 @@ impl RpcHandler {
info!("Server name updated to: {}", name); 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 // Push the new name to federation peers in background
let data_dir = self.config.data_dir.clone(); let data_dir = self.config.data_dir.clone();
let state_manager = self.state_manager.clone(); let state_manager = self.state_manager.clone();
+2 -1
View File
@@ -499,7 +499,8 @@ pub(super) async fn notify_federation_peers_address_change(
) )
.service(crate::settings::transport::PeerService::Peers) .service(crate::settings::transport::PeerService::Peers)
.timeout(std::time::Duration::from_secs(30)) .timeout(std::time::Duration::from_secs(30))
.fips_timeout(std::time::Duration::from_secs(6)); .fips_timeout(std::time::Duration::from_secs(6))
.record_transport(data_dir);
match req.send_json(&payload).await { match req.send_json(&payload).await {
Ok((_, transport)) => { Ok((_, transport)) => {
info!(peer_did = %peer.did, transport = %transport, "Notified peer of address change") info!(peer_did = %peer.did, transport = %transport, "Notified peer of address change")
+73 -7
View File
@@ -22,8 +22,10 @@
//! single declarative call. //! single declarative call.
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use std::collections::HashMap;
use std::path::PathBuf; use std::path::PathBuf;
use std::time::Duration; use std::sync::{LazyLock, Mutex};
use std::time::{Duration, Instant};
use tokio::fs; use tokio::fs;
use tokio::process::Command; use tokio::process::Command;
use tracing::{info, warn}; use tracing::{info, warn};
@@ -35,6 +37,15 @@ const COMPANION_REGISTRY: &str = "146.59.87.168:3000/lfg2025";
const COMPANION_IMAGE_CHECK_TIMEOUT: Duration = Duration::from_secs(15); const COMPANION_IMAGE_CHECK_TIMEOUT: Duration = Duration::from_secs(15);
const COMPANION_BUILD_TIMEOUT: Duration = Duration::from_secs(900); const COMPANION_BUILD_TIMEOUT: Duration = Duration::from_secs(900);
const COMPANION_PULL_TIMEOUT: Duration = Duration::from_secs(300); const COMPANION_PULL_TIMEOUT: Duration = Duration::from_secs(300);
/// After a failed repair (image build/pull included), leave the companion
/// alone for this long. Without it, a node under IO pressure retried a 900s
/// image build every 30s reconcile tick — each build pegging the disk that
/// made the probes fail in the first place (live-diagnosed on zaza-optiplex
/// 2026-07-28: load 50, podman scans starved, apps page stuck).
const REPAIR_COOLDOWN: Duration = Duration::from_secs(600);
static REPAIR_FAILED_AT: LazyLock<Mutex<HashMap<&'static str, Instant>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
/// Static description of one companion. The full list per backend /// Static description of one companion. The full list per backend
/// app_id lives in `companions_for`. /// app_id lives in `companions_for`.
@@ -464,14 +475,30 @@ pub async fn reconcile(installed_apps: &[String]) -> Vec<(String, anyhow::Error)
match needs_repair(spec).await { match needs_repair(spec).await {
Ok(false) => {} Ok(false) => {}
Ok(true) => { Ok(true) => {
if let Some(failed_at) =
REPAIR_FAILED_AT.lock().unwrap().get(spec.name).copied()
{
if failed_at.elapsed() < REPAIR_COOLDOWN {
continue;
}
}
info!( info!(
companion = spec.name, companion = spec.name,
"reconcile: companion not active, repairing" "reconcile: companion not active, repairing"
); );
if let Err(e) = install_one(spec).await { match install_one(spec).await {
Ok(()) => {
REPAIR_FAILED_AT.lock().unwrap().remove(spec.name);
}
Err(e) => {
REPAIR_FAILED_AT
.lock()
.unwrap()
.insert(spec.name, Instant::now());
failures.push((spec.name.to_string(), e)); failures.push((spec.name.to_string(), e));
} }
} }
}
Err(e) => { Err(e) => {
warn!(companion = spec.name, error = %e, "reconcile probe failed"); warn!(companion = spec.name, error = %e, "reconcile probe failed");
failures.push((spec.name.to_string(), e)); failures.push((spec.name.to_string(), e));
@@ -484,19 +511,58 @@ pub async fn reconcile(installed_apps: &[String]) -> Vec<(String, anyhow::Error)
/// Does this companion need install_one to be re-run? Returns true if /// Does this companion need install_one to be re-run? Returns true if
/// the unit file is missing, stale, or the service is not active. /// the unit file is missing, stale, or the service is not active.
///
/// This probe runs every reconcile tick for every companion, so it must be
/// PASSIVE: no image builds, no pulls. It used to call ensure_image_present
/// to render the expected unit — under IO pressure the image-existence check
/// inside timed out, read as "image missing", and a 900s `podman build` ran
/// inside the probe even though the companion was up (the .198 load spiral).
async fn needs_repair(spec: &CompanionSpec) -> Result<bool> { async fn needs_repair(spec: &CompanionSpec) -> Result<bool> {
let dir = quadlet::unit_dir().await?; let dir = quadlet::unit_dir().await?;
let unit_path = dir.join(format!("{}.container", spec.name)); let unit_path = dir.join(format!("{}.container", spec.name));
if !fs::try_exists(&unit_path).await.unwrap_or(false) { if !fs::try_exists(&unit_path).await.unwrap_or(false) {
return Ok(true); return Ok(true);
} }
let expected_image = ensure_image_present(spec).await?; let svc = format!("{}.service", spec.name);
let expected_unit = build_unit(spec, &expected_image); // A hung `systemctl is-active` under IO pressure must not read as
if expected_unit.render() != fs::read_to_string(&unit_path).await.unwrap_or_default() { // "companion dead" — that's a repair (and possibly an image build) fired
// off exactly when the node can least afford one.
match tokio::time::timeout(Duration::from_secs(10), quadlet::is_active(&svc)).await {
Ok(active) => {
if !active {
return Ok(true); return Ok(true);
} }
let svc = format!("{}.service", spec.name); }
Ok(!quadlet::is_active(&svc).await) Err(_) => {
warn!(
companion = spec.name,
"is-active probe timed out; assuming active"
);
}
}
// Service is running. Flag it stale only on definitive, cheap signals:
// the on-disk unit matching none of the image refs install_one could
// have written, or a local build context newer than the built image.
let on_disk = fs::read_to_string(&unit_path).await.unwrap_or_default();
let local_image = format!("localhost/{}:latest", spec.image_base);
let local_image_compat = format!("localhost/{}:local", spec.image_base);
let registry_image = format!("{}/{}:latest", COMPANION_REGISTRY, spec.image_base);
let matches_known_shape = [&local_image, &local_image_compat, &registry_image]
.iter()
.any(|img| build_unit(spec, img).render() == on_disk);
if !matches_known_shape {
return Ok(true);
}
if on_disk.contains(&local_image) && !on_disk.contains(&local_image_compat) {
for dir in spec.build_dir_candidates {
let dockerfile = PathBuf::from(dir).join("Dockerfile");
if fs::try_exists(&dockerfile).await.unwrap_or(false) {
// Conservative on any timeout/error inside: reuse the cache.
return Ok(context_is_newer_than_image(dir, &local_image).await);
}
}
}
Ok(false)
} }
#[cfg(test)] #[cfg(test)]
+21 -10
View File
@@ -232,9 +232,14 @@ pub async fn remove(data_dir: &Path, npub: &str) -> Result<Vec<SeedAnchor>> {
/// leaving `anchor_connected=false` and every peer dial falling back to /// leaving `anchor_connected=false` and every peer dial falling back to
/// a slow Tor timeout. /// a slow Tor timeout.
pub async fn apply(anchors: &[SeedAnchor]) -> Vec<ApplyResult> { pub async fn apply(anchors: &[SeedAnchor]) -> Vec<ApplyResult> {
let mut results = Vec::with_capacity(anchors.len()); // Concurrent, each connect hard-capped: the old serial loop waited
for anchor in anchors { // unbounded on every `sudo fipsctl connect`, so one hung subprocess
let out = Command::new("sudo") // stalled the whole apply — and the periodic anchor tick behind it,
// which is exactly when a wedged daemon most needs the re-apply.
let futs = anchors.iter().cloned().map(|anchor| async move {
let out = tokio::time::timeout(
std::time::Duration::from_secs(15),
Command::new("sudo")
.args([ .args([
"-n", "-n",
"fipsctl", "fipsctl",
@@ -243,15 +248,16 @@ pub async fn apply(anchors: &[SeedAnchor]) -> Vec<ApplyResult> {
&anchor.address, &anchor.address,
&anchor.transport, &anchor.transport,
]) ])
.output() .output(),
)
.await; .await;
let result = match out { let result = match out {
Ok(o) if o.status.success() => ApplyResult { Ok(Ok(o)) if o.status.success() => ApplyResult {
npub: anchor.npub.clone(), npub: anchor.npub.clone(),
ok: true, ok: true,
message: String::from_utf8_lossy(&o.stdout).trim().to_string(), message: String::from_utf8_lossy(&o.stdout).trim().to_string(),
}, },
Ok(o) => ApplyResult { Ok(Ok(o)) => ApplyResult {
npub: anchor.npub.clone(), npub: anchor.npub.clone(),
ok: false, ok: false,
message: format!( message: format!(
@@ -260,11 +266,16 @@ pub async fn apply(anchors: &[SeedAnchor]) -> Vec<ApplyResult> {
String::from_utf8_lossy(&o.stderr).trim() String::from_utf8_lossy(&o.stderr).trim()
), ),
}, },
Err(e) => ApplyResult { Ok(Err(e)) => ApplyResult {
npub: anchor.npub.clone(), npub: anchor.npub.clone(),
ok: false, ok: false,
message: format!("sudo fipsctl launch failed: {}", e), message: format!("sudo fipsctl launch failed: {}", e),
}, },
Err(_) => ApplyResult {
npub: anchor.npub.clone(),
ok: false,
message: "sudo fipsctl connect timed out after 15s".to_string(),
},
}; };
if result.ok { if result.ok {
tracing::debug!(npub = %result.npub, "Seed anchor applied"); tracing::debug!(npub = %result.npub, "Seed anchor applied");
@@ -275,9 +286,9 @@ pub async fn apply(anchors: &[SeedAnchor]) -> Vec<ApplyResult> {
"Seed anchor apply failed (non-fatal)" "Seed anchor apply failed (non-fatal)"
); );
} }
results.push(result); result
} });
results futures_util::future::join_all(futs).await
} }
/// Outcome of a single `fipsctl connect` call. /// Outcome of a single `fipsctl connect` call.
+121 -12
View File
@@ -24,6 +24,7 @@
//! ``` //! ```
#![allow(dead_code)] #![allow(dead_code)]
use super::telemetry::{self, FallbackReason};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use std::net::{IpAddr, Ipv6Addr}; use std::net::{IpAddr, Ipv6Addr};
use std::time::Duration; use std::time::Duration;
@@ -150,6 +151,12 @@ pub async fn warm_path(npub: &str) {
if !is_service_active().await { if !is_service_active().await {
return; return;
} }
warm_path_unchecked(npub).await
}
/// [`warm_path`] without the service-active check — for callers (the warm
/// tick) that already verified the daemon once for the whole batch.
pub async fn warm_path_unchecked(npub: &str) {
let Ok(base) = peer_base_url(npub).await else { let Ok(base) = peer_base_url(npub).await else {
return; return;
}; };
@@ -276,21 +283,39 @@ pub fn as_ip_addr(v6: Ipv6Addr) -> IpAddr {
// ── High-level peer request helpers ──────────────────────────────────── // ── High-level peer request helpers ────────────────────────────────────
/// TTL for the [`is_service_active`] cache. Every FIPS dial attempt and
/// every warm-tick peer used to spawn up to two `systemctl` subprocesses;
/// service state changes on human timescales, so 10s staleness is free.
const SERVICE_ACTIVE_TTL_MS: u64 = 10_000;
static SERVICE_ACTIVE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
static SERVICE_PROBED_AT_MS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
/// Quick poll: is the FIPS daemon (archipelago-supervised OR upstream) /// Quick poll: is the FIPS daemon (archipelago-supervised OR upstream)
/// currently `systemctl is-active`? Async wrapper intended for the /// currently `systemctl is-active`? Cached for [`SERVICE_ACTIVE_TTL_MS`];
/// migration call sites; unlike `FipsTransport::is_available` this does /// concurrent refreshes are harmless (idempotent probe, last write wins).
/// not maintain a cache, so callers that poll frequently should cache
/// themselves.
pub async fn is_service_active() -> bool { pub async fn is_service_active() -> bool {
use std::sync::atomic::Ordering;
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
let probed_at = SERVICE_PROBED_AT_MS.load(Ordering::Relaxed);
if probed_at != 0 && now_ms.saturating_sub(probed_at) < SERVICE_ACTIVE_TTL_MS {
return SERVICE_ACTIVE.load(Ordering::Relaxed);
}
let mut active = false;
for unit in [ for unit in [
crate::fips::SERVICE_UNIT, crate::fips::SERVICE_UNIT,
crate::fips::UPSTREAM_SERVICE_UNIT, crate::fips::UPSTREAM_SERVICE_UNIT,
] { ] {
if crate::fips::service::unit_state(unit).await == "active" { if crate::fips::service::unit_state(unit).await == "active" {
return true; active = true;
break;
} }
} }
false SERVICE_ACTIVE.store(active, Ordering::Relaxed);
SERVICE_PROBED_AT_MS.store(now_ms, Ordering::Relaxed);
active
} }
/// Builder for a peer request that may be sent over FIPS (preferred) or /// Builder for a peer request that may be sent over FIPS (preferred) or
@@ -317,6 +342,11 @@ pub struct PeerRequest<'a> {
/// large content download needs so its long FIPS transfer isn't truncated. /// large content download needs so its long FIPS transfer isn't truncated.
pub fips_timeout: Option<std::time::Duration>, pub fips_timeout: Option<std::time::Duration>,
pub service: Option<crate::settings::transport::PeerService>, pub service: Option<crate::settings::transport::PeerService>,
/// When set, the transport that actually served this request is written
/// to federation storage (`record_peer_transport`, matched by onion) so
/// the per-peer FIPS/Tor badge reflects reality. Opt-in because not
/// every caller has a data dir in scope.
pub record_data_dir: Option<std::path::PathBuf>,
} }
impl<'a> PeerRequest<'a> { impl<'a> PeerRequest<'a> {
@@ -329,6 +359,31 @@ impl<'a> PeerRequest<'a> {
timeout: std::time::Duration::from_secs(30), timeout: std::time::Duration::from_secs(30),
fips_timeout: None, fips_timeout: None,
service: None, service: None,
record_data_dir: None,
}
}
/// Record the transport that serves this request into federation storage
/// (matched by this request's onion host). Best-effort, off the hot path.
pub fn record_transport(mut self, data_dir: impl Into<std::path::PathBuf>) -> Self {
self.record_data_dir = Some(data_dir.into());
self
}
fn spawn_record(&self, kind: crate::transport::TransportKind) {
if let Some(dir) = &self.record_data_dir {
let dir = dir.clone();
let onion = self.onion_host.to_string();
let transport = kind.to_string();
tokio::spawn(async move {
let _ = crate::federation::record_peer_transport(
&dir,
None,
Some(&onion),
&transport,
)
.await;
});
} }
} }
@@ -389,8 +444,22 @@ impl<'a> PeerRequest<'a> {
// fix (404 path-not-served / 5xx) and we're allowed to // fix (404 path-not-served / 5xx) and we're allowed to
// fall back. FIPS-only never falls back. // fall back. FIPS-only never falls back.
if pref == TransportPref::Fips || !fips_should_fall_back(resp.status()) { if pref == TransportPref::Fips || !fips_should_fall_back(resp.status()) {
telemetry::record_fips_ok();
self.spawn_record(crate::transport::TransportKind::Fips);
return Ok((resp, crate::transport::TransportKind::Fips)); return Ok((resp, crate::transport::TransportKind::Fips));
} }
let reason = if resp.status() == reqwest::StatusCode::NOT_FOUND {
FallbackReason::Http404
} else {
FallbackReason::Http5xx
};
telemetry::record_fallback(reason);
tracing::info!(
reason = reason.key(),
status = %resp.status(),
"FIPS POST {} answered but status triggers Tor fallback",
self.path
);
} }
None => { None => {
if pref == TransportPref::Fips { if pref == TransportPref::Fips {
@@ -402,6 +471,7 @@ impl<'a> PeerRequest<'a> {
} }
} }
let resp = self.send_tor_post_json(body).await?; let resp = self.send_tor_post_json(body).await?;
self.spawn_record(crate::transport::TransportKind::Tor);
Ok((resp, crate::transport::TransportKind::Tor)) Ok((resp, crate::transport::TransportKind::Tor))
} }
@@ -413,8 +483,22 @@ impl<'a> PeerRequest<'a> {
match self.try_fips_get().await? { match self.try_fips_get().await? {
Some(resp) => { Some(resp) => {
if pref == TransportPref::Fips || !fips_should_fall_back(resp.status()) { if pref == TransportPref::Fips || !fips_should_fall_back(resp.status()) {
telemetry::record_fips_ok();
self.spawn_record(crate::transport::TransportKind::Fips);
return Ok((resp, crate::transport::TransportKind::Fips)); return Ok((resp, crate::transport::TransportKind::Fips));
} }
let reason = if resp.status() == reqwest::StatusCode::NOT_FOUND {
FallbackReason::Http404
} else {
FallbackReason::Http5xx
};
telemetry::record_fallback(reason);
tracing::info!(
reason = reason.key(),
status = %resp.status(),
"FIPS GET {} answered but status triggers Tor fallback",
self.path
);
} }
None => { None => {
if pref == TransportPref::Fips { if pref == TransportPref::Fips {
@@ -426,6 +510,7 @@ impl<'a> PeerRequest<'a> {
} }
} }
let resp = self.send_tor_get().await?; let resp = self.send_tor_get().await?;
self.spawn_record(crate::transport::TransportKind::Tor);
Ok((resp, crate::transport::TransportKind::Tor)) Ok((resp, crate::transport::TransportKind::Tor))
} }
@@ -434,15 +519,23 @@ impl<'a> PeerRequest<'a> {
body: &B, body: &B,
) -> Result<Option<reqwest::Response>> { ) -> Result<Option<reqwest::Response>> {
let Some(npub) = self.fips_npub else { let Some(npub) = self.fips_npub else {
telemetry::record_fallback(FallbackReason::NoNpub);
return Ok(None); return Ok(None);
}; };
if !is_service_active().await { if !is_service_active().await {
telemetry::record_fallback(FallbackReason::ServiceInactive);
return Ok(None); return Ok(None);
} }
let base = match peer_base_url(npub).await { let base = match peer_base_url(npub).await {
Ok(b) => b, Ok(b) => b,
Err(e) => { Err(e) => {
tracing::debug!("FIPS resolve for {} failed: {}", npub, e); telemetry::record_fallback(FallbackReason::DnsFail);
tracing::info!(
reason = FallbackReason::DnsFail.key(),
"FIPS resolve for {} failed: {}, falling back to Tor",
npub,
e
);
return Ok(None); return Ok(None);
} }
}; };
@@ -467,7 +560,9 @@ impl<'a> PeerRequest<'a> {
match tokio::time::timeout(budget, send_with_retry(rb)).await { match tokio::time::timeout(budget, send_with_retry(rb)).await {
Ok(Ok(r)) => Ok(Some(r)), Ok(Ok(r)) => Ok(Some(r)),
Ok(Err(e)) => { Ok(Err(e)) => {
tracing::debug!( telemetry::record_fallback(FallbackReason::ConnectFail);
tracing::info!(
reason = FallbackReason::ConnectFail.key(),
"FIPS POST {} failed after retry: {}, falling back to Tor", "FIPS POST {} failed after retry: {}, falling back to Tor",
url, url,
e e
@@ -475,7 +570,9 @@ impl<'a> PeerRequest<'a> {
Ok(None) Ok(None)
} }
Err(_) => { Err(_) => {
tracing::debug!( telemetry::record_fallback(FallbackReason::ConnectFail);
tracing::info!(
reason = FallbackReason::ConnectFail.key(),
"FIPS POST {} exceeded attempt budget {:?}, falling back to Tor", "FIPS POST {} exceeded attempt budget {:?}, falling back to Tor",
url, url,
budget budget
@@ -487,15 +584,23 @@ impl<'a> PeerRequest<'a> {
async fn try_fips_get(&self) -> Result<Option<reqwest::Response>> { async fn try_fips_get(&self) -> Result<Option<reqwest::Response>> {
let Some(npub) = self.fips_npub else { let Some(npub) = self.fips_npub else {
telemetry::record_fallback(FallbackReason::NoNpub);
return Ok(None); return Ok(None);
}; };
if !is_service_active().await { if !is_service_active().await {
telemetry::record_fallback(FallbackReason::ServiceInactive);
return Ok(None); return Ok(None);
} }
let base = match peer_base_url(npub).await { let base = match peer_base_url(npub).await {
Ok(b) => b, Ok(b) => b,
Err(e) => { Err(e) => {
tracing::debug!("FIPS resolve for {} failed: {}", npub, e); telemetry::record_fallback(FallbackReason::DnsFail);
tracing::info!(
reason = FallbackReason::DnsFail.key(),
"FIPS resolve for {} failed: {}, falling back to Tor",
npub,
e
);
return Ok(None); return Ok(None);
} }
}; };
@@ -516,7 +621,9 @@ impl<'a> PeerRequest<'a> {
match tokio::time::timeout(budget, send_with_retry(rb)).await { match tokio::time::timeout(budget, send_with_retry(rb)).await {
Ok(Ok(r)) => Ok(Some(r)), Ok(Ok(r)) => Ok(Some(r)),
Ok(Err(e)) => { Ok(Err(e)) => {
tracing::debug!( telemetry::record_fallback(FallbackReason::ConnectFail);
tracing::info!(
reason = FallbackReason::ConnectFail.key(),
"FIPS GET {} failed after retry: {}, falling back to Tor", "FIPS GET {} failed after retry: {}, falling back to Tor",
url, url,
e e
@@ -524,7 +631,9 @@ impl<'a> PeerRequest<'a> {
Ok(None) Ok(None)
} }
Err(_) => { Err(_) => {
tracing::debug!( telemetry::record_fallback(FallbackReason::ConnectFail);
tracing::info!(
reason = FallbackReason::ConnectFail.key(),
"FIPS GET {} exceeded attempt budget {:?}, falling back to Tor", "FIPS GET {} exceeded attempt budget {:?}, falling back to Tor",
url, url,
budget budget
+53 -4
View File
@@ -31,6 +31,7 @@ pub mod config;
pub mod dial; pub mod dial;
pub mod iface; pub mod iface;
pub mod service; pub mod service;
pub mod telemetry;
pub mod update; pub mod update;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -79,25 +80,73 @@ pub async fn ensure_activated(data_dir: &std::path::Path) {
pub fn spawn_fips_supervisor(data_dir: std::path::PathBuf) { pub fn spawn_fips_supervisor(data_dir: std::path::PathBuf) {
tokio::spawn(async move { tokio::spawn(async move {
let mut tick = tokio::time::interval(std::time::Duration::from_secs(25)); let mut tick = tokio::time::interval(std::time::Duration::from_secs(25));
// Connectivity watcher state: re-apply seed anchors the moment the
// anchor link drops (edge) or the data path degrades (dials keep
// failing with zero successes), instead of waiting for the 300s
// anchor tick. Bounded: at most one re-apply per RE_APPLY_BACKOFF.
const RE_APPLY_BACKOFF: std::time::Duration = std::time::Duration::from_secs(60);
let mut prev_connected: Option<bool> = None;
let mut prev_totals = telemetry::totals();
let mut last_apply: Option<std::time::Instant> = None;
loop { loop {
tick.tick().await; tick.tick().await;
// Bring FIPS up on its own once onboarding has materialised the key. // Bring FIPS up on its own once onboarding has materialised the key.
ensure_activated(&data_dir).await; ensure_activated(&data_dir).await;
if !dial::is_service_active().await { if !dial::is_service_active().await {
prev_connected = None; // daemon restart = fresh edge detection
continue; continue;
} }
// ── Warm the union of federation peers + configured seed
// anchors. Warming only federation npubs left the direct
// anchors (vps2, LAN peers) to go cold between 300s ticks.
let nodes = crate::federation::load_nodes(&data_dir) let nodes = crate::federation::load_nodes(&data_dir)
.await .await
.unwrap_or_default(); .unwrap_or_default();
let seed = anchors::load(&data_dir).await.unwrap_or_default();
let mut warm_npubs: std::collections::BTreeSet<String> = nodes
.iter()
.filter_map(|n| n.fips_npub.clone())
.collect();
warm_npubs.extend(seed.iter().map(|a| a.npub.clone()));
let mut handles = Vec::new(); let mut handles = Vec::new();
for node in nodes { for npub in warm_npubs {
if let Some(npub) = node.fips_npub.clone() { // Service-active was checked once above for the whole batch.
handles.push(tokio::spawn(async move { dial::warm_path(&npub).await })); handles.push(tokio::spawn(
} async move { dial::warm_path_unchecked(&npub).await },
));
} }
for h in handles { for h in handles {
let _ = h.await; let _ = h.await;
} }
// ── Connectivity watcher: detect anchor-link loss AND silent
// data-path death (daemon reports "connected" but every dial
// connect-fails — observed live on .198, 2026-07-27, where the
// 300s tick never healed it).
let mut anchor_npubs = vec![service::PUBLIC_ANCHOR_NPUB.to_string()];
anchor_npubs.extend(seed.iter().map(|a| a.npub.clone()));
let (_, connected) = service::peer_connectivity_summary(&anchor_npubs).await;
let totals = telemetry::totals();
let link_dropped = prev_connected == Some(true) && !connected;
let never_connected = prev_connected.is_none() && !connected;
let data_path_dead =
totals.1.saturating_sub(prev_totals.1) >= 5 && totals.0 == prev_totals.0;
prev_connected = Some(connected);
prev_totals = totals;
let backoff_ok = last_apply.is_none_or(|t| t.elapsed() >= RE_APPLY_BACKOFF);
if (link_dropped || never_connected || data_path_dead) && backoff_ok && !seed.is_empty()
{
tracing::info!(
link_dropped,
never_connected,
data_path_dead,
"FIPS connectivity degraded — re-applying seed anchors now"
);
last_apply = Some(std::time::Instant::now());
let _ = anchors::apply(&seed).await;
}
} }
}); });
} }
+155
View File
@@ -0,0 +1,155 @@
//! In-process counters for FIPS dial outcomes.
//!
//! Every peer dial that could have used FIPS either succeeds over FIPS or
//! falls back to Tor for one of six reasons (F1F6). Before these counters
//! existed, fallbacks were `debug!`-only and invisible in production, which
//! made "FIPS uptime" unfalsifiable — several paths were 100% Tor for months
//! (dead ports, firewalled listeners, allowlist 404s) and nothing surfaced
//! it. The counters are process-lifetime (reset on restart) and exposed via
//! `fips.status` as `dial_stats`, so a fleet-wide fallback regression shows
//! up on the dashboard instead of as vague slowness.
use std::sync::atomic::{AtomicU64, Ordering};
/// Why a FIPS-capable dial fell back to Tor.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FallbackReason {
/// F1 — no FIPS npub known for the peer (never meshed, or pre-npub
/// federation record). Expected for non-FIPS peers; high counts here
/// mean npub propagation is broken, not the transport.
NoNpub,
/// F2 — the local FIPS daemon service isn't active.
ServiceInactive,
/// F3 — the local FIPS DNS resolver couldn't resolve the peer's npub
/// (daemon up but peer not in the identity cache / mesh unreachable).
DnsFail,
/// F4 — TCP/HTTP dial to the peer's ULA failed or exceeded the FIPS
/// attempt budget (firewalled :5679, cold hole-punch, peer down).
ConnectFail,
/// F5 — peer answered over FIPS with 404: its listener doesn't serve
/// this path (older build / stricter allowlist).
Http404,
/// F6 — peer answered over FIPS with a 5xx server error.
Http5xx,
}
impl FallbackReason {
pub fn key(self) -> &'static str {
match self {
Self::NoNpub => "no_npub",
Self::ServiceInactive => "service_inactive",
Self::DnsFail => "dns_fail",
Self::ConnectFail => "connect_fail",
Self::Http404 => "http_404",
Self::Http5xx => "http_5xx",
}
}
}
static FIPS_OK: AtomicU64 = AtomicU64::new(0);
static NO_NPUB: AtomicU64 = AtomicU64::new(0);
static SERVICE_INACTIVE: AtomicU64 = AtomicU64::new(0);
static DNS_FAIL: AtomicU64 = AtomicU64::new(0);
static CONNECT_FAIL: AtomicU64 = AtomicU64::new(0);
static HTTP_404: AtomicU64 = AtomicU64::new(0);
static HTTP_5XX: AtomicU64 = AtomicU64::new(0);
fn counter(reason: FallbackReason) -> &'static AtomicU64 {
match reason {
FallbackReason::NoNpub => &NO_NPUB,
FallbackReason::ServiceInactive => &SERVICE_INACTIVE,
FallbackReason::DnsFail => &DNS_FAIL,
FallbackReason::ConnectFail => &CONNECT_FAIL,
FallbackReason::Http404 => &HTTP_404,
FallbackReason::Http5xx => &HTTP_5XX,
}
}
/// A dial completed over FIPS (any HTTP status that wasn't a fallback
/// trigger — the peer was reached on the mesh).
pub fn record_fips_ok() {
FIPS_OK.fetch_add(1, Ordering::Relaxed);
}
/// A FIPS-capable dial fell back to Tor.
pub fn record_fallback(reason: FallbackReason) {
counter(reason).fetch_add(1, Ordering::Relaxed);
}
/// `(fips_ok, connect_fail)` totals for the connectivity watcher: a window
/// where connect_fail grows while fips_ok doesn't is a degraded data path —
/// including the "daemon says connected but packets blackhole" failure the
/// link-state check alone can't see (observed live 2026-07-27 on .198).
pub fn totals() -> (u64, u64) {
(
FIPS_OK.load(Ordering::Relaxed),
CONNECT_FAIL.load(Ordering::Relaxed),
)
}
/// Snapshot for `fips.status` (`dial_stats`). Process-lifetime counts.
pub fn snapshot() -> serde_json::Value {
let f1 = NO_NPUB.load(Ordering::Relaxed);
let f2 = SERVICE_INACTIVE.load(Ordering::Relaxed);
let f3 = DNS_FAIL.load(Ordering::Relaxed);
let f4 = CONNECT_FAIL.load(Ordering::Relaxed);
let f5 = HTTP_404.load(Ordering::Relaxed);
let f6 = HTTP_5XX.load(Ordering::Relaxed);
serde_json::json!({
"fips_ok": FIPS_OK.load(Ordering::Relaxed),
"fallbacks": {
"no_npub": f1,
"service_inactive": f2,
"dns_fail": f3,
"connect_fail": f4,
"http_404": f5,
"http_5xx": f6,
"total": f1 + f2 + f3 + f4 + f5 + f6,
},
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn snapshot_counts_recorded_events() {
// Counters are global; assert deltas rather than absolutes so this
// test stays correct alongside any other test that dials.
let before = snapshot();
record_fips_ok();
record_fallback(FallbackReason::ConnectFail);
record_fallback(FallbackReason::Http404);
let after = snapshot();
let d = |v: &serde_json::Value, path: &[&str]| -> u64 {
let mut cur = v;
for p in path {
cur = &cur[p];
}
cur.as_u64().unwrap()
};
assert_eq!(d(&after, &["fips_ok"]) - d(&before, &["fips_ok"]), 1);
assert_eq!(
d(&after, &["fallbacks", "connect_fail"]) - d(&before, &["fallbacks", "connect_fail"]),
1
);
assert_eq!(
d(&after, &["fallbacks", "http_404"]) - d(&before, &["fallbacks", "http_404"]),
1
);
assert!(d(&after, &["fallbacks", "total"]) >= 2);
}
#[test]
fn reason_keys_are_stable() {
// These strings are the fips.status API surface — renaming one is a
// breaking change for the UI.
assert_eq!(FallbackReason::NoNpub.key(), "no_npub");
assert_eq!(FallbackReason::ServiceInactive.key(), "service_inactive");
assert_eq!(FallbackReason::DnsFail.key(), "dns_fail");
assert_eq!(FallbackReason::ConnectFail.key(), "connect_fail");
assert_eq!(FallbackReason::Http404.key(), "http_404");
assert_eq!(FallbackReason::Http5xx.key(), "http_5xx");
}
}
+975
View File
@@ -0,0 +1,975 @@
// WIP mesh/transport protocol — suppress dead code warnings
#![allow(dead_code)]
//! Firmware flashing for LoRa mesh radios — Heltec V3/V4 in v1, across all
//! three firmware families the mesh module already knows how to detect (see
//! `mesh::types::DeviceType`). Firmware is always fetched from upstream at
//! flash time (never bundled/pinned in the repo), and every flash defaults
//! to a full chip erase before write.
//!
//! MeshCore and Meshtastic are flashed the same way: download a released
//! image, `esptool erase_flash`, then `esptool write_flash 0x0 <image>`.
//! Reticulum/RNode is different: `archy-rnodeconf --autoinstall` owns the
//! whole fetch+erase+flash+EEPROM-bootstrap sequence itself (confirmed live
//! via `archy-rnodeconf --help` — there is no raw esptool path exposed for
//! this family, so we deliberately don't resolve a firmware URL ourselves
//! for Reticulum; rnodeconf already knows how).
use super::serial::DetectedDeviceInfo;
use super::types::DeviceType;
use super::MeshService;
use anyhow::{Context, Result};
use regex::Regex;
use serde::Serialize;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::{Arc, OnceLock};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::process::Command;
use tokio::sync::RwLock;
use tracing::{info, warn};
/// Boards supported for v1. Both are ESP32-S3 (a single `--chip esp32s3`
/// esptool target covers both), but ship different USB identities and
/// different per-board firmware assets upstream.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum FlashBoard {
HeltecV3,
HeltecV4,
}
impl FlashBoard {
/// Meshtastic's board id (matches the release manifest's `board` field
/// and its per-board asset naming, e.g. `firmware-heltec-v3-<ver>.factory.bin`).
fn meshtastic_id(self) -> &'static str {
match self {
Self::HeltecV3 => "heltec-v3",
Self::HeltecV4 => "heltec-v4",
}
}
}
/// Map a detected USB vid:pid to a known flashable board, using the same
/// table as `image-recipe/configs/99-mesh-radio.rules`. CP2102 (10c4:ea60)
/// is confirmed there as Heltec V3's USB-UART bridge chip, and is safe to
/// auto-match since that vid:pid is bridge-chip-specific.
///
/// Heltec V4 is NOT auto-matchable and deliberately has no entry here: it
/// was confirmed live (real hardware, 2026-07-23) to use the ESP32-S3's
/// built-in native-USB JTAG/serial peripheral, reporting vid:pid 303a:1001
/// with product string "USB JTAG/serial debug unit" — that descriptor is
/// baked into the chip's ROM and is IDENTICAL across every ESP32-S3 board
/// with native USB enabled, not just Heltec V4. Adding `303a:1001 =>
/// HeltecV4` here would silently misidentify any other native-USB ESP32-S3
/// board (a T3-S3, a bare devkit, etc.) as a V4 and risk writing the wrong
/// board's image. Callers (the RPC layer / frontend) must let the user pick
/// the board manually whenever this returns `None`.
pub fn resolve_flash_board(info: &DetectedDeviceInfo) -> Option<FlashBoard> {
match (info.vid.as_deref(), info.pid.as_deref()) {
(Some("10c4"), Some("ea60")) => Some(FlashBoard::HeltecV3),
_ => None,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum FlashStage {
Downloading,
Erasing,
Writing,
Autoinstalling,
Done,
Failed,
}
#[derive(Debug, Clone, Serialize)]
pub struct FlashJobStatus {
pub board: FlashBoard,
pub family: DeviceType,
pub path: String,
pub stage: FlashStage,
pub percent: Option<u8>,
pub log_tail: Vec<String>,
pub done: bool,
pub error: Option<String>,
}
const LOG_TAIL_MAX: usize = 200;
/// How long to wait after a successful flash before resuming the mesh
/// listener, so the board finishes its own post-flash boot/reset before we
/// start opening the port (which itself toggles DTR/RTS) again.
const POST_FLASH_SETTLE_DELAY: std::time::Duration = std::time::Duration::from_secs(5);
/// Absolute ceiling on a whole flash job (download + erase + write, or
/// autoinstall), regardless of what it's doing internally. Last-resort
/// safety net so a hang anywhere can't wedge the single-flash-job guard
/// forever — generous enough to never trigger on a legitimately slow
/// multi-hundred-MB transfer.
const MAX_JOB_DURATION: std::time::Duration = std::time::Duration::from_secs(15 * 60);
/// How long to wait for MeshService::stop() to release the serial port
/// before giving up. Confirmed live 2026-07-23: the listener's own
/// reconnect/multi-candidate-probe loop doesn't check its shutdown signal
/// between candidates, so stop() can take a while (or, if the loop is
/// wedged, never return) — 20s comfortably covers a normal handshake-probe
/// cycle without leaving a flash request hanging indefinitely if the
/// listener genuinely won't let go.
const STOP_LISTENER_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20);
/// How long to keep retrying the port-free check before giving up.
const PORT_FREE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
/// Confirm nothing else has `path` open by actually opening (and immediately
/// closing) it ourselves. Retries across the timeout since a just-stopped
/// listener's fd can take a moment to actually release even after `stop()`
/// returns (task abort is a request, not an instant guarantee the OS-level
/// resource is gone yet).
async fn wait_for_port_free(path: &str) -> Result<()> {
let deadline = tokio::time::Instant::now() + PORT_FREE_TIMEOUT;
let mut last_err = None;
loop {
match serial2_tokio::SerialPort::open(path, 115200) {
Ok(_) => return Ok(()),
Err(e) => last_err = Some(e),
}
if tokio::time::Instant::now() >= deadline {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
}
Err(anyhow::anyhow!(
"{path} is still held open by something else after {}s (last error: {}) — refusing to start the flasher against a contended port",
PORT_FREE_TIMEOUT.as_secs(),
last_err.map(|e| e.to_string()).unwrap_or_default()
))
}
/// Live state for the one flash job that can run at a time. A single global
/// slot is sufficient because flashing needs exclusive serial access to the
/// one port being flashed — there is no meaningful concept of two concurrent
/// flash jobs on this node.
pub struct FlashJob {
status: RwLock<FlashJobStatus>,
/// Set once the background task is spawned. Only used while `stage` is
/// still `Downloading` — an interrupted erase/write can leave the chip
/// in a worse state than either finished or unstarted, so cancellation
/// is refused once erase begins (see `cancel()`).
abort_handle: RwLock<Option<tokio::task::AbortHandle>>,
}
impl FlashJob {
fn new(board: FlashBoard, family: DeviceType, path: String) -> Arc<Self> {
Arc::new(Self {
abort_handle: RwLock::new(None),
status: RwLock::new(FlashJobStatus {
board,
family,
path,
stage: FlashStage::Downloading,
percent: None,
log_tail: Vec::new(),
done: false,
error: None,
}),
})
}
pub async fn snapshot(&self) -> FlashJobStatus {
self.status.read().await.clone()
}
async fn set_stage(&self, stage: FlashStage) {
let mut s = self.status.write().await;
s.stage = stage;
s.percent = None;
}
async fn set_percent(&self, percent: u8) {
self.status.write().await.percent = Some(percent.min(100));
}
async fn push_log(&self, line: impl Into<String>) {
let mut s = self.status.write().await;
s.log_tail.push(line.into());
let overflow = s.log_tail.len().saturating_sub(LOG_TAIL_MAX);
if overflow > 0 {
s.log_tail.drain(0..overflow);
}
}
async fn fail(&self, err: &anyhow::Error) {
let mut s = self.status.write().await;
s.stage = FlashStage::Failed;
s.error = Some(format!("{err:#}"));
s.done = true;
}
async fn finish(&self) {
let mut s = self.status.write().await;
s.stage = FlashStage::Done;
s.done = true;
}
/// Best-effort cancel: only honored before erase/write/autoinstall has
/// started (i.e. still in `Downloading`). Once a stage that touches the
/// chip begins, this refuses — interrupting an erase or write can leave
/// the flash in a state worse than either finished or unstarted.
pub async fn cancel(&self) -> Result<()> {
let mut s = self.status.write().await;
if s.done {
anyhow::bail!("Flash job already finished");
}
if s.stage != FlashStage::Downloading {
anyhow::bail!(
"Cannot cancel once {:?} has started — let it finish or fail on its own",
s.stage
);
}
if let Some(handle) = self.abort_handle.write().await.take() {
handle.abort();
}
s.stage = FlashStage::Failed;
s.error = Some("Cancelled by user".to_string());
s.done = true;
Ok(())
}
}
/// Shared handle held by `RpcHandler`, sibling to `mesh_service`.
pub type FlashJobHandle = Arc<RwLock<Option<Arc<FlashJob>>>>;
pub fn new_job_handle() -> FlashJobHandle {
Arc::new(RwLock::new(None))
}
fn firmware_cache_dir(data_dir: &Path) -> PathBuf {
data_dir.join("mesh").join("firmware-cache")
}
/// No blanket `.timeout()` here on purpose: reqwest's request timeout covers
/// the *entire* request including streaming the response body, which would
/// kill a legitimate large download partway through (Meshtastic's esp32s3
/// zip is ~170MB) — not just a hung connection. `download_to_file` instead
/// applies a per-chunk stall timeout, and metadata calls (small JSON
/// responses) get their own short timeout at the call site.
fn github_client() -> Result<reqwest::Client> {
reqwest::Client::builder()
.user_agent("archipelago-mesh-flash")
.connect_timeout(std::time::Duration::from_secs(10))
.build()
.context("Failed to build HTTP client")
}
/// Applied per-chunk while streaming a firmware download — if the transfer
/// stalls (no bytes for this long) it's treated as a failure, but a slow
/// download that's still making progress is never killed just for taking a
/// while.
const DOWNLOAD_STALL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
/// Applied to metadata calls (GitHub release JSON) — these are small
/// responses with no reason to ever take this long.
const METADATA_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20);
/// Resolve what firmware is available for a board+family. v1 only ever
/// offers "latest" — MeshCore/Meshtastic latest GitHub release, or, for
/// Reticulum, "latest" meaning "whatever archy-rnodeconf --autoinstall
/// resolves on its own" (it does its own version checking upstream).
pub async fn list_firmware(family: DeviceType) -> Result<Vec<String>> {
match family {
DeviceType::Reticulum => Ok(vec!["latest".to_string()]),
DeviceType::Meshtastic => {
let client = github_client()?;
let release: GithubRelease = client
.get("https://api.github.com/repos/meshtastic/firmware/releases/latest")
.send()
.await
.context("Fetching Meshtastic release list")?
.error_for_status()
.context("Meshtastic releases API error")?
.json()
.await
.context("Parsing Meshtastic release JSON")?;
Ok(vec![release.tag_name])
}
DeviceType::Meshcore => {
let client = github_client()?;
let release: GithubRelease = client
.get("https://api.github.com/repos/meshcore-dev/MeshCore/releases/latest")
.send()
.await
.context("Fetching MeshCore release list")?
.error_for_status()
.context("MeshCore releases API error")?
.json()
.await
.context("Parsing MeshCore release JSON")?;
Ok(vec![release.tag_name])
}
DeviceType::Unknown => anyhow::bail!("Pick a firmware family before listing versions"),
}
}
#[derive(serde::Deserialize)]
struct GithubAsset {
name: String,
browser_download_url: String,
}
#[derive(serde::Deserialize)]
struct GithubRelease {
tag_name: String,
assets: Vec<GithubAsset>,
}
/// Start a flash job in the background. Returns as soon as the job has been
/// registered and the listener released — callers poll `FlashJobHandle` via
/// `mesh.flash-status` for progress. Only one job may be in flight at a time.
pub async fn start_flash_job(
handle: &FlashJobHandle,
mesh_service: &Arc<RwLock<Option<MeshService>>>,
data_dir: PathBuf,
path: String,
board: FlashBoard,
family: DeviceType,
) -> Result<()> {
{
let existing = handle.read().await;
if let Some(job) = existing.as_ref() {
if !job.snapshot().await.done {
anyhow::bail!("A firmware flash is already in progress on this node");
}
}
}
let job = FlashJob::new(board, family, path.clone());
*handle.write().await = Some(Arc::clone(&job));
let bg_job = Arc::clone(&job);
let bg_service = Arc::clone(mesh_service);
let task = tokio::spawn(async move {
// esptool/archy-rnodeconf need exclusive serial access — release
// the listener's hold on the port before touching it. This USED
// TO run synchronously in start_flash_job before the job was even
// spawned, blocking the RPC call itself on s.stop().await — a real
// 2026-07-23 incident: the mesh listener was mid a multi-candidate
// reconnect/probe sequence that doesn't check its shutdown signal
// between candidates, so stop() never returned. The HTTP request
// timed out client-side ("Operation failed"), while the job
// (already inserted into `handle`) was permanently wedged — nothing
// had been spawned yet to ever mark it done, so every later flash
// attempt failed with "already in progress" until a full restart.
// Now this runs inside the spawned task with its own bounded
// timeout, so the RPC call always returns immediately regardless,
// and a slow-to-stop listener fails the job cleanly instead of
// hanging everything downstream of it forever.
let stop_result = tokio::time::timeout(STOP_LISTENER_TIMEOUT, async {
let mut svc = bg_service.write().await;
if let Some(s) = svc.as_mut() {
s.stop().await;
}
})
.await;
if stop_result.is_err() {
let err = anyhow::anyhow!(
"Mesh listener did not release the serial port within {}s — it may still be mid a reconnect attempt. Try again once mesh.status shows the device idle, or restart the archipelago service if this persists.",
STOP_LISTENER_TIMEOUT.as_secs()
);
bg_job.push_log(format!("ERROR: {err:#}")).await;
bg_job.fail(&err).await;
return;
}
// Belt-and-suspenders port-free check. `stop()` above should have
// fully released the port, but esptool/rnodeconf run as external
// subprocesses for minutes outside our own async runtime — if
// ANYTHING else still has it open (a racing probe, a not-yet-dropped
// fd from an aborted task, anything we haven't anticipated), handing
// the port to the flasher anyway risks exactly the corruption
// confirmed live 2026-07-23: esptool's "device disconnected or
// multiple access on port?" and rnodeconf's raw `OSError: [Errno 71]
// Protocol error` on an RTS ioctl are both textbook two-openers-on-
// one-fd symptoms. Verify by actually opening it ourselves — cheap,
// and definitive — before ever starting the flasher.
if let Err(e) = wait_for_port_free(&path).await {
bg_job.push_log(format!("ERROR: {e:#}")).await;
bg_job.fail(&e).await;
return;
}
// Outer ceiling on top of run_flash's own internal timeouts —
// belt-and-suspenders so that no future hang (network, subprocess,
// anything) can ever wedge the single-flash-job guard permanently
// again the way a stuck download did on 2026-07-23 (every
// subsequent mesh.flash-device call failed with "already in
// progress" until the service was restarted). Generous enough that
// a legitimately slow multi-hundred-MB transfer still completes.
let result = match tokio::time::timeout(
MAX_JOB_DURATION,
run_flash(board, family, &data_dir, &path, &bg_job),
)
.await
{
Ok(inner) => inner,
Err(_) => Err(anyhow::anyhow!(
"Flash job exceeded the {}-minute ceiling — aborted",
MAX_JOB_DURATION.as_secs() / 60
)),
};
let succeeded = result.is_ok();
match &result {
Ok(()) => {
bg_job.push_log("Flash completed successfully".to_string()).await;
bg_job.finish().await;
info!(path = %path, board = ?board, family = %family, "LoRa firmware flash succeeded");
}
Err(e) => {
// {:#} (alternate Display) walks the full anyhow context
// chain — plain {} / %e only prints the outermost .context()
// message, which made a real 2026-07-23 esptool failure
// undiagnosable from journalctl alone (just "esptool
// erase_flash failed", no actual esptool stderr).
warn!(path = %path, error = %format!("{e:#}"), "LoRa firmware flash failed");
bg_job.push_log(format!("ERROR: {e:#}")).await;
bg_job.fail(e).await;
}
}
// The board's firmware may now differ from whatever was pinned
// before — clear the pin either way so a later reconnect's strict
// auto-detect order picks up reality instead of getting wedged
// trying the old protocol first.
if let Ok(mut config) = super::load_config(&data_dir).await {
config.device_kind = None;
if let Err(e) = super::save_config(&data_dir, &config).await {
warn!(error = %e, "Failed to clear device_kind pin after flash");
}
}
if !succeeded {
// Deliberately do NOT auto-restart the listener here. A failed
// flash means we can't vouch for the board's state — reopening
// the port immediately (esptool/rnodeconf's own reset sequence
// plus our open() toggling DTR/RTS again right after) risks
// hammering a marginal device with reconnect attempts. Confirmed
// live 2026-07-23: exactly this sequence left a real Heltec V3
// boot-looping for 5+ minutes after a failed flash. Leave mesh
// stopped; the user reconnects explicitly via the hot-swap
// modal/Mesh page once they've confirmed the board is alive.
warn!(
path = %path,
"Leaving mesh listener stopped after failed flash — reconnect manually once the board is confirmed responsive"
);
return;
}
// On success, give the board a moment to finish booting after the
// flash tool's own reset sequence before we start hammering it with
// connection attempts — same reasoning as above, just the
// lower-risk (successful-flash) side of it.
tokio::time::sleep(POST_FLASH_SETTLE_DELAY).await;
let mut svc = bg_service.write().await;
if let Some(s) = svc.as_mut() {
match super::load_config(&data_dir).await {
Ok(config) => {
// Only resume if mesh is actually still enabled per the
// CURRENT persisted config — confirmed live 2026-07-23:
// unconditionally forcing a restart here, regardless of
// `enabled`, overrode a user's own concurrent "disable
// mesh" toggle and left the listener running while
// config said disabled. That inconsistent state is what
// made a later legitimate "Keep As Is" click (which
// correctly tries to start on a false→true transition)
// fail with "already running" — the listener had already
// been force-started behind the config's back.
let should_run = config.enabled;
if let Err(e) = s.configure(config).await {
warn!(error = %e, "Failed to resume mesh listener after flash");
}
if should_run {
if let Err(e) = s.start() {
warn!(error = %e, "Failed to restart mesh listener after flash");
}
}
}
Err(e) => warn!(error = %e, "Failed to load mesh config after flash"),
}
}
});
*job.abort_handle.write().await = Some(task.abort_handle());
Ok(())
}
async fn run_flash(
board: FlashBoard,
family: DeviceType,
data_dir: &Path,
path: &str,
job: &Arc<FlashJob>,
) -> Result<()> {
match family {
DeviceType::Meshtastic | DeviceType::Meshcore => {
let image = fetch_esptool_image(board, family, data_dir, job).await?;
esptool_erase_and_write(path, &image, job).await
}
DeviceType::Reticulum => {
let lora_region = super::load_config(data_dir)
.await
.ok()
.and_then(|c| c.lora_region);
rnodeconf_autoinstall(path, board, lora_region.as_deref(), job).await
}
DeviceType::Unknown => anyhow::bail!("Pick a firmware family before flashing"),
}
}
// ─── MeshCore / Meshtastic: esptool ─────────────────────────────────────
async fn fetch_esptool_image(
board: FlashBoard,
family: DeviceType,
data_dir: &Path,
job: &Arc<FlashJob>,
) -> Result<PathBuf> {
let cache = firmware_cache_dir(data_dir);
tokio::fs::create_dir_all(&cache)
.await
.context("Creating firmware cache dir")?;
let client = github_client()?;
match family {
DeviceType::Meshtastic => fetch_meshtastic_image(&client, board, &cache, job).await,
DeviceType::Meshcore => fetch_meshcore_image(&client, board, &cache, job).await,
_ => anyhow::bail!("{family} is not flashed via esptool"),
}
}
async fn fetch_meshtastic_image(
client: &reqwest::Client,
board: FlashBoard,
cache: &Path,
job: &Arc<FlashJob>,
) -> Result<PathBuf> {
let release: GithubRelease = client
.get("https://api.github.com/repos/meshtastic/firmware/releases/latest")
.timeout(METADATA_TIMEOUT)
.send()
.await
.context("Fetching Meshtastic release list")?
.error_for_status()
.context("Meshtastic releases API error")?
.json()
.await
.context("Parsing Meshtastic release JSON")?;
// Meshtastic bundles all esp32s3 boards' images inside one per-platform
// zip rather than shipping per-board top-level assets — both Heltec V3
// and V4 are esp32s3, so this is the right zip for both (confirmed live
// against v2.7.26.54e0d8d).
let zip_asset = release
.assets
.iter()
.find(|a| a.name.starts_with("firmware-esp32s3-") && a.name.ends_with(".zip"))
.ok_or_else(|| anyhow::anyhow!("No esp32s3 firmware zip in latest Meshtastic release"))?;
let version = zip_asset
.name
.strip_prefix("firmware-esp32s3-")
.and_then(|s| s.strip_suffix(".zip"))
.ok_or_else(|| anyhow::anyhow!("Unexpected Meshtastic asset name: {}", zip_asset.name))?
.to_string();
let zip_path = cache.join(&zip_asset.name);
if tokio::fs::metadata(&zip_path).await.is_err() {
download_to_file(client, &zip_asset.browser_download_url, &zip_path, job).await?;
} else {
job.push_log(format!("Using cached {}", zip_asset.name)).await;
}
// "*.factory.bin" is Meshtastic's full merged image (bootloader +
// partition table + app) meant to be written at offset 0x0 on a freshly
// erased chip — confirmed by inspecting the real zip's contents, as
// opposed to the plain "*.bin" OTA-update image which assumes an
// existing bootloader/partition table already on the chip.
let entry_name = format!(
"firmware-{}-{}.factory.bin",
board.meshtastic_id(),
version
);
let out_path = cache.join(&entry_name);
if tokio::fs::metadata(&out_path).await.is_ok() {
return Ok(out_path);
}
job.push_log(format!(
"Extracting {entry_name} from {}",
zip_asset.name
))
.await;
let zip_path_owned = zip_path.clone();
let entry_name_owned = entry_name.clone();
let out_path_owned = out_path.clone();
tokio::task::spawn_blocking(move || -> Result<()> {
let file = std::fs::File::open(&zip_path_owned).context("Opening downloaded firmware zip")?;
let mut archive = zip::ZipArchive::new(file).context("Reading firmware zip")?;
let mut entry = archive
.by_name(&entry_name_owned)
.with_context(|| format!("{entry_name_owned} not found in firmware zip"))?;
let mut out =
std::fs::File::create(&out_path_owned).context("Creating extracted firmware file")?;
std::io::copy(&mut entry, &mut out).context("Extracting firmware image")?;
Ok(())
})
.await
.context("Firmware extraction task panicked")??;
Ok(out_path)
}
async fn fetch_meshcore_image(
client: &reqwest::Client,
board: FlashBoard,
cache: &Path,
job: &Arc<FlashJob>,
) -> Result<PathBuf> {
let release: GithubRelease = client
.get("https://api.github.com/repos/meshcore-dev/MeshCore/releases/latest")
.timeout(METADATA_TIMEOUT)
.send()
.await
.context("Fetching MeshCore release list")?
.error_for_status()
.context("MeshCore releases API error")?
.json()
.await
.context("Parsing MeshCore release JSON")?;
// Upstream's casing differs between boards (Heltec_v3_... vs
// heltec_v4_...) — match case-insensitively on the exact per-board
// substring so V4 isn't accidentally matched by "heltec_v4_tft_..."
// variants (there's a "_tft_" in between, so a straight substring match
// on "heltec_v4_companion_radio_usb" is already safe).
let needle = match board {
FlashBoard::HeltecV3 => "heltec_v3_companion_radio_usb",
FlashBoard::HeltecV4 => "heltec_v4_companion_radio_usb",
};
let asset = release
.assets
.iter()
.find(|a| {
let lower = a.name.to_lowercase();
lower.contains(needle) && lower.ends_with("-merged.bin")
})
.ok_or_else(|| {
anyhow::anyhow!("No matching MeshCore image in release {}", release.tag_name)
})?;
let out_path = cache.join(&asset.name);
if tokio::fs::metadata(&out_path).await.is_ok() {
job.push_log(format!("Using cached {}", asset.name)).await;
return Ok(out_path);
}
download_to_file(client, &asset.browser_download_url, &out_path, job).await?;
Ok(out_path)
}
async fn download_to_file(
client: &reqwest::Client,
url: &str,
dest: &Path,
job: &Arc<FlashJob>,
) -> Result<()> {
job.set_stage(FlashStage::Downloading).await;
// Bound only the wait for the response to *start* (headers) — NOT a
// request-level `.timeout()`, which would cap the whole body transfer
// again (the bug this replaced: a blanket 30s client timeout killed
// large downloads mid-stream). If the server never responds at all,
// this is what stops the job from hanging forever; the per-chunk stall
// timeout below is what guards the body once streaming starts. Without
// this, a server that accepts the TCP connection but never sends
// headers back hangs this call indefinitely — confirmed live
// 2026-07-23: a stuck `.send()` here wedged the single-flash-job guard
// for good, permanently blocking every subsequent flash attempt with
// "already in progress" until the service was restarted.
let resp = tokio::time::timeout(METADATA_TIMEOUT, client.get(url).send())
.await
.context("Firmware download server did not respond")?
.context("Starting firmware download")?
.error_for_status()
.context("Firmware download returned an error status")?;
let total = resp.content_length();
let tmp = dest.with_extension("part");
let mut file = tokio::fs::File::create(&tmp)
.await
.context("Creating firmware download file")?;
let mut stream = resp.bytes_stream();
let mut downloaded: u64 = 0;
use futures_util::StreamExt;
loop {
let next = tokio::time::timeout(DOWNLOAD_STALL_TIMEOUT, stream.next())
.await
.context("Firmware download stalled")?;
let Some(chunk) = next else { break };
let chunk = chunk.context("Reading firmware download stream")?;
file.write_all(&chunk)
.await
.context("Writing firmware download")?;
downloaded += chunk.len() as u64;
if let Some(total) = total {
if total > 0 {
job.set_percent(((downloaded.saturating_mul(100)) / total) as u8)
.await;
}
}
}
file.flush().await.ok();
tokio::fs::rename(&tmp, dest)
.await
.context("Finalizing firmware download")?;
job.push_log(format!(
"Downloaded {} ({downloaded} bytes)",
dest.display()
))
.await;
Ok(())
}
/// Both Heltec V3 and V4 are ESP32-S3 boards.
const ESPTOOL_CHIP: &str = "esp32s3";
/// esptool's auto-reset-into-bootloader handshake (toggling DTR/RTS in a
/// specific timed pattern) is well-known to be flaky on some CP2102/CH340
/// board+adapter combinations — esptool's own docs recommend retrying at a
/// lower baud rate when this happens. Rather than fail the whole job on the
/// first hiccup, retry once at a conservative baud before giving up.
const ESPTOOL_FALLBACK_BAUD: &str = "115200";
/// `write_flash --erase-all` erases the whole chip before writing, in one
/// esptool invocation. This needs the esp32s3 stub flasher loaded (see
/// esptool_global_args' doc comment) — without it, --erase-all hits the
/// exact same ROM limitation a standalone `erase_flash` does ("ESP32-S3 ROM
/// does not support function erase_flash", confirmed live 2026-07-23), since
/// esptool's --erase-all is implemented as the same full-chip-erase command,
/// not a per-sector loop.
async fn esptool_erase_and_write(path: &str, image: &Path, job: &Arc<FlashJob>) -> Result<()> {
job.set_stage(FlashStage::Writing).await;
let image_str = image.to_string_lossy().to_string();
esptool_with_retry(
path,
&["write_flash", "--erase-all", "0x0", &image_str],
job,
)
.await
.context("esptool write_flash failed")?;
Ok(())
}
/// esptool's global flags (--chip/--port/--baud) MUST precede the subcommand
/// token (erase_flash/write_flash/...) — confirmed live 2026-07-23:
/// appending `--baud 115200` after the subcommand on the retry path
/// produced "esptool: error: unrecognized arguments: --baud 115200" every
/// time, so the fallback-baud retry never actually got a chance to run.
/// Building global args separately from subcommand args keeps this correct
/// by construction instead of relying on call-site ordering.
///
/// Normal stub-loader mode (no --no-stub) needs the esp32s3 stub flasher
/// blob at /usr/lib/python3/dist-packages/esptool/targets/stub_flasher/
/// stub_flasher_32s3.json — Debian's `esptool` package (4.7.0+dfsg-0.1)
/// ships without it (stripped for DFSG compliance: the prebuilt blob has no
/// buildable-from-source path Debian could verify), so scripts/self-update.sh
/// fetches the exact same file from the matching upstream esptool release
/// tag and installs it alongside the apt package (see the esptool install
/// step there). --no-stub (talk directly to the ROM bootloader, skip the
/// stub) was tried first and works for connecting, but the ROM bootloader
/// doesn't implement a full-chip-erase opcode at all — only the stub does —
/// so --no-stub broke our "always erase before write" default outright
/// rather than just being slower. Restoring the real stub file is the
/// correct fix, not routing around its absence.
fn esptool_global_args<'a>(path: &'a str, baud: Option<&'a str>) -> Vec<&'a str> {
let mut args = vec!["--chip", ESPTOOL_CHIP, "--port", path];
if let Some(b) = baud {
args.push("--baud");
args.push(b);
}
args
}
async fn esptool_with_retry(path: &str, subcommand: &[&str], job: &Arc<FlashJob>) -> Result<()> {
let mut cmd = Command::new("esptool");
cmd.args(esptool_global_args(path, None));
cmd.args(subcommand);
match run_streamed(cmd, None, job).await {
Ok(()) => Ok(()),
Err(first_err) => {
job.push_log(format!(
"First attempt failed ({first_err:#}); retrying once at {ESPTOOL_FALLBACK_BAUD} baud"
))
.await;
let mut retry = Command::new("esptool");
retry.args(esptool_global_args(path, Some(ESPTOOL_FALLBACK_BAUD)));
retry.args(subcommand);
run_streamed(retry, None, job)
.await
.context(format!("retry also failed (first attempt: {first_err:#})"))
}
}
}
// ─── Reticulum/RNode: archy-rnodeconf ───────────────────────────────────
fn rnodeconf_bin() -> String {
std::env::var("ARCHY_RNODECONF_BIN")
.unwrap_or_else(|_| "/usr/local/bin/archy-rnodeconf".to_string())
}
/// `--autoinstall`'s "which board is this" step is interactive by design —
/// confirmed live against a real Heltec V4 (2026-07-23): even with a board
/// given on the command line, rnodeconf can't always tell V3 from V4 apart
/// (their bootstrap-time USB identity is often generic, same root cause as
/// `resolve_flash_board`'s doc comment), so it always asks. The full prompt
/// sequence observed for a Heltec board that already has *some* RNode
/// firmware installed (the common case — a truly blank chip likely skips
/// straight to the same "Device Selection" menu):
/// 1. numbered device-type menu → answer with the menu number
/// 2. "Hit enter to continue" → answer with a blank line
/// 3. numbered band menu → answer with the menu number
/// 4. "Is the above correct? [y/N]" → answer "y"
/// Feeding all four answers up front (rather than watching stdout for each
/// prompt text) works because the menu is always asked in this fixed order
/// for every board that needs (re)provisioning — verified by driving it
/// through an unprovisioned real V4 end-to-end (erase → flash → EEPROM
/// bootstrap → "Device signature validated" on the next probe).
fn rnodeconf_device_menu_number(board: FlashBoard) -> &'static str {
match board {
FlashBoard::HeltecV3 => "8",
FlashBoard::HeltecV4 => "9",
}
}
/// rnodeconf's band choice is a coarse RF-frontend bootstrap parameter
/// (868/915/923 MHz), not the final operating frequency — that's still
/// configured later via the daemon's interface config, same as today. This
/// is a best-effort mapping from the node's persisted Meshtastic-style
/// region code (see `mesh::meshtastic::region_name_to_code`) down to
/// rnodeconf's 3-way menu; regions with no exact 868/923 match fall back to
/// 915 MHz as the broadest-compatibility default.
fn rnodeconf_band_menu_number(lora_region: Option<&str>) -> &'static str {
match lora_region.map(|s| s.trim().to_uppercase()) {
Some(r) if r.contains("868") => "1",
Some(r) if r.contains("923") => "3",
_ => "2",
}
}
/// `--autoinstall` fetches, erases, flashes, and bootstraps the EEPROM for
/// a detected board as one atomic step (confirmed via `archy-rnodeconf
/// --help` AND a real end-to-end flash on real hardware) — this is the
/// RNode-side equivalent of our "always erase before write" default, since
/// autoinstall doesn't try to preserve any existing on-device state.
async fn rnodeconf_autoinstall(
path: &str,
board: FlashBoard,
lora_region: Option<&str>,
job: &Arc<FlashJob>,
) -> Result<()> {
job.set_stage(FlashStage::Autoinstalling).await;
let bin = rnodeconf_bin();
let mut cmd = if Path::new(&bin).exists() {
Command::new(bin)
} else {
// Dev fallback if only a plain venv/system rnodeconf is on PATH.
Command::new("rnodeconf")
};
cmd.args(["--autoinstall", path]);
let stdin = format!(
"{}\n\n{}\ny\n",
rnodeconf_device_menu_number(board),
rnodeconf_band_menu_number(lora_region)
);
run_streamed(cmd, Some(stdin.into_bytes()), job)
.await
.context("archy-rnodeconf --autoinstall failed")
}
// ─── Subprocess streaming ────────────────────────────────────────────────
fn percent_regex() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"\((\d{1,3})\s*%\)").expect("valid regex"))
}
async fn run_streamed(mut cmd: Command, stdin: Option<Vec<u8>>, job: &Arc<FlashJob>) -> Result<()> {
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
if stdin.is_some() {
cmd.stdin(Stdio::piped());
}
// Deliberately NOT kill_on_drop: an interrupted erase/write can leave
// the chip in a worse state than either finished or unstarted (see the
// cancellation-safety note in mesh flashing docs). The job is expected
// to run to completion or fail on its own.
let mut child = cmd.spawn().context("Failed to start subprocess")?;
if let Some(bytes) = stdin {
if let Some(mut child_stdin) = child.stdin.take() {
child_stdin
.write_all(&bytes)
.await
.context("Writing to subprocess stdin")?;
}
}
let mut tasks = Vec::new();
if let Some(stdout) = child.stdout.take() {
let job = Arc::clone(job);
tasks.push(tokio::spawn(async move {
let mut lines = BufReader::new(stdout).lines();
while let Ok(Some(line)) = lines.next_line().await {
if let Some(cap) = percent_regex().captures(&line) {
if let Ok(pct) = cap[1].parse::<u8>() {
job.set_percent(pct).await;
}
}
job.push_log(line).await;
}
}));
}
if let Some(stderr) = child.stderr.take() {
let job = Arc::clone(job);
tasks.push(tokio::spawn(async move {
let mut lines = BufReader::new(stderr).lines();
while let Ok(Some(line)) = lines.next_line().await {
job.push_log(line).await;
}
}));
}
let status = child.wait().await.context("Waiting for subprocess")?;
for t in tasks {
let _ = t.await;
}
if !status.success() {
// Exit status alone isn't diagnosable — the actual esptool/rnodeconf
// stderr (already captured into job.log_tail by the reader tasks
// above) is what actually explains a failure. Confirmed live
// 2026-07-23: a bare "Command exited with exit status: 1" told us
// nothing when esptool's real error was sitting in the log tail the
// whole time, only visible via the UI's live poll, not journald.
let tail: Vec<String> = job
.snapshot()
.await
.log_tail
.iter()
.rev()
.take(10)
.rev()
.cloned()
.collect();
anyhow::bail!("Command exited with {status}\n{}", tail.join("\n"));
}
Ok(())
}
+19 -1
View File
@@ -578,7 +578,7 @@ pub(super) async fn handle_identity_received(
.insert(contact_id, shared_secret); .insert(contact_id, shared_secret);
// Update peer record // Update peer record
let peer = MeshPeer { let mut peer = MeshPeer {
contact_id, contact_id,
// .get(): a malformed DID shorter than the "did:key:" prefix must // .get(): a malformed DID shorter than the "did:key:" prefix must
// not panic the listener on a radio-supplied string. // not panic the listener on a radio-supplied string.
@@ -607,6 +607,24 @@ pub(super) async fn handle_identity_received(
let is_new = { let is_new = {
let mut peers = state.peers.write().await; let mut peers = state.peers.write().await;
let is_new = !peers.contains_key(&contact_id); 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()); peers.insert(contact_id, peer.clone());
is_new is_new
}; };
+43
View File
@@ -557,6 +557,10 @@ pub fn spawn_mesh_listener(
let mut shutdown = shutdown; let mut shutdown = shutdown;
let mut cmd_rx = cmd_rx; let mut cmd_rx = cmd_rx;
let mut reconnect_delay = RECONNECT_DELAY_INIT; let mut reconnect_delay = RECONNECT_DELAY_INIT;
// Mutable so a successful auto-detect can pin the firmware kind for
// the rest of this listener's lifetime — see the pin-on-first-success
// block below for why.
let mut device_kind = device_kind;
// Backlog #12 hot-swap re-binding: each run_mesh_session call already // Backlog #12 hot-swap re-binding: each run_mesh_session call already
// builds a fresh device struct (contacts/current_region/etc. all // builds a fresh device struct (contacts/current_region/etc. all
// start empty), so per-device session state is naturally isolated // start empty), so per-device session state is naturally isolated
@@ -628,6 +632,45 @@ pub fn spawn_mesh_listener(
} }
} }
// Pin the firmware kind after the first successful auto-detect.
// Confirmed live 2026-07-23: with device_kind left unpinned (e.g.
// after clearing a stale pin), EVERY reconnect re-runs the full
// Reticulum→Meshcore→Meshtastic auto-detect cascade — each
// candidate past the first does its own open() with the DTR/RTS
// reset both boards need, so a device correctly identified as
// Meshtastic still gets reset once for the failed Meshcore
// attempt before Meshtastic's own open() resets it again. That
// doubled the reset count on every single reconnect indefinitely,
// not just during initial detection. Once auto-detect has
// identified the device this listener is actually talking to,
// there's no reason to keep guessing on subsequent reconnects —
// pin it, both in this task's own loop (takes effect
// immediately) and on disk (survives a service restart). A
// genuine hot-swap to different firmware is still handled: the
// setup modal's `mesh.probe-device` always re-probes unpinned,
// and the flash flow already clears this pin on its own.
if device_kind.is_none() {
let detected = state.status.read().await.device_type;
if detected != super::types::DeviceType::Unknown {
device_kind = Some(detected);
match super::load_config(&data_dir).await {
Ok(mut cfg) if cfg.device_kind.is_none() => {
cfg.device_kind = Some(detected);
if let Err(e) = super::save_config(&data_dir, &cfg).await {
warn!("Failed to persist auto-detected device_kind: {}", e);
} else {
info!(
kind = %detected,
"Pinned auto-detected firmware kind to avoid repeated multi-protocol resets on reconnect"
);
}
}
Ok(_) => {}
Err(e) => warn!("Failed to load mesh config to persist device_kind: {}", e),
}
}
}
// Update status to disconnected. device_type/firmware_version are // Update status to disconnected. device_type/firmware_version are
// reset too — they were previously left holding the LAST radio's // reset too — they were previously left holding the LAST radio's
// identity, so after a hot-swap the UI showed the old firmware // identity, so after a hot-swap the UI showed the old firmware
+90 -53
View File
@@ -270,6 +270,7 @@ async fn auto_detect_and_open(
our_x25519_pubkey_hex: &str, our_x25519_pubkey_hex: &str,
device_kind: Option<DeviceType>, device_kind: Option<DeviceType>,
skip_path: Option<&str>, skip_path: Option<&str>,
advert_name: Option<&str>,
) -> Result<(String, MeshRadioDevice, DeviceInfo)> { ) -> Result<(String, MeshRadioDevice, DeviceInfo)> {
let mut paths = super::super::serial::detect_serial_devices().await; let mut paths = super::super::serial::detect_serial_devices().await;
// When falling back from a just-failed preferred path, don't probe that // When falling back from a just-failed preferred path, don't probe that
@@ -287,6 +288,7 @@ async fn auto_detect_and_open(
None => "No serial devices found in /dev".to_string(), None => "No serial devices found in /dev".to_string(),
}); });
} }
info!(candidates = ?paths, "Auto-detect candidate ports for this attempt");
for path in &paths { for path in &paths {
debug!(path = %path, "Probing for mesh radio device"); debug!(path = %path, "Probing for mesh radio device");
// Tried FIRST: `ReticulumLink::open()` gates its expensive daemon // Tried FIRST: `ReticulumLink::open()` gates its expensive daemon
@@ -304,6 +306,7 @@ async fn auto_detect_and_open(
data_dir, data_dir,
Some(our_ed_pubkey_hex), Some(our_ed_pubkey_hex),
Some(our_x25519_pubkey_hex), Some(our_x25519_pubkey_hex),
advert_name,
) )
.await .await
{ {
@@ -465,6 +468,7 @@ async fn open_preferred_path(
our_ed_pubkey_hex: &str, our_ed_pubkey_hex: &str,
our_x25519_pubkey_hex: &str, our_x25519_pubkey_hex: &str,
device_kind: Option<DeviceType>, device_kind: Option<DeviceType>,
advert_name: Option<&str>,
) -> Result<(MeshRadioDevice, DeviceInfo)> { ) -> Result<(MeshRadioDevice, DeviceInfo)> {
// Pinned: try only the configured firmware and surface its own error — // Pinned: try only the configured firmware and surface its own error —
// never fall through to (and inject probe bytes into) another firmware's // never fall through to (and inject probe bytes into) another firmware's
@@ -497,6 +501,7 @@ async fn open_preferred_path(
data_dir, data_dir,
Some(our_ed_pubkey_hex), Some(our_ed_pubkey_hex),
Some(our_x25519_pubkey_hex), Some(our_x25519_pubkey_hex),
advert_name,
) )
.await .await
.context("Could not open preferred path as Reticulum")?; .context("Could not open preferred path as Reticulum")?;
@@ -510,40 +515,21 @@ async fn open_preferred_path(
}; };
} }
// Reticulum first — see the matching comment on auto_detect_and_open: // Unpinned: don't probe this path ourselves at all. Confirmed live
// its cheap probe_rnode gate fails in ~1s for non-RNode firmware, while // 2026-07-23 — this function used to run its own Reticulum→Meshcore→
// trying Meshcore/Meshtastic first was observed leaving a real RNode // Meshtastic sequence here, and the caller (run_mesh_session) falls
// board unresponsive by the time Reticulum's turn came. // back to `auto_detect_and_open` on any error, which scans every
match ReticulumLink::open( // candidate path (this one included) with the exact same three-protocol
path, // sequence. With a single physical radio — the overwhelmingly common
data_dir, // case — `path` here IS the one candidate `auto_detect_and_open` is
Some(our_ed_pubkey_hex), // about to try, so every unpinned reconnect was resetting the board via
Some(our_x25519_pubkey_hex), // Reticulum/Meshcore/Meshtastic's DTR/RTS toggle TWICE: once here, once
) // again moments later in auto-detect. Bailing immediately (no port
.await // access at all) means auto-detect's single pass is the only one that
{ // ever touches the port when nothing is pinned yet. (auto_detect_and_open
Ok(mut dev) => match dev.initialize().await { // carries the advert_name threading from main, so nothing is lost.)
Ok(info) => return Ok((MeshRadioDevice::Reticulum(dev), info)), let _ = advert_name;
Err(e) => { anyhow::bail!("No device_kind pin — deferring to auto-detect for {path}")
debug!(path = %path, error = %e, "Preferred path is not a working Reticulum RNode")
}
},
Err(e) => debug!(path = %path, error = %e, "Could not open preferred path as Reticulum"),
}
match MeshcoreDevice::open(path).await {
Ok(mut dev) => match dev.initialize().await {
Ok(info) => return Ok((MeshRadioDevice::Meshcore(dev), info)),
Err(e) => debug!(path = %path, error = %e, "Preferred path is not Meshcore"),
},
Err(e) => debug!(path = %path, error = %e, "Could not open preferred path as Meshcore"),
}
match MeshtasticDevice::open(path).await {
Ok(mut dev) => match dev.initialize().await {
Ok(info) => Ok((MeshRadioDevice::Meshtastic(dev), info)),
Err(e) => Err(e).context("Preferred path is not a working Meshtastic device"),
},
Err(e) => Err(e).context("Could not open preferred path as Meshtastic"),
}
} }
/// Bring up a Reticulum daemon over plain TCP — no physical RNode, no /// Bring up a Reticulum daemon over plain TCP — no physical RNode, no
@@ -556,6 +542,7 @@ async fn open_reticulum_tcp(
data_dir: &Path, data_dir: &Path,
our_ed_pubkey_hex: &str, our_ed_pubkey_hex: &str,
our_x25519_pubkey_hex: &str, our_x25519_pubkey_hex: &str,
advert_name: Option<&str>,
) -> Result<(String, MeshRadioDevice, DeviceInfo)> { ) -> Result<(String, MeshRadioDevice, DeviceInfo)> {
let mut dev = match cfg { let mut dev = match cfg {
ReticulumTcpConfig::Server { bind } => ReticulumLink::open_tcp_server( ReticulumTcpConfig::Server { bind } => ReticulumLink::open_tcp_server(
@@ -563,6 +550,7 @@ async fn open_reticulum_tcp(
data_dir, data_dir,
Some(our_ed_pubkey_hex), Some(our_ed_pubkey_hex),
Some(our_x25519_pubkey_hex), Some(our_x25519_pubkey_hex),
advert_name,
) )
.await .await
.context("Could not open Reticulum TCP server interface")?, .context("Could not open Reticulum TCP server interface")?,
@@ -571,6 +559,7 @@ async fn open_reticulum_tcp(
data_dir, data_dir,
Some(our_ed_pubkey_hex), Some(our_ed_pubkey_hex),
Some(our_x25519_pubkey_hex), Some(our_x25519_pubkey_hex),
advert_name,
) )
.await .await
.context("Could not open Reticulum TCP client interface")?, .context("Could not open Reticulum TCP client interface")?,
@@ -987,12 +976,41 @@ pub(super) async fn run_mesh_session(
// auto-detect. TCP mode is additive/dev-only; it never changes behavior // auto-detect. TCP mode is additive/dev-only; it never changes behavior
// for existing serial/RNode deployments where `reticulum_tcp` is None. // 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 // 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 // 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. // doc comment). Held only until the device is opened, then released.
//
// 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 port_guard = PORT_OPEN_LOCK.lock().await;
let (device_path, mut device, device_info) = if let Some(tcp_cfg) = &reticulum_tcp { let result = if let Some(tcp_cfg) = &reticulum_tcp {
open_reticulum_tcp(tcp_cfg, data_dir, our_ed_pubkey_hex, our_x25519_pubkey_hex).await? 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 { } else if let Some(path) = preferred_path {
match open_preferred_path( match open_preferred_path(
path, path,
@@ -1000,10 +1018,11 @@ pub(super) async fn run_mesh_session(
our_ed_pubkey_hex, our_ed_pubkey_hex,
our_x25519_pubkey_hex, our_x25519_pubkey_hex,
device_kind, device_kind,
Some(&desired_advert_name),
) )
.await .await
{ {
Ok((dev, info)) => (path.to_string(), dev, info), Ok((dev, info)) => Ok((path.to_string(), dev, info)),
Err(e) => { Err(e) => {
warn!( warn!(
"Preferred path {} probe failed: {} — trying auto-detect", "Preferred path {} probe failed: {} — trying auto-detect",
@@ -1015,8 +1034,9 @@ pub(super) async fn run_mesh_session(
our_x25519_pubkey_hex, our_x25519_pubkey_hex,
device_kind, device_kind,
Some(path), Some(path),
Some(&desired_advert_name),
) )
.await? .await
} }
} }
} else { } else {
@@ -1026,10 +1046,23 @@ pub(super) async fn run_mesh_session(
our_x25519_pubkey_hex, our_x25519_pubkey_hex,
device_kind, device_kind,
None, None,
Some(&desired_advert_name),
) )
.await? .await
}; };
drop(port_guard); 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");
}
};
// Update status // Update status
{ {
@@ -1176,19 +1209,14 @@ pub(super) async fn run_mesh_session(
} }
} }
// Set advert name to the server's human-readable name (e.g. "ThinkPad"), // Set advert name to the configured mesh/server name (computed above).
// falling back to the DID fragment if no name is configured. Skipped in // Skipped in keep-as-is mode for radio-held names — the radio keeps the
// keep-as-is mode — the radio keeps the name it came with (already // name it came with (already reflected in status from the connect
// reflected in status from the connect handshake). // handshake). Reticulum is exempt from keep-as-is: its display name
if manage_radio { // lives in OUR daemon (the RNode holds no name), so "keep as is" has
let advert_name = if let Some(name) = server_name { // nothing to preserve and an unnamed node would be anonymous on RNS.
// Meshcore firmware limits advert names — truncate to 20 chars if manage_radio || matches!(device, MeshRadioDevice::Reticulum(_)) {
name.chars().take(20).collect::<String>() if let Err(e) = device.set_advert_name(&desired_advert_name).await {
} 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 {
warn!("Failed to set advert name: {}", e); warn!("Failed to set advert name: {}", e);
} else { } else {
// Reflect the post-set name in MeshStatus too so the UI can filter // Reflect the post-set name in MeshStatus too so the UI can filter
@@ -1196,7 +1224,7 @@ pub(super) async fn run_mesh_session(
// still carries whatever pre-set name the firmware reported and the // still carries whatever pre-set name the firmware reported and the
// self-filter never matches. // self-filter never matches.
let mut status = state.status.write().await; 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 +1517,15 @@ async fn handle_send_command(
} else { } else {
*consecutive_write_failures = 0; *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 } => { MeshCommand::RebootRadio { seconds } => {
if let Err(e) = device.reboot(seconds).await { if let Err(e) = device.reboot(seconds).await {
+166 -12
View File
@@ -8,6 +8,7 @@
pub mod alerts; pub mod alerts;
pub mod bitcoin_relay; pub mod bitcoin_relay;
pub mod crypto; pub mod crypto;
pub mod flash;
pub mod listener; pub mod listener;
pub mod meshtastic; pub mod meshtastic;
pub mod message_types; pub mod message_types;
@@ -477,6 +478,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> { pub async fn load_config(data_dir: &Path) -> Result<MeshConfig> {
let path = data_dir.join(MESH_CONFIG_FILE); let path = data_dir.join(MESH_CONFIG_FILE);
if !path.exists() { if !path.exists() {
@@ -742,10 +768,18 @@ impl MeshService {
self.server_name = name; self.server_name = name;
} }
/// Start the background mesh listener. /// Start the background mesh listener. Idempotent: if the listener is
/// already running, this is a harmless no-op rather than an error —
/// confirmed live 2026-07-23, a real race between the flash job's own
/// post-flash restart and a concurrent user "Keep As Is" click (both
/// legitimately trying to ensure the listener is running) surfaced this
/// as a user-facing "Mesh listener already running" RPC error. Ensuring
/// the listener is running is the intent every caller actually has;
/// whichever caller's start() happens to win the race, the other
/// finding it already satisfied is success, not failure.
pub fn start(&mut self) -> Result<()> { pub fn start(&mut self) -> Result<()> {
if self.listener_handle.is_some() { if self.listener_handle.is_some() {
anyhow::bail!("Mesh listener already running"); return Ok(());
} }
let (shutdown_tx, shutdown_rx) = watch::channel(false); let (shutdown_tx, shutdown_rx) = watch::channel(false);
@@ -764,7 +798,11 @@ impl MeshService {
self.our_ed_pubkey_hex.clone(), self.our_ed_pubkey_hex.clone(),
self.our_x25519_secret, self.our_x25519_secret,
self.our_x25519_pubkey_hex.clone(), 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_region.clone(),
self.config.lora_radio_params, self.config.lora_radio_params,
self.config.channel_name.clone(), self.config.channel_name.clone(),
@@ -1055,13 +1093,12 @@ impl MeshService {
self.state.peers.read().await.values().cloned().collect() self.state.peers.read().await.values().cloned().collect()
} }
/// Probe a serial port for a mesh radio without provisioning or keeping /// Refuse to probe the port the live session currently occupies (the
/// it — powers the hot-swap "device detected" modal's current-details /// probe would steal the serial port from under the session); a
/// view. Refuses to probe the port the live session currently occupies
/// (the probe would steal the serial port from under the session); a
/// detected-but-not-connected port is fair game, accepting a benign race /// detected-but-not-connected port is fair game, accepting a benign race
/// with the reconnect loop (whichever loses just retries). /// with the reconnect loop (whichever loses just retries). Split out from
pub async fn probe_device(&self, path: &str) -> Result<listener::DeviceProbe> { /// the actual probe on purpose — see `probe_device`'s doc comment.
pub async fn ensure_probe_allowed(&self, path: &str) -> Result<()> {
let status = self.state.status.read().await; let status = self.state.status.read().await;
if status.device_connected { if status.device_connected {
if let Some(active) = status.device_path.as_deref() { if let Some(active) = status.device_path.as_deref() {
@@ -1076,8 +1113,7 @@ impl MeshService {
} }
} }
} }
drop(status); Ok(())
listener::probe_device(path).await
} }
/// Get message history. /// Get message history.
@@ -1124,7 +1160,32 @@ impl MeshService {
let peer = peers let peer = peers
.get(&contact_id) .get(&contact_id)
.ok_or_else(|| anyhow::anyhow!("Peer not found"))?; .ok_or_else(|| anyhow::anyhow!("Peer not found"))?;
let pubkey_hex = peer // Cross-transport twin resolution: callers frequently hold the
// FEDERATION twin's contact_id (the UI's merged conversation row),
// whose pubkey_hex is the Archipelago ed25519 key — NOT a radio
// routing key. Sending a Reticulum resource with that prefix fails
// with "Unknown Reticulum prefix" (observed live 2026-07-28,
// image-over-LoRa to a merged contact). Route via the radio twin —
// same arch identity, radio-range id — whose pubkey_hex is the
// actual over-the-air routing key (RNS dest hash / firmware key).
let radio_peer = if peer.contact_id >= FEDERATION_CONTACT_ID_BASE {
peer.arch_pubkey_hex
.as_deref()
.and_then(|arch| {
peers.values().find(|p| {
p.contact_id < FEDERATION_CONTACT_ID_BASE
&& p.arch_pubkey_hex.as_deref() == Some(arch)
})
})
.ok_or_else(|| {
anyhow::anyhow!(
"Peer is federation-only (no radio twin) — not reachable over the radio"
)
})?
} else {
peer
};
let pubkey_hex = radio_peer
.pubkey_hex .pubkey_hex
.as_ref() .as_ref()
.ok_or_else(|| anyhow::anyhow!("Peer has no public key"))?; .ok_or_else(|| anyhow::anyhow!("Peer has no public key"))?;
@@ -1262,12 +1323,44 @@ impl MeshService {
.map(|p| !p.reachable && p.arch_pubkey_hex.is_some()) .map(|p| !p.reachable && p.arch_pubkey_hex.is_some())
.unwrap_or(false) .unwrap_or(false)
}; };
// Transport policy: LoRa first when it can actually carry the message,
// then FIPS, then Tor. A federation-synthetic id (what the UI's merged
// conversation holds) used to ALWAYS take the federation path, even
// when the very same node was sitting one LoRa hop away — so chats
// between two radio-equipped nodes silently rode FIPS/Tor. If the
// federation contact has a REACHABLE radio twin (same archipelago
// identity, radio-range id) and the payload fits the radio, skip the
// federation branch: the fall-through LoRa path twin-resolves the
// routing key via peer_dest_prefix.
let device_connected = self.state.status.read().await.device_connected;
let radio_twin_reachable = is_federation_synthetic && !exceeds_lora && device_connected && {
let peers = self.state.peers.read().await;
peers
.get(&contact_id)
.and_then(|p| p.arch_pubkey_hex.clone())
.map(|arch| {
peers.values().any(|p| {
p.contact_id < FEDERATION_CONTACT_ID_BASE
&& p.reachable
&& p.arch_pubkey_hex.as_deref() == Some(arch.as_str())
})
})
.unwrap_or(false)
};
let mesh_only_mode = load_config(&self.data_dir) let mesh_only_mode = load_config(&self.data_dir)
.await .await
.ok() .ok()
.and_then(|cfg| cfg.mesh_only_mode) .and_then(|cfg| cfg.mesh_only_mode)
.unwrap_or(false); .unwrap_or(false);
if radio_twin_reachable && !mesh_only_mode {
tracing::info!(
contact_id,
bytes = wire.len(),
"Radio-first routing: federation contact has a reachable radio twin — sending over LoRa"
);
}
if !mesh_only_mode if !mesh_only_mode
&& !radio_twin_reachable
&& (is_federation_synthetic || exceeds_lora || radio_federated_unreachable) && (is_federation_synthetic || exceeds_lora || radio_federated_unreachable)
{ {
// Resolve the peer's pubkey/did. Prefer the live mesh peer table, // Resolve the peer's pubkey/did. Prefer the live mesh peer table,
@@ -2091,6 +2184,7 @@ impl MeshService {
save_config(&self.data_dir, &config).await?; save_config(&self.data_dir, &config).await?;
let was_enabled = self.config.enabled; let was_enabled = self.config.enabled;
let needs_session_restart = session_config_changed(&self.config, &config);
self.config = config.clone(); self.config = config.clone();
// Update the status to reflect new config // Update the status to reflect new config
@@ -2115,11 +2209,31 @@ impl MeshService {
status.firmware_version = None; status.firmware_version = None;
status.self_node_id = None; status.self_node_id = None;
status.peer_count = 0; 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(()) 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). /// Get a reference to shared state (for RPC handlers).
pub fn shared_state(&self) -> Arc<MeshState> { pub fn shared_state(&self) -> Arc<MeshState> {
Arc::clone(&self.state) Arc::clone(&self.state)
@@ -2241,6 +2355,46 @@ async fn bitcoin_rpc_getblockheader_by_height(
mod tests { mod tests {
use super::*; 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 { fn mk_peer(contact_id: u32, name: &str, arch: Option<&str>, reachable: bool) -> MeshPeer {
MeshPeer { MeshPeer {
contact_id, contact_id,
+247 -28
View File
@@ -121,6 +121,7 @@ fn daemon_command(
identity_key: &Path, identity_key: &Path,
archy_ed_pubkey_hex: Option<&str>, archy_ed_pubkey_hex: Option<&str>,
archy_x25519_pubkey_hex: Option<&str>, archy_x25519_pubkey_hex: Option<&str>,
display_name: Option<&str>,
) -> Command { ) -> Command {
let bin = std::env::var("ARCHY_RETICULUM_DAEMON_BIN") let bin = std::env::var("ARCHY_RETICULUM_DAEMON_BIN")
.unwrap_or_else(|_| "/usr/local/bin/archy-reticulum-daemon".to_string()); .unwrap_or_else(|_| "/usr/local/bin/archy-reticulum-daemon".to_string());
@@ -159,6 +160,15 @@ fn daemon_command(
.arg("--archy-x25519-pubkey-hex") .arg("--archy-x25519-pubkey-hex")
.arg(x); .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 // Run the daemon as its own process-group leader. The packaged binary is
// a PyInstaller one-file bootloader that forks the real Python process; // a PyInstaller one-file bootloader that forks the real Python process;
// making it a group leader lets shutdown signal the WHOLE group so the // 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). /// `bind_federation_twins`, which those two transports rely on instead).
arch_pubkey_hex: Option<String>, arch_pubkey_hex: Option<String>,
reachable: bool, 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, /// 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 /// matching `resource_progress`/`resource_sent`/`resource_failed` events
/// back to a log line; sends are fire-and-forget (see `send_resource`). /// back to a log line; sends are fire-and-forget (see `send_resource`).
resource_id_counter: u64, 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 { impl ReticulumLink {
@@ -269,6 +288,7 @@ impl ReticulumLink {
data_dir: &Path, data_dir: &Path,
our_ed_pubkey_hex: Option<&str>, our_ed_pubkey_hex: Option<&str>,
our_x25519_pubkey_hex: Option<&str>, our_x25519_pubkey_hex: Option<&str>,
display_name: Option<&str>,
) -> Result<Self> { ) -> Result<Self> {
probe_rnode(path) probe_rnode(path)
.await .await
@@ -278,6 +298,7 @@ impl ReticulumLink {
data_dir, data_dir,
our_ed_pubkey_hex, our_ed_pubkey_hex,
our_x25519_pubkey_hex, our_x25519_pubkey_hex,
display_name,
) )
.await .await
} }
@@ -290,6 +311,7 @@ impl ReticulumLink {
data_dir: &Path, data_dir: &Path,
our_ed_pubkey_hex: Option<&str>, our_ed_pubkey_hex: Option<&str>,
our_x25519_pubkey_hex: Option<&str>, our_x25519_pubkey_hex: Option<&str>,
display_name: Option<&str>,
) -> Result<Self> { ) -> Result<Self> {
let host = bind.rsplit_once(':').map(|(h, _)| h).unwrap_or(bind); let host = bind.rsplit_once(':').map(|(h, _)| h).unwrap_or(bind);
anyhow::ensure!( anyhow::ensure!(
@@ -302,6 +324,7 @@ impl ReticulumLink {
data_dir, data_dir,
our_ed_pubkey_hex, our_ed_pubkey_hex,
our_x25519_pubkey_hex, our_x25519_pubkey_hex,
display_name,
) )
.await .await
} }
@@ -313,6 +336,7 @@ impl ReticulumLink {
data_dir: &Path, data_dir: &Path,
our_ed_pubkey_hex: Option<&str>, our_ed_pubkey_hex: Option<&str>,
our_x25519_pubkey_hex: Option<&str>, our_x25519_pubkey_hex: Option<&str>,
display_name: Option<&str>,
) -> Result<Self> { ) -> Result<Self> {
anyhow::ensure!( anyhow::ensure!(
!targets.is_empty(), !targets.is_empty(),
@@ -323,6 +347,7 @@ impl ReticulumLink {
data_dir, data_dir,
our_ed_pubkey_hex, our_ed_pubkey_hex,
our_x25519_pubkey_hex, our_x25519_pubkey_hex,
display_name,
) )
.await .await
} }
@@ -332,6 +357,7 @@ impl ReticulumLink {
data_dir: &Path, data_dir: &Path,
our_ed_pubkey_hex: Option<&str>, our_ed_pubkey_hex: Option<&str>,
our_x25519_pubkey_hex: Option<&str>, our_x25519_pubkey_hex: Option<&str>,
display_name: Option<&str>,
) -> Result<Self> { ) -> Result<Self> {
// Keep the RPC socket under the archipelago-owned data dir (not the // Keep the RPC socket under the archipelago-owned data dir (not the
// shared system temp dir) so its access is bounded by the same // shared system temp dir) so its access is bounded by the same
@@ -379,6 +405,7 @@ impl ReticulumLink {
&identity_key, &identity_key,
our_ed_pubkey_hex, our_ed_pubkey_hex,
our_x25519_pubkey_hex, our_x25519_pubkey_hex,
display_name,
); );
cmd.env("TMPDIR", &tmp_dir); cmd.env("TMPDIR", &tmp_dir);
let child = cmd let child = cmd
@@ -450,6 +477,7 @@ impl ReticulumLink {
peers_file: runtime_dir.join("peers.json"), peers_file: runtime_dir.join("peers.json"),
inbound: std::collections::VecDeque::new(), inbound: std::collections::VecDeque::new(),
resource_id_counter: 0, resource_id_counter: 0,
daemon_gone: false,
}; };
link.load_persisted_peers(); link.load_persisted_peers();
Ok(link) Ok(link)
@@ -472,15 +500,25 @@ impl ReticulumLink {
}; };
let prefix: [u8; 6] = hash[..6].try_into().unwrap(); let prefix: [u8; 6] = hash[..6].try_into().unwrap();
self.prefix_to_hash.insert(prefix, hash); 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( self.peers.insert(
hash, hash,
ReticulumPeer { ReticulumPeer {
dest_hash: hash, dest_hash: hash,
display_name: p.display_name, display_name,
arch_pubkey_hex: p.arch_pubkey_hex, arch_pubkey_hex: p.arch_pubkey_hex,
// Reachability is a live property, not a persisted fact — // Reachability is a live property, not a persisted fact —
// start conservative and let the first real event refresh it. // start conservative and let the first real event refresh it.
reachable: false, reachable: false,
last_advert_at: 0,
}, },
); );
} }
@@ -533,10 +571,12 @@ impl ReticulumLink {
} }
pub async fn set_advert_name(&mut self, name: &str) -> Result<()> { pub async fn set_advert_name(&mut self, name: &str) -> Result<()> {
// The daemon's display_name is fixed at spawn time (CLI arg); changing // Live rename: the daemon's `set_name` verb updates the LXMF delivery
// it live would require an RPC verb we haven't added. Track locally so // destination's display_name and re-announces, so peers pick the new
// `advert_name()` reflects the caller's intent even though the // name up on their next announce receipt. Also tracked locally so
// RNS-visible name doesn't change until the daemon restarts. // `advert_name()` reflects it immediately.
self.send_rpc(serde_json::json!({"cmd": "set_name", "name": name}))
.await?;
self.display_name = Some(name.to_string()); self.display_name = Some(name.to_string());
Ok(()) Ok(())
} }
@@ -685,7 +725,7 @@ impl ReticulumLink {
.map(|p| ParsedContact { .map(|p| ParsedContact {
public_key_hex: hex::encode(p.dest_hash), public_key_hex: hex::encode(p.dest_hash),
advert_name: p.display_name.clone(), 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 // Deliberately not 1 ("friend"/meshcore type), so the
// meshcore-only auto-heal `reset_contact_path` loop in // meshcore-only auto-heal `reset_contact_path` loop in
// `refresh_contacts` (session.rs) skips these — RNS does its // `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>> { pub async fn try_recv_frame(&mut self) -> Result<Option<InboundFrame>> {
self.drain_events().await; 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()) Ok(self.inbound.pop_front())
} }
@@ -743,6 +789,15 @@ impl ReticulumLink {
/// Drain any buffered daemon events (non-blocking) and translate them into /// Drain any buffered daemon events (non-blocking) and translate them into
/// peer-table updates / synthetic InboundFrames. /// peer-table updates / synthetic InboundFrames.
async fn drain_events(&mut self) { 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 { loop {
let mut line = String::new(); let mut line = String::new();
let read = let read =
@@ -750,10 +805,16 @@ impl ReticulumLink {
.await; .await;
let n = match read { let n = match read {
Ok(Ok(n)) => n, 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 { if n == 0 {
warn!("Reticulum daemon RPC connection closed"); warn!("Reticulum daemon RPC connection closed");
self.daemon_gone = true;
break; break;
} }
let Ok(ev) = serde_json::from_str::<Value>(line.trim()) else { 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(); let prefix: [u8; 6] = hash[..6].try_into().unwrap();
self.prefix_to_hash.insert(prefix, hash); 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 let app_data_text = ev
.get("app_data") .get("app_data")
.and_then(Value::as_str) .and_then(Value::as_str)
@@ -794,12 +872,17 @@ impl ReticulumLink {
// now carry the same `arch_pubkey_hex`, instead of relying on // now carry the same `arch_pubkey_hex`, instead of relying on
// `bind_federation_twins`'s advert_name matching, which never // `bind_federation_twins`'s advert_name matching, which never
// matches here — see `display_name` below. // matches here — see `display_name` below.
let parsed_identity = app_data_text let legacy_identity = app_data_text
.as_deref() .as_deref()
.and_then(protocol::parse_identity_broadcast); .and_then(protocol::parse_identity_broadcast);
let is_identity_blob = parsed_identity.is_some(); let is_legacy_blob = legacy_identity.is_some();
if is_identity_blob { let identity_blob_text = explicit_blob.or_else(|| {
let text = app_data_text.clone().unwrap(); 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()); let mut data = Vec::with_capacity(7 + text.len());
data.push(0); // channel index — unused by the identity path data.push(0); // channel index — unused by the identity path
data.extend_from_slice(&prefix); 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 arch_pubkey_hex = parsed_identity.map(|(_did, ed_pubkey, _x25519)| ed_pubkey);
let display_name = app_data_text let announced_name =
.filter(|_| !is_identity_blob) pick_announced_name(explicit_name, app_data_text, is_legacy_blob);
.unwrap_or_else(|| format!("Reticulum {}", hex::encode(&hash[..4]))); let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
self.peers self.peers
.entry(hash) .entry(hash)
.and_modify(|p| { .and_modify(|p| {
p.display_name = display_name.clone(); if let Some(name) = announced_name.clone() {
p.display_name = name;
}
p.reachable = true; p.reachable = true;
p.last_advert_at = now;
if arch_pubkey_hex.is_some() { if arch_pubkey_hex.is_some() {
p.arch_pubkey_hex = arch_pubkey_hex.clone(); p.arch_pubkey_hex = arch_pubkey_hex.clone();
} }
}) })
.or_insert(ReticulumPeer { .or_insert_with(|| ReticulumPeer {
dest_hash: hash, dest_hash: hash,
display_name, display_name: announced_name
.unwrap_or_else(|| format!("Reticulum {}", hex::encode(&hash[..4]))),
arch_pubkey_hex, arch_pubkey_hex,
reachable: true, reachable: true,
last_advert_at: now,
}); });
self.persist_peers(); self.persist_peers();
} }
@@ -844,17 +935,25 @@ impl ReticulumLink {
// A peer that messages us without ever announcing still needs // A peer that messages us without ever announcing still needs
// to survive a restart — give it a placeholder name (the real // to survive a restart — give it a placeholder name (the real
// one, if any, arrives via a later "announce" and overwrites // one, if any, arrives via a later "announce" and overwrites
// this) so its routing entry alone doesn't get lost. // this) so its routing entry alone doesn't get lost. An
if let std::collections::hash_map::Entry::Vacant(e) = self.peers.entry(source_hash) // 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 { e.insert(ReticulumPeer {
dest_hash: source_hash, dest_hash: source_hash,
display_name: format!("Reticulum {}", hex::encode(&source_hash[..4])), display_name: format!("Reticulum {}", hex::encode(&source_hash[..4])),
arch_pubkey_hex: None, arch_pubkey_hex: None,
reachable: true, reachable: true,
last_advert_at: 0,
}); });
self.persist_peers(); 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) // A stock LXMF client (Sideband/NomadNet — not an archy peer)
// carries photos/files in native LXMF fields, not our own // carries photos/files in native LXMF fields, not our own
@@ -932,16 +1031,21 @@ impl ReticulumLink {
}; };
let prefix: [u8; 6] = source_hash[..6].try_into().unwrap(); let prefix: [u8; 6] = source_hash[..6].try_into().unwrap();
self.prefix_to_hash.insert(prefix, source_hash); self.prefix_to_hash.insert(prefix, source_hash);
if let std::collections::hash_map::Entry::Vacant(e) = self.peers.entry(source_hash) match self.peers.entry(source_hash) {
{ std::collections::hash_map::Entry::Vacant(e) => {
e.insert(ReticulumPeer { e.insert(ReticulumPeer {
dest_hash: source_hash, dest_hash: source_hash,
display_name: format!("Reticulum {}", hex::encode(&source_hash[..4])), display_name: format!("Reticulum {}", hex::encode(&source_hash[..4])),
arch_pubkey_hex: None, arch_pubkey_hex: None,
reachable: true, reachable: true,
last_advert_at: 0,
}); });
self.persist_peers(); 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 _}; use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
let Some(data) = ev let Some(data) = ev
.get("data_b64") .get("data_b64")
@@ -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 // 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 // bridge chip) treat a DTR/RTS transition on open as a reset signal, the
// same mechanism esptool uses to force bootloader entry. Deassert both // same mechanism esptool uses to force bootloader entry. Deassert both
// and let the board settle before writing the probe, or the reboot eats // before writing the probe. Boards behind a USB-UART bridge (CP2102 on
// the DETECT_RESP window below. // 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_dtr(false);
let _ = port.set_rts(false); let _ = port.set_rts(false);
tokio::time::sleep(Duration::from_millis(300)).await; tokio::time::sleep(Duration::from_millis(300)).await;
@@ -1109,26 +1215,81 @@ pub(crate) async fn probe_rnode(path: &str) -> Result<()> {
0x00, 0x00,
KISS_FEND, 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)) tokio::time::timeout(Duration::from_millis(500), port.write_all(&probe))
.await .await
.context("RNode probe write timed out")? .context("RNode probe write timed out")?
.context("RNode probe write failed")?; .context("RNode probe write failed")?;
let mut buf = [0u8; 256]; let mut buf = [0u8; 256];
let mut seen = Vec::new(); let mut seen = Vec::new();
let deadline = tokio::time::Instant::now() + PROBE_READ_TIMEOUT; if await_detect_resp(&port, &mut buf, &mut seen, PROBE_READ_TIMEOUT).await {
while tokio::time::Instant::now() < deadline { 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 { match tokio::time::timeout(Duration::from_millis(150), port.read(&mut buf)).await {
Ok(Ok(n)) if n > 0 => { Ok(Ok(n)) if n > 0 => {
seen.extend_from_slice(&buf[..n]); seen.extend_from_slice(&buf[..n]);
// A late DETECT_RESP to the first write still counts.
if contains_detect_resp(&seen) { if contains_detect_resp(&seen) {
return Ok(()); 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, _ => continue,
} }
} }
anyhow::bail!("No RNode DETECT_RESP within {:?}", PROBE_READ_TIMEOUT) false
} }
/// Look for the `[FEND, CMD_DETECT, DETECT_RESP]` sequence anywhere in the /// 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]) .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 { impl Drop for ReticulumLink {
fn drop(&mut self) { fn drop(&mut self) {
// Group-wide SIGTERM with a delayed SIGKILL backstop (`terminate_group`). // Group-wide SIGTERM with a delayed SIGKILL backstop (`terminate_group`).
@@ -1154,6 +1342,37 @@ impl Drop for ReticulumLink {
mod tests { mod tests {
use super::*; 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] #[test]
fn detect_resp_found_in_kiss_stream() { fn detect_resp_found_in_kiss_stream() {
let stream = [ let stream = [
+36 -15
View File
@@ -557,29 +557,39 @@ fn likely_non_mesh_serial_device(path: &str) -> bool {
/// Scan for serial devices that could be Meshcore radios. /// Scan for serial devices that could be Meshcore radios.
/// Returns paths to existing serial device files. /// Returns paths to existing serial device files.
/// ///
/// Candidates are deduplicated by canonical path: /dev/mesh-radio is a udev /// Dedupes by canonical (symlink-resolved) target: `/dev/mesh-radio` is a
/// symlink to a ttyUSB*/ttyACM* node that is ALSO in the candidate list, so /// stable udev symlink to whatever `/dev/ttyUSB*`/`/dev/ttyACM*` node the
/// without this one physical board shows up (and gets probed, and gets its /// primary radio currently enumerates as, so both names always pointed at
/// DTR/RTS reset toggled) twice per cycle. The first candidate wins, which /// the same candidate list entry and both passed this scan — confirmed live
/// keeps the stable /dev/mesh-radio name when the symlink exists. /// 2026-07-23, this made an already-connected, working radio (connected via
/// its `/dev/mesh-radio` alias) simultaneously appear as a second, separate
/// "detected but unclaimed" device under its raw `/dev/ttyUSBn` name. The
/// hot-swap UI's active-session guard compares path strings, so it didn't
/// recognize the two aliases as the same port, showed the "device detected"
/// modal for a radio that was already set up, and probing it there opened
/// (and DTR/RTS-reset) the exact port the live session was mid-conversation
/// with — a continuous, UI-driven reset loop that only ran while that view
/// was open (matches the reported "stops when I leave, resumes when I come
/// back"). SERIAL_CANDIDATES lists `/dev/mesh-radio` first, so it wins the
/// dedup and is what's reported when both alias and target are present.
/// (Independently re-discovered and fixed on main 2026-07-26 — both sides
/// of the 2026-07-28 merge carried an equivalent implementation.)
pub async fn detect_serial_devices() -> Vec<String> { pub async fn detect_serial_devices() -> Vec<String> {
let mut devices = Vec::new(); let mut devices = Vec::new();
let mut seen_canonical: Vec<std::path::PathBuf> = Vec::new(); let mut seen_real_paths = std::collections::HashSet::new();
for path in SERIAL_CANDIDATES { for path in SERIAL_CANDIDATES {
if tokio::fs::metadata(path).await.is_ok() { if tokio::fs::metadata(path).await.is_ok() {
if likely_non_mesh_serial_device(path) { if likely_non_mesh_serial_device(path) {
debug!(path = %path, "Skipping known non-mesh serial device"); debug!(path = %path, "Skipping known non-mesh serial device");
continue; continue;
} }
let canonical = tokio::fs::canonicalize(path) let real_path = tokio::fs::canonicalize(path)
.await .await
.unwrap_or_else(|_| std::path::PathBuf::from(path)); .unwrap_or_else(|_| std::path::PathBuf::from(path));
if seen_canonical.contains(&canonical) { if !seen_real_paths.insert(real_path.clone()) {
debug!(path = %path, canonical = %canonical.display(), debug!(path = %path, real_path = %real_path.display(), "Skipping duplicate alias for an already-listed device");
"Skipping alias of an already-detected serial device");
continue; continue;
} }
seen_canonical.push(canonical);
devices.push(path.to_string()); devices.push(path.to_string());
} }
} }
@@ -610,12 +620,23 @@ pub async fn detect_serial_devices_info() -> Vec<DetectedDeviceInfo> {
let mut out = Vec::new(); let mut out = Vec::new();
for path in detect_serial_devices().await { for path in detect_serial_devices().await {
let usb = usb_info_for_tty(&path).await; let usb = usb_info_for_tty(&path).await;
let plugged_at = tokio::fs::metadata(&path) // Birth time (btime), falling back to inode-change time (ctime) —
.await // 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() .ok()
.and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs()); .map(|d| d.as_secs())
.or_else(|| {
use std::os::unix::fs::MetadataExt;
u64::try_from(m.ctime()).ok()
})
});
out.push(DetectedDeviceInfo { out.push(DetectedDeviceInfo {
path, path,
vid: usb.0, vid: usb.0,
+5
View File
@@ -134,6 +134,7 @@ pub async fn sync_with_peers(data_dir: &Path, peer_onions: &[String]) -> Result<
for onion in &unique_onions { for onion in &unique_onions {
let fips_npub = crate::federation::fips_npub_for_onion(data_dir, onion).await; let fips_npub = crate::federation::fips_npub_for_onion(data_dir, onion).await;
match sync_single_peer( match sync_single_peer(
data_dir,
fips_npub.as_deref(), fips_npub.as_deref(),
&store, &store,
onion, onion,
@@ -173,6 +174,7 @@ pub async fn sync_with_peers(data_dir: &Path, peer_onions: &[String]) -> Result<
/// Sync with a single peer: pull their messages and push ours. /// Sync with a single peer: pull their messages and push ours.
/// Each HTTP call picks FIPS when a npub is known, otherwise Tor. /// Each HTTP call picks FIPS when a npub is known, otherwise Tor.
async fn sync_single_peer( async fn sync_single_peer(
data_dir: &Path,
fips_npub: Option<&str>, fips_npub: Option<&str>,
store: &crate::network::dwn_store::DwnStore, store: &crate::network::dwn_store::DwnStore,
onion: &str, onion: &str,
@@ -187,6 +189,7 @@ async fn sync_single_peer(
.service(crate::settings::transport::PeerService::Federation) .service(crate::settings::transport::PeerService::Federation)
.timeout(std::time::Duration::from_secs(30)) .timeout(std::time::Duration::from_secs(30))
.fips_timeout(std::time::Duration::from_secs(6)) .fips_timeout(std::time::Duration::from_secs(6))
.record_transport(data_dir)
.send_get() .send_get()
.await .await
.context("Peer DWN unreachable")?; .context("Peer DWN unreachable")?;
@@ -213,6 +216,7 @@ async fn sync_single_peer(
.service(crate::settings::transport::PeerService::Federation) .service(crate::settings::transport::PeerService::Federation)
.timeout(std::time::Duration::from_secs(30)) .timeout(std::time::Duration::from_secs(30))
.fips_timeout(std::time::Duration::from_secs(6)) .fips_timeout(std::time::Duration::from_secs(6))
.record_transport(data_dir)
.send_json(&pull_body) .send_json(&pull_body)
.await .await
.context("Failed to query peer DWN")?; .context("Failed to query peer DWN")?;
@@ -272,6 +276,7 @@ async fn sync_single_peer(
.service(crate::settings::transport::PeerService::Federation) .service(crate::settings::transport::PeerService::Federation)
.timeout(std::time::Duration::from_secs(30)) .timeout(std::time::Duration::from_secs(30))
.fips_timeout(std::time::Duration::from_secs(6)) .fips_timeout(std::time::Duration::from_secs(6))
.record_transport(data_dir)
.send_json(&push_body) .send_json(&push_body)
.await .await
{ {
-11
View File
@@ -366,17 +366,6 @@ pub async fn save_router_config(data_dir: &Path, config: &RouterConfig) -> Resul
.context("Writing router config") .context("Writing router config")
} }
/// Remove the saved router config (credentials + address) so the app forgets
/// this router entirely and the next call requires a fresh login.
pub async fn forget_router_config(data_dir: &Path) -> Result<()> {
let path = data_dir.join(ROUTER_CONFIG_FILE);
match fs::remove_file(&path).await {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e).context("Removing router config"),
}
}
/// Validate that an IP string is a private/LAN address (not public, not localhost). /// Validate that an IP string is a private/LAN address (not public, not localhost).
fn is_valid_private_ip(ip_str: &str) -> bool { fn is_valid_private_ip(ip_str: &str) -> bool {
let ip: std::net::IpAddr = match ip_str.parse() { let ip: std::net::IpAddr = match ip_str.parse() {
+9 -2
View File
@@ -342,6 +342,9 @@ pub async fn send_to_peer(
signing_key: Option<&ed25519_dalek::SigningKey>, signing_key: Option<&ed25519_dalek::SigningKey>,
recipient_pubkey: Option<&str>, recipient_pubkey: Option<&str>,
from_name: Option<&str>, from_name: Option<&str>,
// Federation data dir for last-transport recording; None skips recording
// (callers without a data dir in scope).
record_data_dir: Option<&std::path::Path>,
) -> Result<()> { ) -> Result<()> {
validate_onion(onion)?; validate_onion(onion)?;
@@ -370,11 +373,15 @@ pub async fn send_to_peer(
body["from_name"] = serde_json::Value::String(name.to_string()); body["from_name"] = serde_json::Value::String(name.to_string());
} }
let (resp, transport) = let mut req =
crate::fips::dial::PeerRequest::new(fips_npub, onion, "/archipelago/node-message") crate::fips::dial::PeerRequest::new(fips_npub, onion, "/archipelago/node-message")
.service(crate::settings::transport::PeerService::Messaging) .service(crate::settings::transport::PeerService::Messaging)
.timeout(std::time::Duration::from_secs(60)) .timeout(std::time::Duration::from_secs(60))
.fips_timeout(std::time::Duration::from_secs(8)) .fips_timeout(std::time::Duration::from_secs(8));
if let Some(dir) = record_data_dir {
req = req.record_transport(dir);
}
let (resp, transport) = req
.send_json(&body) .send_json(&body)
.await .await
.map_err(|e| { .map_err(|e| {
+122 -16
View File
@@ -294,8 +294,13 @@ impl Server {
.await .await
.unwrap_or_default(); .unwrap_or_default();
// Auto-enable mesh if a radio is detected and no config exists yet // Auto-enable mesh if a radio is detected and no config exists
if !mesh_config.enabled { // 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; let devices = crate::mesh::detect_devices().await;
if !devices.is_empty() { if !devices.is_empty() {
info!("📡 Auto-detected mesh radio: {:?} — enabling mesh", devices); info!("📡 Auto-detected mesh radio: {:?} — enabling mesh", devices);
@@ -1217,18 +1222,36 @@ async fn peer_late_bind_loop(
} }
}; };
info!("FIPS peer listener bound {}", addr); info!("FIPS peer listener bound {}", addr);
// Once bound, serve until shutdown fires. accept_loop // Serve until shutdown, a persistent accept failure, or a
// returns on shutdown, which also ends this outer loop. // fips0 ULA change. The listener must be REBINDABLE: a
accept_loop( // daemon re-key tears fips0 down and brings it back with a
handler, // (possibly different) ULA, and the old one-shot bind left
// the node inbound-dead over FIPS until process restart.
tokio::select! {
_ = accept_loop(
handler.clone(),
listener, listener,
active_connections, active_connections.clone(),
true, // peer listener: apply path filter true, // peer listener: apply path filter
shutdown_rx, shutdown_rx.clone(),
addr, addr,
) ) => {
.await; if *shutdown_rx.borrow() { return; }
return; warn!("FIPS peer accept loop ended — rebinding");
}
_ = async {
loop {
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
if crate::fips::iface::fips0_ula() != Some(ip) {
break;
}
}
} => {
info!("fips0 ULA changed — rebinding FIPS peer listener");
// Dropping the select arm cancels accept_loop and
// frees the socket; the outer loop rebinds fresh.
}
}
} }
_ = shutdown_rx.changed() => { _ = shutdown_rx.changed() => {
if *shutdown_rx.borrow() { return; } if *shutdown_rx.borrow() { return; }
@@ -1280,22 +1303,70 @@ async fn accept_loop(
mut shutdown_rx: tokio::sync::watch::Receiver<bool>, mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
local_addr: SocketAddr, local_addr: SocketAddr,
) { ) {
// Consecutive accept-error tracking: a fips0 teardown/re-key leaves the
// peer listener's socket permanently broken — `continue`-ing forever
// made the node inbound-dead over FIPS until process restart. After a
// burst of consecutive errors the peer accept loop returns so its
// caller (peer_late_bind_loop) can rebind on the current ULA.
let mut consecutive_errors: u32 = 0;
loop { loop {
tokio::select! { tokio::select! {
result = listener.accept() => { result = listener.accept() => {
let (stream, peer_addr) = match result { let (stream, peer_addr) = match result {
Ok(c) => c, Ok(c) => { consecutive_errors = 0; c }
Err(e) => { Err(e) => {
error!("{} accept error: {}", local_addr, e); error!("{} accept error: {}", local_addr, e);
consecutive_errors += 1;
if peer_only && consecutive_errors >= 10 {
warn!("{} accept failing persistently — returning for rebind", local_addr);
return;
}
// Don't hot-loop on a dead socket.
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
continue; continue;
} }
}; };
let handler = handler.clone(); let handler = handler.clone();
let permit = active_connections.clone().acquire_owned().await; // NEVER park the accept loop on the connection budget.
// `acquire_owned().await` here froze accept() entirely when
// permits drained — and permits drained because half-open
// clients and hung upstreams held them forever (the .228
// session-flapping / CLOSE-WAIT `inode: 0` signature). Shed
// load instead: accept, answer 503, close.
let permit = match active_connections.clone().try_acquire_owned() {
Ok(p) => p,
Err(_) => {
warn!(
"{} connection budget exhausted — shedding {}",
local_addr, peer_addr
);
tokio::spawn(async move {
use tokio::io::AsyncWriteExt;
let mut stream = stream;
let _ = tokio::time::timeout(
std::time::Duration::from_secs(5),
stream.write_all(
b"HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\nContent-Length: 0\r\n\r\n",
),
)
.await;
let _ = stream.shutdown().await;
});
continue;
}
};
tokio::spawn(async move { tokio::spawn(async move {
let _permit = permit; let _permit = permit;
// Set when a request carries an Upgrade header (websocket):
// upgraded connections are legitimately long-lived and are
// exempt from the non-upgraded connection deadline below.
let upgraded = Arc::new(std::sync::atomic::AtomicBool::new(false));
let upgraded_flag = upgraded.clone();
let service = service_fn(move |mut req: hyper::Request<hyper::Body>| { let service = service_fn(move |mut req: hyper::Request<hyper::Body>| {
let handler = handler.clone(); let handler = handler.clone();
if req.headers().contains_key(hyper::header::UPGRADE) {
upgraded_flag.store(true, std::sync::atomic::Ordering::Relaxed);
}
async move { async move {
// Record the TCP peer so rate limiting only trusts // Record the TCP peer so rate limiting only trusts
// forwarded headers on loopback (nginx) connections. // forwarded headers on loopback (nginx) connections.
@@ -1314,14 +1385,49 @@ async fn accept_loop(
.map_err(|e| std::io::Error::other(format!("{}", e))) .map_err(|e| std::io::Error::other(format!("{}", e)))
} }
}); });
if let Err(e) = Http::new() // header_read_timeout: a client that connects and never
// sends a request (slowloris / half-open) is dropped
// instead of holding a permit until the heat death of the
// node. Long RPCs are safe — the clock only covers header
// read.
let conn = Http::new()
.http1_keep_alive(false) .http1_keep_alive(false)
.http1_header_read_timeout(std::time::Duration::from_secs(30))
.serve_connection(stream, service) .serve_connection(stream, service)
.with_upgrades() .with_upgrades();
.await tokio::pin!(conn);
// Deadline watchdog for NON-upgraded connections. With
// keep-alive off a plain connection serves one exchange;
// 15 min bounds even the slowest legitimate RPC/stream
// while guaranteeing a hung upstream can't hold a permit
// forever. Upgraded (websocket) connections are exempt.
const NON_UPGRADED_DEADLINE: std::time::Duration =
std::time::Duration::from_secs(900);
let started = std::time::Instant::now();
let watchdog = async {
loop {
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
if !upgraded.load(std::sync::atomic::Ordering::Relaxed)
&& started.elapsed() >= NON_UPGRADED_DEADLINE
{ {
return;
}
}
};
tokio::select! {
r = &mut conn => {
if let Err(e) = r {
error!("Error serving connection from {}: {}", peer_addr, e); error!("Error serving connection from {}: {}", peer_addr, e);
} }
}
_ = watchdog => {
warn!(
"connection from {} exceeded {}s without completing or upgrading — dropping",
peer_addr,
NON_UPGRADED_DEADLINE.as_secs()
);
}
}
}); });
} }
_ = shutdown_rx.changed() => { _ = shutdown_rx.changed() => {
-16
View File
@@ -82,22 +82,6 @@ impl Router {
Ok(out) Ok(out)
} }
/// Set `user`'s login password via BusyBox `passwd`, non-interactively.
///
/// BusyBox's passwd applet reads the new password twice from stdin even
/// without a tty (unlike shadow-utils' passwd, which requires one), so
/// piping two lines in works. Root can always set its own (or another
/// user's) password without supplying the old one first. This is what
/// lets a fresh-flash router (root has no password yet) be fully set up
/// from the app — no manual SSH/console session required.
pub fn set_password(&self, user: &str, new_password: &str) -> Result<()> {
let q = crate::uci::shell_quote(new_password);
let uq = crate::uci::shell_quote(user);
let cmd = format!("printf '%s\\n%s\\n' {q} {q} | passwd {uq} 2>&1");
self.run_ok(&cmd)?;
Ok(())
}
/// Verify the remote device is actually running OpenWrt. /// Verify the remote device is actually running OpenWrt.
pub fn verify_openwrt(&self) -> Result<String> { pub fn verify_openwrt(&self) -> Result<String> {
let release = self let release = self
+1 -1
View File
@@ -60,6 +60,6 @@ impl Router {
} }
/// Wrap a value in single quotes, escaping any embedded single quotes. /// Wrap a value in single quotes, escaping any embedded single quotes.
pub(crate) fn shell_quote(s: &str) -> String { fn shell_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', r"'\''")) format!("'{}'", s.replace('\'', r"'\''"))
} }
+27
View File
@@ -12,6 +12,33 @@ Full plan: `.claude/plans/enchanted-strolling-rocket.md`. Memory pointer:
avoiding `mesh/listener/session.rs` transport plumbing + `mesh/mod.rs` routing, which this work avoiding `mesh/listener/session.rs` transport plumbing + `mesh/mod.rs` routing, which this work
owns. Stay out of `meshtastic.rs`/`protocol.rs` to avoid collisions. owns. Stay out of `meshtastic.rs`/`protocol.rs` to avoid collisions.
## Checkpoint 2026-07-28 — RNode connect + names FIXED, live-verified E2E (read this first)
The fleet reflash back to RNode firmware exposed a stack of bugs that made Reticulum
unusable on CP2102-bridged boards (Heltec V3 etc.) and left every archy node nameless on
RNS. All fixed in `a8c4694c` (backend) + `3f76b496` (UI), live-verified on archi-dev-box
and archy-x250-dev with a real RNode-to-RNode LXMF message (`transport: "reticulum"` in
mesh-messages) plus a cross-transport reply:
1. **probe_rnode boot race** — serial open pulses DTR/RTS via the USB-UART bridge → ESP32
power-cycles → KISS DETECT written 300ms later is eaten during ~2.5-3s of boot. Fix:
immediate probe (fast path) + drain-until-quiet boot settle + second DETECT window.
2. **configure() was a no-op on a running listener** (only enable/disable restarted it) —
the setup modal's apply/keep-as-is and every rename did nothing until process restart.
3. **Name propagation**`config.advert_name` had no reader; `server.set-name` never
reached mesh; daemon display name fixed at spawn to the "Archy" default; the ARCHY:2
announce blob REPLACED the LXMF name. Now: announces carry msgpack
`[name, stamp_cost, sf, ARCHY-blob]` (Sideband-compatible, blob invisible to stock
clients), daemon has a `set_name` verb, renames bounce the session live.
4. **Daemon-death detection** (was invisible up to the 30-min RX-stall watchdog),
**modal re-trigger loop** (plugged_at used tty mtime → bumps on every open; now
btime/ctime), **ARCHY:2 federation-name clobber**, **mesh.refresh RPC** (Refresh button
now actually re-queries the radio), **Meshtastic mesh.broadcast now sends NodeInfo**.
Still open here: legacy-format peers (old fleet builds) show as `Reticulum <hex4>` until
they OTA; RNode RF params still daemon-hardcoded (EU-868 869.525/125k/SF8/CR5); Phase 4
multi-radio; duty-cycle guard.
## Status at a glance ## Status at a glance
| Phase | What | Status | | Phase | What | Status |
+55
View File
@@ -110,6 +110,61 @@ lands].
2. ❑ Mobile Home: wallet card directly under My Apps (G5 from the voice epic). 2. ❑ Mobile Home: wallet card directly under My Apps (G5 from the voice epic).
3. ❑ Mesh RF settings panel (Mesh → Device) still loads and saves. 3. ❑ Mesh RF settings panel (Mesh → Device) still loads and saves.
## H. LoRa radio firmware flashing (Heltec V3/V4, new — extends Section E)
Full v1 scope is 3 firmware families × 2 boards (6 cells); mark each cell
tested on real hardware vs. code-reviewed only as this is run.
1. ❑ From the hot-swap modal's step 1 (device already probed), press
**Flash Firmware…** → new step shows firmware-family + board pickers and
the erase-confirmation checkbox; "Erase & Flash Now" stays disabled until
family, board, AND the checkbox are all set.
2. ❑ Confirm what's currently on the test stick via the existing probe
BEFORE flashing it — don't flash the only known-good device without a
fallback board on hand.
3. ❑ Prefer a spare Heltec V3/V4 for the first destructive erase+flash run;
only exercise a primary/in-use stick once the flow is proven safe.
4. ❑ MeshCore → Heltec V3: erase + write completes, progress bar and log
tail update live, ends at "Flash complete".
5. ❑ Meshtastic → Heltec V3: same, using the extracted `*.factory.bin` from
the esp32s3 release zip.
6. ❑ Reticulum/RNode → Heltec V3: `archy-rnodeconf --autoinstall` path
completes (no raw esptool erase/write step for this family — see
`mesh/flash.rs` doc comment).
7. ❑ Repeat 4-6 against a Heltec V4. Confirmed 2026-07-23 on real hardware:
V4 uses the ESP32-S3's native-USB JTAG/serial peripheral (vid:pid
303a:1001, generic to every native-USB ESP32-S3 board, not V4-specific)
— so unlike V3's CP2102 bridge chip, V4 is permanently NOT auto-matchable
by vid:pid. Board auto-detect should fail closed for it every time
(manual board selection required, "couldn't confirm automatically"
warning shown) — this is expected steady-state behavior, not a gap to
close later.
8. ❑ After a successful flash, the modal automatically re-probes and shows
the NEW firmware's badge/details — same as unplugging and replugging
(Section E item 3), but without physically touching the cable.
9. ❑ Deliberately test a failure path once (disconnect the board mid-write,
or point at a bad cached asset) — confirm the error surfaces in the
progress log AND that `docs/troubleshooting.md`'s "LoRa radio firmware
flash failed" recovery steps (BOOT+RST bootloader entry, manual esptool/
rnodeconf command) actually get the board back to a flashable state.
10. ❑ Cancel button only appears (and only works) while still in the
"Downloading firmware…" stage — once erasing/writing starts, no cancel
affordance is offered.
11. ❑ **Boot-loop regression (2026-07-23 incident)**: after a *failed* flash
(e.g. kill network access mid-download to force a failure), confirm the
mesh listener does NOT auto-resume — `journalctl -u archipelago` should
show a single `Leaving mesh listener stopped after failed flash` line
and then go quiet for that device, not a repeating `mesh::serial:
Opened serial port... Starting Meshcore handshake` cycle every few
seconds. Reconnect manually via the hot-swap modal afterward and confirm
it connects normally (the board itself should be untouched — the
download fails before esptool/rnodeconf ever runs).
12. ❑ Separately, force a device to flap connected/disconnected a few times
in under 20s each (e.g. a marginal USB connection) and confirm
`reconnect_delay` in the logs actually escalates (5s → 10s → 20s → ...)
rather than resetting to 5s on every attempt — see
`STABLE_SESSION_THRESHOLD` in `mesh/listener/mod.rs`.
--- ---
After this passes: fold the batch + other agent's work into the next release After this passes: fold the batch + other agent's work into the next release
+80
View File
@@ -443,6 +443,86 @@ free -h
- Check Nginx WebSocket proxy config: `/etc/nginx/sites-available/archipelago` must include `proxy_set_header Upgrade $http_upgrade` - Check Nginx WebSocket proxy config: `/etc/nginx/sites-available/archipelago` must include `proxy_set_header Upgrade $http_upgrade`
- If on WiFi, try wired Ethernet for more stable connectivity - If on WiFi, try wired Ethernet for more stable connectivity
### 21. LoRa radio firmware flash failed / board unresponsive
**Symptoms**: The "Erase & Flash Now" flow in the mesh hot-swap modal reports
an error, or the radio no longer enumerates as a serial device after a flash
attempt.
**Diagnosis**:
```bash
# Poll the flash job's last-known stage/error directly
curl -s http://localhost:5678/rpc/v1 \
-H 'Content-Type: application/json' \
-d '{"method":"mesh.flash-status","params":{}}'
# Confirm the board is still enumerating at all
ls -la /dev/ttyUSB* /dev/ttyACM* /dev/mesh-radio 2>&1
# esptool/rnodeconf binaries present?
which esptool; ls -la /usr/local/bin/archy-rnodeconf
```
**Solutions**:
- A failure during `erasing`/`writing` (MeshCore/Meshtastic) or
`autoinstalling` (Reticulum) can leave the chip erased or half-written —
this is expected risk of the "always erase first" default, not a bug.
- Heltec V3/V4 boards can be forced back into bootloader mode manually: hold
**BOOT**, tap **RST**, then release **BOOT** — this puts the chip in a
state esptool can always talk to, regardless of what firmware (if any) is
currently on it.
- With the board in bootloader mode, a manual recovery flash can be run
directly over SSH without the UI:
```bash
esptool --chip esp32s3 --port /dev/ttyACM0 erase_flash
esptool --chip esp32s3 --port /dev/ttyACM0 write_flash 0x0 <known-good-image.bin>
```
- For Reticulum/RNode boards, the equivalent manual recovery is
`archy-rnodeconf /dev/ttyACM0 --autoinstall` (or `/usr/local/bin/archy-rnodeconf`
if it's not on `PATH`) — it re-runs the same fetch+erase+flash+bootstrap
sequence the UI triggers.
- If `esptool`/`archy-rnodeconf` are missing entirely, they should have been
installed by the last `self-update.sh` run — check
`sudo journalctl -u archipelago-update` for install failures, or install
`esptool` via `sudo apt-get install esptool` directly.
- Once a fresh image is confirmed written, unplug/replug the radio (or wait
for the next detection poll) — the hot-swap modal re-probes automatically
and shows whatever firmware is actually on the board now.
**Known incident (2026-07-23) — reconnect storm / device boot-loop after a
failed flash**: a real Heltec V3 got stuck cycling "connect → partial
handshake → drop" every 5-15s for 5+ minutes after a `mesh.flash-device`
attempt failed with `Reading firmware download stream`. Root cause was two
compounding issues, both now fixed:
1. `spawn_mesh_listener`'s reconnect backoff (`core/archipelago/src/mesh/listener/mod.rs`)
reset to its 5s minimum any time the prior session had been `device_connected`
at all, even for under a second — so a device that connects-then-drops
repeatedly never actually backed off. Every retry's `open()` toggles
DTR/RTS, which resets many ESP32 boards' MCU (native-USB *and*
CP2102/CH340 auto-reset-circuit boards), so the aggressive retries were
themselves *causing* the boot loop, not just observing one. Fixed by only
resetting backoff when a session ran for at least `STABLE_SESSION_THRESHOLD`
(20s) — see that constant's doc comment.
2. `mesh::flash::start_flash_job`'s post-completion handler auto-resumed the
listener unconditionally, even after a *failed* flash, immediately
re-entering the reconnect loop above with no cooldown. Fixed: on failure
the listener is now deliberately left stopped (reconnect manually via the
UI once the board is confirmed alive); on success there's a 5s settle
delay before resuming, so the board finishes booting from the flash
tool's own reset before Archipelago starts probing it again.
3. Separately, the download itself was failing because `mesh::flash`'s HTTP
client had a blanket 30s request timeout that covered the *entire*
download (including streaming a 170MB Meshtastic zip), not just
connection setup — fixed with a per-chunk stall timeout instead of a
fixed total-transfer cap.
If this symptom recurs (rapid repeating `mesh::serial: Opened serial
port... Starting Meshcore handshake` lines in `journalctl -u archipelago`
without a `LoRa firmware flash` job in progress), it's a NEW instance of the
same class of bug, not the one above — check whether backoff is actually
escalating (`Mesh session error: ... (retry in Xs)` — X should grow past 5s
within a few cycles) before assuming it's flashing-related.
--- ---
## General Maintenance ## General Maintenance
@@ -365,6 +365,11 @@ RUN apt-get update && apt-get -y full-upgrade && apt-get install -y --no-install
ca-certificates \ ca-certificates \
openssl \ openssl \
chrony \ chrony \
iputils-ping \
esptool \
python3-venv \
binutils \
libpython3.13 \
locales \ locales \
console-setup \ console-setup \
keyboard-configuration \ keyboard-configuration \
+22 -3
View File
@@ -1,12 +1,12 @@
{ {
"name": "neode-ui", "name": "neode-ui",
"version": "1.7.116-alpha", "version": "1.7.115-alpha",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "neode-ui", "name": "neode-ui",
"version": "1.7.116-alpha", "version": "1.7.115-alpha",
"dependencies": { "dependencies": {
"@scure/bip39": "^2.2.0", "@scure/bip39": "^2.2.0",
"@types/dompurify": "^3.0.5", "@types/dompurify": "^3.0.5",
@@ -150,6 +150,7 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.29.0", "@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0", "@babel/generator": "^7.29.0",
@@ -1811,6 +1812,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
}, },
@@ -1834,6 +1836,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@@ -3920,6 +3923,7 @@
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==", "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/geojson": "*" "@types/geojson": "*"
} }
@@ -3969,6 +3973,7 @@
"integrity": "sha512-MCbrb508JZHqe7bUibmZj/lyojdhLRnfkmyXnkrCM2zVrjTgL89U8UEfInpKTvPeTnxsw2hmyZxnhsdNR6yhwg==", "integrity": "sha512-MCbrb508JZHqe7bUibmZj/lyojdhLRnfkmyXnkrCM2zVrjTgL89U8UEfInpKTvPeTnxsw2hmyZxnhsdNR6yhwg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"cac": "^6.7.14", "cac": "^6.7.14",
"colorette": "^2.0.20", "colorette": "^2.0.20",
@@ -4469,6 +4474,7 @@
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.3", "fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1", "fast-uri": "^3.0.1",
@@ -4954,6 +4960,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@@ -5972,6 +5979,7 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC", "license": "ISC",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
} }
@@ -8164,6 +8172,7 @@
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"jiti": "bin/jiti.js" "jiti": "bin/jiti.js"
} }
@@ -8213,6 +8222,7 @@
"integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"cssstyle": "^4.1.0", "cssstyle": "^4.1.0",
"data-urls": "^5.0.0", "data-urls": "^5.0.0",
@@ -8315,7 +8325,8 @@
"version": "1.9.4", "version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause" "license": "BSD-2-Clause",
"peer": true
}, },
"node_modules/leven": { "node_modules/leven": {
"version": "3.1.0", "version": "3.1.0",
@@ -9071,6 +9082,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.11",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@@ -9732,6 +9744,7 @@
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/estree": "1.0.8" "@types/estree": "1.0.8"
}, },
@@ -10987,6 +11000,7 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -11242,6 +11256,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -11483,6 +11498,7 @@
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.27.0", "esbuild": "^0.27.0",
"fdir": "^6.5.0", "fdir": "^6.5.0",
@@ -11645,6 +11661,7 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -11658,6 +11675,7 @@
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/chai": "^5.2.2", "@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4", "@vitest/expect": "3.2.4",
@@ -11750,6 +11768,7 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.30.tgz", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.30.tgz",
"integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==", "integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@vue/compiler-dom": "3.5.30", "@vue/compiler-dom": "3.5.30",
"@vue/compiler-sfc": "3.5.30", "@vue/compiler-sfc": "3.5.30",
+33 -2
View File
@@ -4,6 +4,16 @@ export interface RPCOptions {
method: string method: string
params?: Record<string, unknown> params?: Record<string, unknown>
timeout?: number timeout?: number
/** Abort the call (and any pending retries) from the outside pass a
* component-scoped controller's signal so fan-outs stop on unmount. */
signal?: AbortSignal
/** Per-call retry budget (default 3). Use 1 for calls whose caller has its
* own timeout/fallback UX retry×3 on a slow peer is how one unreachable
* node turns a 30s timeout into a 90s spinner. */
maxRetries?: number
/** Collapse concurrent identical calls (same method + params) into one
* request. Opt-in: only safe for reads. */
dedup?: boolean
} }
export interface RPCResponse<T> { export interface RPCResponse<T> {
@@ -74,18 +84,35 @@ function getCsrfToken(): string | null {
class RPCClient { class RPCClient {
private static _sessionExpiredRedirecting = false private static _sessionExpiredRedirecting = false
private baseUrl: string private baseUrl: string
/** In-flight dedup map for `dedup: true` calls, keyed method+params. */
private inflight = new Map<string, Promise<unknown>>()
constructor(baseUrl: string = '/rpc/v1') { constructor(baseUrl: string = '/rpc/v1') {
this.baseUrl = baseUrl this.baseUrl = baseUrl
} }
async call<T>(options: RPCOptions): Promise<T> { async call<T>(options: RPCOptions): Promise<T> {
const { method, params = {}, timeout = 15000 } = options if (options.dedup) {
const maxRetries = 3 const key = `${options.method}:${JSON.stringify(options.params ?? {})}`
const existing = this.inflight.get(key)
if (existing) return existing as Promise<T>
const p = this.callInner<T>(options).finally(() => this.inflight.delete(key))
this.inflight.set(key, p)
return p
}
return this.callInner<T>(options)
}
private async callInner<T>(options: RPCOptions): Promise<T> {
const { method, params = {}, timeout = 15000, signal: external } = options
const maxRetries = Math.max(1, options.maxRetries ?? 3)
for (let attempt = 0; attempt < maxRetries; attempt++) { for (let attempt = 0; attempt < maxRetries; attempt++) {
if (external?.aborted) throw new Error('Aborted')
const controller = new AbortController() const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), timeout) const timeoutId = setTimeout(() => controller.abort(), timeout)
const onExternalAbort = () => controller.abort()
external?.addEventListener('abort', onExternalAbort, { once: true })
try { try {
const headers: Record<string, string> = { const headers: Record<string, string> = {
@@ -105,6 +132,7 @@ class RPCClient {
}) })
clearTimeout(timeoutId) clearTimeout(timeoutId)
external?.removeEventListener('abort', onExternalAbort)
if (!response.ok) { if (!response.ok) {
// Session expired — debounced redirect to login // Session expired — debounced redirect to login
@@ -167,8 +195,11 @@ class RPCClient {
return data.result as T return data.result as T
} catch (error) { } catch (error) {
clearTimeout(timeoutId) clearTimeout(timeoutId)
external?.removeEventListener('abort', onExternalAbort)
if (error instanceof Error) { if (error instanceof Error) {
if (error.name === 'AbortError') { if (error.name === 'AbortError') {
// Caller-initiated abort is final — never retried.
if (external?.aborted) throw new Error('Aborted')
const timeoutErr = new Error('Request timeout') const timeoutErr = new Error('Request timeout')
if (attempt < maxRetries - 1) { if (attempt < maxRetries - 1) {
const delay = 600 * (attempt + 1) const delay = 600 * (attempt + 1)
@@ -377,8 +377,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed } from 'vue'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
import { useCachedResource } from '@/composables/useCachedResource'
import { useTxExplorer } from '@/composables/useTxExplorer' import { useTxExplorer } from '@/composables/useTxExplorer'
defineProps<{ compact?: boolean }>() defineProps<{ compact?: boolean }>()
@@ -462,11 +463,41 @@ const feePresets: { key: FeePreset; label: string; hint?: string; confTarget?: n
{ key: 'custom', label: 'Custom' }, { key: 'custom', label: 'Custom' },
] ]
const loading = ref(true) // Cached: revisits paint the channel lists instantly and revalidate behind
const error = ref<string | null>(null) // them. Open and closed history are separate entries so a closed-history
const channels = ref<Channel[]>([]) // failure keeps its last list without touching the main channel view.
const closedChannels = ref<ClosedChannel[]>([]) interface ChannelsData { channels: Channel[]; total_inbound: number; total_outbound: number }
const summary = ref({ total_inbound: 0, total_outbound: 0 }) const channelsRes = useCachedResource<ChannelsData>({
key: 'lnd.channels',
fetcher: async (signal) => {
const result = await rpcClient.call<ChannelsData>({
method: 'lnd.listchannels', timeout: 15000, signal, dedup: true, maxRetries: 1,
})
return {
channels: result?.channels || [],
total_inbound: result?.total_inbound || 0,
total_outbound: result?.total_outbound || 0,
}
},
})
const closedRes = useCachedResource<ClosedChannel[]>({
key: 'lnd.closed-channels',
fetcher: async (signal) => {
const closed = await rpcClient.call<{ channels: ClosedChannel[] }>({
method: 'lnd.closedchannels', timeout: 15000, signal, dedup: true, maxRetries: 1,
})
return closed?.channels || []
},
})
const loading = computed(() =>
channelsRes.loadState.value === 'loading' || channelsRes.loadState.value === 'refreshing')
const error = computed(() => channelsRes.error.value)
const channels = computed(() => channelsRes.data.value?.channels ?? [])
const closedChannels = computed(() => closedRes.data.value ?? [])
const summary = computed(() => ({
total_inbound: channelsRes.data.value?.total_inbound ?? 0,
total_outbound: channelsRes.data.value?.total_outbound ?? 0,
}))
// Olympus by ZEUS the LSP node behind the Zeus mobile wallet. // Olympus by ZEUS the LSP node behind the Zeus mobile wallet.
// Channel limits: min 150,000 / max 1,500,000 sats. // Channel limits: min 150,000 / max 1,500,000 sats.
@@ -538,37 +569,10 @@ function capacityPercent(amount: number, capacity: number): number {
return Math.round((amount / capacity) * 100) return Math.round((amount / capacity) * 100)
} }
async function loadChannels() { function loadChannels(): Promise<void> {
const hadChannels = channels.value.length > 0 const main = channelsRes.refresh()
loading.value = true void closedRes.refresh()
error.value = null return main
try {
const result = await rpcClient.call<{ channels: Channel[]; total_inbound: number; total_outbound: number }>({
method: 'lnd.listchannels',
timeout: 15000,
})
channels.value = result.channels || []
summary.value = {
total_inbound: result.total_inbound || 0,
total_outbound: result.total_outbound || 0,
}
// Closed history is a separate RPC a failure here keeps the previous
// list rather than blanking the main channel view.
try {
const closed = await rpcClient.call<{ channels: ClosedChannel[] }>({
method: 'lnd.closedchannels',
timeout: 15000,
})
closedChannels.value = closed.channels || []
} catch {
/* keep previous closed list */
}
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to load channels'
if (!hadChannels) channels.value = []
} finally {
loading.value = false
}
} }
function feeParams(): { target_conf?: number; sat_per_vbyte?: number } | null { function feeParams(): { target_conf?: number; sat_per_vbyte?: number } | null {
@@ -647,7 +651,5 @@ async function closeChannel() {
} }
} }
onMounted(loadChannels)
defineExpose({ channels, loadChannels }) defineExpose({ channels, loadChannels })
</script> </script>
@@ -1,7 +1,7 @@
<template> <template>
<BaseModal <BaseModal
:show="show" :show="show"
:title="step === 1 ? 'Mesh Radio Detected' : 'Apply Archipelago Settings'" :title="step === 1 ? 'Mesh Radio Detected' : step === 2 ? 'Set Recommended' : 'Flash Firmware'"
max-width="max-w-lg" max-width="max-w-lg"
content-class="max-h-[90vh] overflow-y-auto" content-class="max-h-[90vh] overflow-y-auto"
@close="dismiss" @close="dismiss"
@@ -35,9 +35,17 @@
<!-- What's currently flashed / configured on it --> <!-- What's currently flashed / configured on it -->
<div class="mt-4 text-left rounded-xl bg-white/[0.05] border border-white/10 p-3"> <div class="mt-4 text-left rounded-xl bg-white/[0.05] border border-white/10 p-3">
<div v-if="probing" class="flex items-center gap-2 text-white/60 text-sm py-1"> <div v-if="probing" class="py-1">
<span class="inline-block w-3.5 h-3.5 rounded-full border-2 border-orange-300/70 border-t-transparent animate-spin"></span> <div class="flex items-center justify-between text-white/60 text-sm mb-1.5">
Reading what's on the radio <span>{{ probeStage }}</span>
<span class="text-white/40 text-xs tabular-nums">{{ Math.round(probeProgress) }}%</span>
</div>
<div class="h-1.5 rounded-full bg-white/10 overflow-hidden">
<div
class="h-full rounded-full bg-orange-400/80 transition-[width] duration-500 ease-linear"
:style="{ width: probeProgress + '%' }"
></div>
</div>
</div> </div>
<template v-else-if="probe"> <template v-else-if="probe">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -94,21 +102,123 @@
:disabled="!!connecting" :disabled="!!connecting"
@click="step = 2" @click="step = 2"
> >
Set Up with Archipelago Settings Set Recommended
</button> </button>
</div> </div>
<p class="text-white/40 text-[11px] mt-3"> <p class="text-white/40 text-[11px] mt-3">
"Keep As Is" uses the radio exactly as it is nothing on it is changed, "Keep As Is" uses the radio exactly as it is nothing on it is changed,
and you can hot-swap radios any time. and you can hot-swap radios any time.
</p> </p>
<button
class="w-full text-center text-white/40 hover:text-white/70 text-[11px] mt-3 underline underline-offset-2"
:disabled="!!connecting"
@click="openFlashStep"
>
Flash Firmware
</button>
<p v-if="error" class="text-xs text-red-400 mt-2">{{ error }}</p> <p v-if="error" class="text-xs text-red-400 mt-2">{{ error }}</p>
</div> </div>
<!-- Step 3: erase + reflash destructive, opt-in only -->
<div v-else-if="step === 'flash'">
<!-- Once a job exists (started via startFlash), ALWAYS show the
progress/result view below including on failure. The old
condition (`!active && stage !== 'done'`) was also true for a
FAILED job (active:false, stage:'failed'), which silently sent
the user back to this picker instead of showing the error. -->
<template v-if="!flashJob">
<p class="text-white/60 text-xs mb-3">
Downloads the latest firmware from upstream and writes it to
<span class="font-mono text-orange-300">{{ devicePath }}</span>.
</p>
<div class="space-y-4">
<div>
<label class="block text-sm text-white/80 mb-1">Firmware family</label>
<select v-model="flashFamily" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60">
<option value="">Choose</option>
<option value="meshcore">MeshCore</option>
<option value="meshtastic">Meshtastic</option>
<option value="reticulum">Reticulum RNode</option>
</select>
</div>
<div>
<label class="block text-sm text-white/80 mb-1">Board</label>
<select v-model="flashBoard" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60">
<option value="">Choose</option>
<option value="heltec-v3">Heltec LoRa 32 V3</option>
<option value="heltec-v4">Heltec LoRa 32 V4</option>
</select>
<p v-if="!boardAutoDetected" class="text-[11px] text-amber-400/80 mt-1">
Couldn't confirm the board automatically double check before flashing.
Flashing the wrong board's image can brick it.
</p>
</div>
</div>
<div class="rounded-xl bg-red-500/10 border border-red-500/30 p-3 mt-4">
<label class="flex items-start gap-2 text-xs text-red-300">
<input type="checkbox" v-model="flashConfirmed" class="mt-0.5" />
<span>
This <strong>erases the entire chip</strong>, including any existing
keys, identity, and contacts. This cannot be undone.
</span>
</label>
</div>
<p v-if="error" class="text-xs text-red-400 mt-3">{{ error }}</p>
<div class="flex gap-2 mt-6">
<button class="glass-button px-4 py-2 rounded-lg text-sm" @click="step = 1">Back</button>
<button
class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium bg-red-500/80 hover:bg-red-500 text-white disabled:opacity-50"
:disabled="!flashFamily || !flashBoard || !flashConfirmed || starting"
@click="startFlash"
>
{{ starting ? 'Starting…' : 'Erase & Flash Now' }}
</button>
</div>
</template>
<!-- Progress -->
<template v-else>
<div class="text-center py-2">
<p class="text-white text-sm font-medium">{{ flashStageLabel }}</p>
<div class="mt-3 h-2 rounded-full bg-white/10 overflow-hidden">
<div
class="h-full bg-orange-400 transition-all"
:style="{ width: (flashJob?.percent ?? (flashJob?.stage === 'done' ? 100 : 8)) + '%' }"
></div>
</div>
<p v-if="flashJob?.error" class="text-xs text-red-400 mt-3">{{ flashJob.error }}</p>
</div>
<div class="mt-3 rounded-xl bg-black/30 border border-white/10 p-2 h-32 overflow-y-auto font-mono text-[10px] text-white/50 leading-relaxed">
<div v-for="(line, i) in (flashJob?.log_tail ?? []).slice(-40)" :key="i">{{ line }}</div>
</div>
<div class="flex gap-2 mt-4">
<button
v-if="flashJob?.stage === 'downloading' && flashJob?.active"
class="glass-button px-4 py-2 rounded-lg text-sm"
@click="cancelFlash"
>
Cancel
</button>
<button
v-if="!flashJob?.active"
class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium"
@click="closeFlashStep"
>
Done
</button>
</div>
</template>
</div>
<!-- Step 2: our latest parameters, shown before anything is written --> <!-- Step 2: our latest parameters, shown before anything is written -->
<div v-else> <div v-else>
<p class="text-white/60 text-xs mb-3"> <p class="text-white/60 text-xs mb-3">
These are the latest Archipelago settings nothing is written to the These are the recommended Archipelago settings nothing is written to
radio until you confirm. the radio until you confirm.
</p> </p>
<!-- Summary of what will be applied --> <!-- Summary of what will be applied -->
@@ -190,7 +300,7 @@
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import BaseModal from '@/components/BaseModal.vue' import BaseModal from '@/components/BaseModal.vue'
import { useMeshStore, type MeshDeviceProbe, type MeshConfigureParams } from '@/stores/mesh' import { useMeshStore, type MeshDeviceProbe, type MeshConfigureParams, type FlashFirmwareFamily, type FlashBoard, type FlashJobStatus } from '@/stores/mesh'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { LORA_REGIONS, regionByCode, suggestRegionFromLatLon, meshcorePlanFor } from '@/utils/loraRegions' import { LORA_REGIONS, regionByCode, suggestRegionFromLatLon, meshcorePlanFor } from '@/utils/loraRegions'
import { resolveMeshDeviceImage } from '@/utils/meshDeviceImages' import { resolveMeshDeviceImage } from '@/utils/meshDeviceImages'
@@ -199,13 +309,37 @@ const mesh = useMeshStore()
const appStore = useAppStore() const appStore = useAppStore()
const router = useRouter() const router = useRouter()
const step = ref<1 | 2>(1) const step = ref<1 | 2 | 'flash'>(1)
const connecting = ref<false | 'keep' | 'setup'>(false) const connecting = ref<false | 'keep' | 'setup'>(false)
const error = ref('') const error = ref('')
const probing = ref(false) const probing = ref(false)
const probe = ref<MeshDeviceProbe | null>(null) const probe = ref<MeshDeviceProbe | null>(null)
const probeError = ref('') const probeError = ref('')
// Time-driven probe progress: the probe RPC is a single opaque call that can
// take ~5-30s (boot settle + up to three firmware handshakes), so the bar
// advances on a clock toward 92% and snaps to 100% when the result lands.
const probeProgress = ref(0)
const probeStage = ref('Waiting for the radio to boot…')
let probeTicker: ReturnType<typeof setInterval> | null = null
function startProbeProgress() {
stopProbeProgress()
probeProgress.value = 0
probeStage.value = 'Waiting for the radio to boot…'
const startedAt = Date.now()
probeTicker = setInterval(() => {
const elapsed = (Date.now() - startedAt) / 1000
// ~92% at 30s, decelerating never looks stuck, never lies "done".
probeProgress.value = Math.min(92, 100 * (1 - Math.exp(-elapsed / 11)))
if (elapsed >= 4) probeStage.value = 'Detecting firmware…'
if (elapsed >= 18) probeStage.value = 'Still checking (radios can be slow to answer)…'
}, 400)
}
function stopProbeProgress(done = false) {
if (probeTicker) { clearInterval(probeTicker); probeTicker = null }
if (done) probeProgress.value = 100
}
const devicePath = computed(() => mesh.undismissedDetectedDevices[0] ?? '') const devicePath = computed(() => mesh.undismissedDetectedDevices[0] ?? '')
const show = computed(() => !!devicePath.value) const show = computed(() => !!devicePath.value)
const imageFailed = ref(false) const imageFailed = ref(false)
@@ -264,7 +398,10 @@ const rfPreset = computed(() => {
// (Re)probe + (re)apply presets each time a new device surfaces the modal // (Re)probe + (re)apply presets each time a new device surfaces the modal
watch([show, devicePath], async ([visible]) => { watch([show, devicePath], async ([visible]) => {
if (!visible) return if (!visible) {
stopFlashPoll()
return
}
step.value = 1 step.value = 1
error.value = '' error.value = ''
imageFailed.value = false imageFailed.value = false
@@ -275,6 +412,7 @@ watch([show, devicePath], async ([visible]) => {
probe.value = null probe.value = null
probeError.value = '' probeError.value = ''
probing.value = true probing.value = true
startProbeProgress()
const path = devicePath.value const path = devicePath.value
try { try {
const res = await mesh.probeDevice(path) const res = await mesh.probeDevice(path)
@@ -284,6 +422,7 @@ watch([show, devicePath], async ([visible]) => {
probeError.value = e instanceof Error ? e.message : String(e) probeError.value = e instanceof Error ? e.message : String(e)
} }
} finally { } finally {
stopProbeProgress(true)
if (devicePath.value === path) probing.value = false if (devicePath.value === path) probing.value = false
} }
}, { immediate: false }) }, { immediate: false })
@@ -349,6 +488,118 @@ async function applySetup() {
connecting.value = false connecting.value = false
} }
} }
// Step 3: erase + reflash
const flashFamily = ref<FlashFirmwareFamily | ''>('')
const flashBoard = ref<FlashBoard | ''>('')
const flashConfirmed = ref(false)
const starting = ref(false)
const flashJob = ref<FlashJobStatus | null>(null)
let flashPollTimer: ReturnType<typeof setInterval> | null = null
const detectedInfo = computed(() =>
mesh.status?.detected_device_info?.find(d => d.path === devicePath.value)
)
// Mirrors mesh::flash::resolve_flash_board (core/archipelago/src/mesh/flash.rs)
// exactly matching on the display label was wrong: a Heltec V3's CP2102
// bridge chip reports "CP2102 USB to UART Bridge Controller" in its USB
// strings, not "Heltec", so meshDeviceImages.ts falls back to a generic
// "LoRa radio (CP2102 serial)" label that never matched /v3/i, showing the
// "couldn't confirm automatically" warning even though the backend CAN
// safely auto-detect V3 via vid:pid. Heltec V4 deliberately has no entry
// here, same reasoning as the backend: its vid:pid (303a:1001) is the
// ESP32-S3's generic native-USB descriptor, not V4-specific, so it can't be
// safely auto-matched and always requires manual selection.
const resolvedFlashBoard = computed<FlashBoard | ''>(() => {
const info = detectedInfo.value
if (info?.vid?.toLowerCase() === '10c4' && info?.pid?.toLowerCase() === 'ea60') return 'heltec-v3'
return ''
})
const boardAutoDetected = computed(() => !!resolvedFlashBoard.value)
const flashStageLabel = computed(() => {
switch (flashJob.value?.stage) {
case 'downloading': return 'Downloading firmware…'
case 'erasing': return 'Erasing chip…'
case 'writing': return 'Writing firmware…'
case 'autoinstalling': return 'Installing (rnodeconf)…'
case 'done': return 'Flash complete'
case 'failed': return 'Flash failed'
default: return ''
}
})
function openFlashStep() {
flashFamily.value = (probe.value?.kind as FlashFirmwareFamily) ?? ''
flashBoard.value = resolvedFlashBoard.value
flashConfirmed.value = false
flashJob.value = null
error.value = ''
step.value = 'flash'
}
function stopFlashPoll() {
if (flashPollTimer) {
clearInterval(flashPollTimer)
flashPollTimer = null
}
}
async function pollFlashStatus() {
try {
const status = await mesh.flashStatus()
flashJob.value = status
if (!status.active) {
stopFlashPoll()
if (status.done && !status.error) {
// Mirrors the unplug/replug hot-swap flow: re-probe so the details
// card reflects whatever firmware is actually on the board now.
const path = devicePath.value
probing.value = true
try {
probe.value = await mesh.probeDevice(path)
} catch {
probe.value = null
} finally {
probing.value = false
}
}
}
} catch {
stopFlashPoll()
}
}
async function startFlash() {
if (!flashFamily.value || !flashBoard.value || !flashConfirmed.value) return
starting.value = true
error.value = ''
try {
await mesh.flashDevice(devicePath.value, flashFamily.value, flashBoard.value)
flashJob.value = { active: true, stage: 'downloading', log_tail: [] }
stopFlashPoll()
flashPollTimer = setInterval(pollFlashStatus, 1500)
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to start flashing'
} finally {
starting.value = false
}
}
async function cancelFlash() {
try {
await mesh.flashCancel()
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to cancel'
}
}
function closeFlashStep() {
stopFlashPoll()
step.value = 1
}
</script> </script>
<style scoped> <style scoped>
@@ -0,0 +1,107 @@
// Stale-while-revalidate resource hook over the shared resources store.
//
// Usage:
// const files = useCachedResource<CloudFile[]>({
// key: 'cloud.my-files',
// fetcher: (signal) => rpcClient.call({ method: 'content.list', signal, dedup: true }),
// ttlMs: 30_000,
// })
// // template: files.data renders instantly on revisit (cache), while
// // files.loadState === 'refreshing' drives a subtle refresh indicator.
//
// Behavior:
// - Synchronous hydrate: memory (survives navigation) → sessionStorage
// snapshot (survives reload) → fetch.
// - Sticky-ready: never regresses ready → loading; refreshes are
// 'refreshing' so content stays on screen.
// - Stale-while-revalidate: on mount, cached data is shown immediately and a
// background refresh runs only if the TTL has lapsed (or never fetched).
// - Keep-last-value on error, with `isStale`/`ageMs` for badges.
// - revalidateOnFocus: refreshes when the tab regains focus and the data is
// stale (debounced by TTL, so focus-flapping is free).
// - Abort-on-unmount: the fetcher receives an AbortSignal that fires when
// the last subscribed component unmounts.
import { computed, getCurrentScope, onScopeDispose, type ComputedRef } from 'vue'
import { useResourcesStore, type ResourceEntry, type ResourceLoadState } from '@/stores/resources'
export interface CachedResourceOptions<T> {
/** Cache key. Include identifying params, e.g. `peer-files:${onion}`. */
key: string
/** Fetch fresh data. Receives an abort signal tied to component lifetime. */
fetcher: (signal: AbortSignal) => Promise<T>
/** Data older than this triggers a background revalidate (default 30s). */
ttlMs?: number
/** Snapshot to sessionStorage so reloads paint instantly (default true).
* Disable for large payloads. */
persist?: boolean
/** Revalidate (if stale) when the window regains focus (default true). */
revalidateOnFocus?: boolean
/** Fetch on first use (default true). Set false for lazy resources. */
immediate?: boolean
}
export interface CachedResource<T> {
entry: ResourceEntry<T>
/** Convenience computed views over the entry. */
data: ComputedRef<T | null>
loadState: ComputedRef<ResourceLoadState>
error: ComputedRef<string | null>
/** True when data exists but is older than the TTL (drive an age badge). */
isStale: ComputedRef<boolean>
ageMs: ComputedRef<number | null>
/** Force a refresh now (deduped with any in-flight one). */
refresh: () => Promise<void>
/** Mark stale + debounce-refresh all mounted users of this key. */
invalidate: () => void
/** Optimistically update cached data; returns rollback for RPC failure. */
optimistic: (update: (current: T | null) => T) => () => void
}
export function useCachedResource<T>(opts: CachedResourceOptions<T>): CachedResource<T> {
const store = useResourcesStore()
const ttlMs = opts.ttlMs ?? 30_000
const persist = opts.persist ?? true
const entry = store.entry<T>(opts.key, persist)
const aborter = new AbortController()
const fetcher = () => opts.fetcher(aborter.signal)
const refresh = () => store.refresh(opts.key, fetcher, { persist })
const stale = () => entry.fetchedAt === null || Date.now() - entry.fetchedAt > ttlMs
const refreshIfStale = () => {
if (stale()) void refresh()
}
// Register as a live revalidator so invalidate(key) reaches us.
const unsubscribe = store.subscribe(opts.key, () => void refresh())
const onFocus = () => refreshIfStale()
if (opts.revalidateOnFocus ?? true) {
window.addEventListener('focus', onFocus)
}
// Tied to the owning effect scope (component setup or manual scope);
// outside any scope (tests, module init) there's nothing to dispose.
if (getCurrentScope()) {
onScopeDispose(() => {
unsubscribe()
window.removeEventListener('focus', onFocus)
aborter.abort()
})
}
if (opts.immediate ?? true) refreshIfStale()
return {
entry,
data: computed(() => entry.data),
loadState: computed(() => entry.loadState),
error: computed(() => entry.error),
isStale: computed(() => entry.data !== null && stale()),
ageMs: computed(() => (entry.fetchedAt === null ? null : Date.now() - entry.fetchedAt)),
refresh,
invalidate: () => store.invalidate(opts.key),
optimistic: (update) => store.optimistic<T>(opts.key, update),
}
}
@@ -0,0 +1,131 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useResourcesStore } from '../resources'
import { useCachedResource } from '@/composables/useCachedResource'
describe('resources store — stale-while-revalidate semantics', () => {
beforeEach(() => {
setActivePinia(createPinia())
sessionStorage.clear()
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('first fetch goes idle → loading → ready with data', async () => {
const store = useResourcesStore()
const e = store.entry<string>('k1')
expect(e.loadState).toBe('idle')
const p = store.refresh('k1', async () => 'hello')
expect(e.loadState).toBe('loading')
await p
expect(e.loadState).toBe('ready')
expect(e.data).toBe('hello')
expect(e.fetchedAt).not.toBeNull()
})
it('sticky-ready: refresh never regresses ready → loading', async () => {
const store = useResourcesStore()
await store.refresh('k2', async () => 1)
const e = store.entry<number>('k2')
const p = store.refresh('k2', async () => 2)
expect(e.loadState).toBe('refreshing')
await p
expect(e.loadState).toBe('ready')
expect(e.data).toBe(2)
})
it('keeps last-known data on refresh error (ready + error set)', async () => {
const store = useResourcesStore()
await store.refresh('k3', async () => 'good')
const e = store.entry<string>('k3')
await store.refresh('k3', async () => {
throw new Error('boom')
})
expect(e.data).toBe('good')
expect(e.loadState).toBe('ready')
expect(e.error).toBe('boom')
})
it('errors with no prior data land in error state', async () => {
const store = useResourcesStore()
await store.refresh('k4', async () => {
throw new Error('down')
})
const e = store.entry('k4')
expect(e.loadState).toBe('error')
expect(e.data).toBeNull()
})
it('dedups concurrent refreshes for the same key', async () => {
const store = useResourcesStore()
const fetcher = vi.fn(async () => 'once')
const p1 = store.refresh('k5', fetcher)
const p2 = store.refresh('k5', fetcher)
await Promise.all([p1, p2])
expect(fetcher).toHaveBeenCalledTimes(1)
})
it('hydrates a new entry from the sessionStorage snapshot', async () => {
const store = useResourcesStore()
await store.refresh('k6', async () => ({ n: 42 }))
// Fresh pinia = fresh memory cache, same sessionStorage.
setActivePinia(createPinia())
const store2 = useResourcesStore()
const e = store2.entry<{ n: number }>('k6')
expect(e.loadState).toBe('ready')
expect(e.data).toEqual({ n: 42 })
})
it('optimistic update applies immediately and rollback restores', async () => {
const store = useResourcesStore()
await store.refresh('k7', async () => ['a'])
const e = store.entry<string[]>('k7')
const rollback = store.optimistic<string[]>('k7', (cur) => [...(cur ?? []), 'b'])
expect(e.data).toEqual(['a', 'b'])
rollback()
expect(e.data).toEqual(['a'])
})
it('invalidate marks stale and debounce-runs subscribers', async () => {
const store = useResourcesStore()
await store.refresh('k8', async () => 1)
const revalidate = vi.fn()
store.subscribe('k8', revalidate)
store.invalidate('k8')
expect(store.entry('k8').fetchedAt).toBeNull()
expect(revalidate).not.toHaveBeenCalled()
vi.advanceTimersByTime(900)
expect(revalidate).toHaveBeenCalledTimes(1)
})
})
describe('useCachedResource composable', () => {
beforeEach(() => {
setActivePinia(createPinia())
sessionStorage.clear()
})
it('fetches immediately when stale and exposes reactive views', async () => {
const fetcher = vi.fn(async () => 'data')
const r = useCachedResource<string>({ key: 'c1', fetcher, revalidateOnFocus: false })
await r.refresh()
expect(fetcher).toHaveBeenCalled()
expect(r.data.value).toBe('data')
expect(r.loadState.value).toBe('ready')
expect(r.isStale.value).toBe(false)
})
it('does not refetch within TTL (instant render from cache)', async () => {
const fetcher = vi.fn(async () => 'v1')
const r1 = useCachedResource<string>({ key: 'c2', fetcher, ttlMs: 60_000, revalidateOnFocus: false })
await r1.refresh()
// Second component using the same key inside the TTL: no new fetch.
const fetcher2 = vi.fn(async () => 'v2')
const r2 = useCachedResource<string>({ key: 'c2', fetcher: fetcher2, ttlMs: 60_000, revalidateOnFocus: false })
expect(r2.data.value).toBe('v1')
expect(fetcher2).not.toHaveBeenCalled()
})
})
+27 -11
View File
@@ -8,6 +8,11 @@ export const useCloudStore = defineStore('cloud', () => {
const loading = ref(false) const loading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
const authenticated = ref(false) const authenticated = ref(false)
// Per-path listing cache: re-entering a folder paints the last listing
// immediately (no spinner) while the fresh listing loads behind it.
const pathCache = new Map<string, FileBrowserItem[]>()
// Last-wins guard for overlapping navigations (fast folder hopping).
let navSeq = 0
const breadcrumbs = computed(() => { const breadcrumbs = computed(() => {
const parts = currentPath.value.split('/').filter(Boolean) const parts = currentPath.value.split('/').filter(Boolean)
@@ -36,7 +41,22 @@ export const useCloudStore = defineStore('cloud', () => {
} }
async function navigate(path: string): Promise<void> { async function navigate(path: string): Promise<void> {
const seq = ++navSeq
const apply = (p: string, result: FileBrowserItem[]) => {
pathCache.set(p, result)
if (seq !== navSeq) return // a newer navigation superseded this one
items.value = result
currentPath.value = p
}
// Stale-while-revalidate: show the cached listing for this path
// immediately (no spinner), then refresh it underneath.
const cached = pathCache.get(path)
if (cached) {
items.value = cached
currentPath.value = path
} else {
loading.value = true loading.value = true
}
error.value = null error.value = null
try { try {
if (!authenticated.value) { if (!authenticated.value) {
@@ -47,9 +67,7 @@ export const useCloudStore = defineStore('cloud', () => {
} }
} }
try { try {
const result = await fileBrowserClient.listDirectory(path) apply(path, await fileBrowserClient.listDirectory(path))
items.value = result
currentPath.value = path
} catch { } catch {
// Directory may not exist — try to create it, then retry // Directory may not exist — try to create it, then retry
if (path !== '/') { if (path !== '/') {
@@ -57,23 +75,20 @@ export const useCloudStore = defineStore('cloud', () => {
const parentPath = path.substring(0, path.lastIndexOf('/')) || '/' const parentPath = path.substring(0, path.lastIndexOf('/')) || '/'
const dirName = path.substring(path.lastIndexOf('/') + 1) const dirName = path.substring(path.lastIndexOf('/') + 1)
await fileBrowserClient.createFolder(parentPath, dirName) await fileBrowserClient.createFolder(parentPath, dirName)
const result = await fileBrowserClient.listDirectory(path) apply(path, await fileBrowserClient.listDirectory(path))
items.value = result
currentPath.value = path
} catch { } catch {
// Fall back to root // Fall back to root
const result = await fileBrowserClient.listDirectory('/') apply('/', await fileBrowserClient.listDirectory('/'))
items.value = result
currentPath.value = '/'
} }
} else { } else {
throw new Error('Failed to list root directory') throw new Error('Failed to list root directory')
} }
} }
} catch (e) { } catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to load files' // Keep showing the cached listing on a failed revalidate.
if (!cached) error.value = e instanceof Error ? e.message : 'Failed to load files'
} finally { } finally {
loading.value = false if (seq === navSeq) loading.value = false
} }
} }
@@ -112,6 +127,7 @@ export const useCloudStore = defineStore('cloud', () => {
items.value = [] items.value = []
loading.value = false loading.value = false
error.value = null error.value = null
pathCache.clear()
} }
return { return {
+65 -1
View File
@@ -57,6 +57,23 @@ export interface MeshDeviceProbe {
max_contacts: number | null max_contacts: number | null
} }
export type FlashFirmwareFamily = 'meshcore' | 'meshtastic' | 'reticulum'
export type FlashBoard = 'heltec-v3' | 'heltec-v4'
export type FlashStage = 'downloading' | 'erasing' | 'writing' | 'autoinstalling' | 'done' | 'failed'
/** Live progress for the one flash job that can run at a time. */
export interface FlashJobStatus {
active: boolean
board?: FlashBoard
family?: FlashFirmwareFamily
path?: string
stage?: FlashStage
percent?: number | null
log_tail?: string[]
done?: boolean
error?: string | null
}
/** Params accepted by mesh.configure (superset of the status fields). */ /** Params accepted by mesh.configure (superset of the status fields). */
export interface MeshConfigureParams { export interface MeshConfigureParams {
enabled?: boolean enabled?: boolean
@@ -274,12 +291,16 @@ export const useMeshStore = defineStore('mesh', () => {
async function fetchStatus() { async function fetchStatus() {
try { try {
loading.value = true loading.value = true
error.value = null
const res = await rpcClient.call<MeshStatus>({ method: 'mesh.status' }) const res = await rpcClient.call<MeshStatus>({ method: 'mesh.status' })
status.value = res status.value = res
trackDetectedDevices(res) trackDetectedDevices(res)
} catch (err: unknown) { } catch (err: unknown) {
// Don't clobber a user-action error (broadcast/configure/send) — this
// runs on a 5s poll, and the old `error.value = null` on entry meant
// any real error banner survived at most one poll tick.
if (!error.value) {
error.value = err instanceof Error ? err.message : 'Failed to fetch mesh status' error.value = err instanceof Error ? err.message : 'Failed to fetch mesh status'
}
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -358,6 +379,32 @@ export const useMeshStore = defineStore('mesh', () => {
timeout: 45000, // serial probes are slow (multi-firmware handshakes) timeout: 45000, // serial probes are slow (multi-firmware handshakes)
}) })
} }
/** Available firmware version(s) for a family v1 only ever returns
* ["latest"], since firmware is always fetched from upstream at flash
* time rather than pinned/bundled. */
async function flashListFirmware(family: FlashFirmwareFamily): Promise<string[]> {
const res = await rpcClient.call<{ versions: string[] }>({
method: 'mesh.flash-list-firmware',
params: { family },
})
return res.versions
}
/** Erase + reflash a detected radio. `board` is optional omit it to let
* the backend auto-resolve from the port's USB vid:pid; if that fails
* (e.g. Heltec V4 not yet in the vid:pid table), it errors and the UI
* must ask the user to pick the board explicitly. Always erases first. */
async function flashDevice(path: string, family: FlashFirmwareFamily, board?: FlashBoard): Promise<void> {
await rpcClient.call({
method: 'mesh.flash-device',
params: board ? { path, family, board } : { path, family },
})
}
async function flashStatus(): Promise<FlashJobStatus> {
return rpcClient.call<FlashJobStatus>({ method: 'mesh.flash-status' })
}
async function flashCancel(): Promise<void> {
await rpcClient.call({ method: 'mesh.flash-cancel' })
}
let globalDetectTimer: ReturnType<typeof setInterval> | null = null let globalDetectTimer: ReturnType<typeof setInterval> | null = null
/** App-wide light poll so the detected-device modal works on every page /** App-wide light poll so the detected-device modal works on every page
* (the Mesh view's own 5s poll takes over while it is mounted). */ * (the Mesh view's own 5s poll takes over while it is mounted). */
@@ -975,6 +1022,18 @@ export const useMeshStore = defineStore('mesh', () => {
await Promise.all([fetchStatus(), fetchPeers(), fetchMessages(), fetchDeadmanStatus(), fetchBlockHeaders()]) await Promise.all([fetchStatus(), fetchPeers(), fetchMessages(), fetchDeadmanStatus(), fetchBlockHeaders()])
} }
/** Ask the backend to actively re-query the radio's contact table (and by
* extension re-drain daemon events for Reticulum) the server-side half
* of the Refresh button; refreshAll() alone only re-reads caches. */
async function refreshRadio(): Promise<boolean> {
try {
const res = await rpcClient.call<{ refreshed: boolean }>({ method: 'mesh.refresh' })
return !!res.refreshed
} catch {
return false
}
}
return { return {
status, status,
peers, peers,
@@ -994,6 +1053,10 @@ export const useMeshStore = defineStore('mesh', () => {
undismissedDetectedDevices, undismissedDetectedDevices,
dismissDetectedDevice, dismissDetectedDevice,
probeDevice, probeDevice,
flashListFirmware,
flashDevice,
flashStatus,
flashCancel,
startGlobalDetection, startGlobalDetection,
fetchPeers, fetchPeers,
fetchMessages, fetchMessages,
@@ -1002,6 +1065,7 @@ export const useMeshStore = defineStore('mesh', () => {
broadcastIdentity, broadcastIdentity,
configure, configure,
refreshAll, refreshAll,
refreshRadio,
markChatRead, markChatRead,
clearViewingChat, clearViewingChat,
sendInvoice, sendInvoice,
+166
View File
@@ -0,0 +1,166 @@
// Shared cache for RPC-backed page data (the "stale-while-revalidate" layer).
//
// Pages used to fetch-on-mount with a spinner on every navigation — Dashboard
// keys its router-view by route.path, so each visit unmounted and refetched
// everything. This store is the single place resource state lives instead:
// keyed entries survive navigation (Pinia) and reloads (sessionStorage
// snapshot), and `useCachedResource` renders them instantly while
// revalidating in the background.
//
// Semantics (generalized from homeStatus.ts / useFleetData.ts, the proven
// hand-rolled versions):
// - sticky-ready: once a key is 'ready' it never regresses to 'loading';
// refreshes show as 'refreshing' so the UI keeps the data visible.
// - keep-last-known-value on error: a failed revalidate leaves data in place
// (with `error` set and `fetchedAt` untouched → age badge shows staleness).
// - in-flight dedup per key: concurrent refreshes collapse into one fetch.
import { defineStore } from 'pinia'
import { reactive } from 'vue'
export type ResourceLoadState = 'idle' | 'loading' | 'ready' | 'refreshing' | 'error'
export interface ResourceEntry<T = unknown> {
data: T | null
loadState: ResourceLoadState
/** Epoch ms of the last SUCCESSFUL fetch (drives TTL + stale badges). */
fetchedAt: number | null
error: string | null
}
const SNAPSHOT_PREFIX = 'resource:'
function readSnapshot<T>(key: string): { data: T; fetchedAt: number } | null {
try {
const raw = sessionStorage.getItem(SNAPSHOT_PREFIX + key)
if (!raw) return null
const parsed = JSON.parse(raw)
if (parsed && typeof parsed.fetchedAt === 'number' && 'data' in parsed) return parsed
} catch {
/* corrupt/absent snapshot — fall through to a fresh fetch */
}
return null
}
function writeSnapshot(key: string, data: unknown, fetchedAt: number): void {
try {
sessionStorage.setItem(SNAPSHOT_PREFIX + key, JSON.stringify({ data, fetchedAt }))
} catch {
/* quota exceeded or unserializable — memory cache still works */
}
}
export const useResourcesStore = defineStore('resources', () => {
const entries = reactive(new Map<string, ResourceEntry>())
// Non-reactive bookkeeping: in-flight fetches + active revalidators.
const inflight = new Map<string, Promise<void>>()
const revalidators = new Map<string, Set<() => void>>()
const invalidateTimers = new Map<string, ReturnType<typeof setTimeout>>()
/** Get (or create) the reactive entry for a key, hydrating from the
* sessionStorage snapshot on first sight so revisits after a reload paint
* before any RPC completes. Pass `persist: false` to skip snapshots. */
function entry<T>(key: string, persist = true): ResourceEntry<T> {
let e = entries.get(key)
if (!e) {
const snap = persist ? readSnapshot<T>(key) : null
e = reactive<ResourceEntry>({
data: snap ? snap.data : null,
loadState: snap ? 'ready' : 'idle',
fetchedAt: snap ? snap.fetchedAt : null,
error: null,
})
entries.set(key, e)
}
return e as ResourceEntry<T>
}
/** Run `fetcher` for `key` with sticky-ready + keep-last-value semantics.
* Concurrent calls for the same key share one in-flight fetch. */
function refresh<T>(
key: string,
fetcher: () => Promise<T>,
opts: { persist?: boolean } = {},
): Promise<void> {
const existing = inflight.get(key)
if (existing) return existing
const e = entry<T>(key, opts.persist ?? true)
e.loadState = e.loadState === 'ready' || e.loadState === 'refreshing' ? 'refreshing' : 'loading'
const p = (async () => {
try {
const data = await fetcher()
e.data = data
e.error = null
e.fetchedAt = Date.now()
e.loadState = 'ready'
if (opts.persist ?? true) writeSnapshot(key, data, e.fetchedAt)
} catch (err) {
e.error = err instanceof Error ? err.message : String(err)
// Keep last-known data visible; only 'error' when we have nothing.
e.loadState = e.data !== null ? 'ready' : 'error'
} finally {
inflight.delete(key)
}
})()
inflight.set(key, p)
return p
}
/** Mark a key stale and (debounced) re-run every mounted subscriber's
* fetcher. Call after a mutation or on a relevant WS push. */
function invalidate(key: string, opts: { debounceMs?: number } = {}): void {
const e = entries.get(key)
if (e) e.fetchedAt = null
const subs = revalidators.get(key)
if (!subs || subs.size === 0) return
const t = invalidateTimers.get(key)
if (t) clearTimeout(t)
invalidateTimers.set(
key,
setTimeout(() => {
invalidateTimers.delete(key)
for (const fn of subs) fn()
}, opts.debounceMs ?? 800),
)
}
/** Register a live revalidator for a key (used by useCachedResource);
* returns an unsubscribe fn. */
function subscribe(key: string, revalidate: () => void): () => void {
let subs = revalidators.get(key)
if (!subs) {
subs = new Set()
revalidators.set(key, subs)
}
subs.add(revalidate)
return () => {
subs.delete(revalidate)
}
}
/** Optimistically apply `update` to the cached value; returns a rollback.
* Pattern: rollback on RPC failure (generalized TransportPrefsCard). */
function optimistic<T>(key: string, update: (current: T | null) => T): () => void {
const e = entry<T>(key)
const before = e.data
const beforeState = e.loadState
e.data = update(before)
if (e.loadState === 'idle' || e.loadState === 'error') e.loadState = 'ready'
return () => {
e.data = before
e.loadState = beforeState
}
}
/** Drop a key entirely (memory + snapshot). */
function evict(key: string): void {
entries.delete(key)
try {
sessionStorage.removeItem(SNAPSHOT_PREFIX + key)
} catch {
/* noop */
}
}
return { entries, entry, refresh, invalidate, subscribe, optimistic, evict }
})
+31 -1
View File
@@ -2,9 +2,38 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import type { DataModel } from '../types/api' import type { DataModel, PatchOperation } from '../types/api'
import { wsClient, applyDataPatch } from '../api/websocket' import { wsClient, applyDataPatch } from '../api/websocket'
import { rpcClient } from '../api/rpc-client' import { rpcClient } from '../api/rpc-client'
import { useResourcesStore } from './resources'
/** Unescape one JSON-pointer segment (RFC 6901: ~1 → '/', ~0 → '~'). */
function pointerSegment(path: string, prefix: string): string {
const seg = path.slice(prefix.length).split('/')[0] ?? ''
return seg.replace(/~1/g, '/').replace(/~0/g, '~')
}
/** B5: bridge /ws/db pushes into the cached-resource layer. Each patch op
* maps to the resource keys whose backing data it changes; invalidate()
* debounces (800ms) and only refetches keys with mounted subscribers, so a
* patch storm costs one revalidation per key. The 30s staleness
* reconciliation stays as the backstop for anything unmapped. */
function invalidateResourcesForPatch(patch: PatchOperation[]): void {
const resources = useResourcesStore()
for (const op of patch) {
const path = op.path ?? ''
if (path.startsWith('/peer-health/')) {
// A peer flipping reachability changes both its browse result and the
// federation node list's online state.
const onion = pointerSegment(path, '/peer-health/')
if (onion) resources.invalidate(`cloud.peer-browse:${onion}`)
resources.invalidate('federation.nodes')
} else if (path.startsWith('/package-data/')) {
// App installs/uninstalls add or remove their tor services.
resources.invalidate('server.tor-services')
}
}
}
export const useSyncStore = defineStore('sync', () => { export const useSyncStore = defineStore('sync', () => {
// State // State
@@ -108,6 +137,7 @@ export const useSyncStore = defineStore('sync', () => {
try { try {
if (import.meta.env.DEV) console.log('[Store] Applying patch at revision', update.rev || 'unknown') if (import.meta.env.DEV) console.log('[Store] Applying patch at revision', update.rev || 'unknown')
data.value = applyDataPatch(data.value, update.patch) data.value = applyDataPatch(data.value, update.patch)
invalidateResourcesForPatch(update.patch)
// Mark as connected once we receive any valid patch // Mark as connected once we receive any valid patch
if (!isConnected.value) { if (!isConnected.value) {
isConnected.value = true isConnected.value = true
+187 -82
View File
@@ -194,7 +194,7 @@
Open Federation Open Federation
</RouterLink> </RouterLink>
</div> </div>
<div v-else-if="filteredPeerFiles.length === 0" class="glass-card p-8 text-center text-white/40 text-sm"> <div v-else-if="filteredPeerFiles.length === 0 && peerFilesPending === 0" class="glass-card p-8 text-center text-white/40 text-sm">
{{ selectedCategory === 'all' ? 'Your peers are not sharing any files yet.' : 'No peer files in this category.' }} {{ selectedCategory === 'all' ? 'Your peers are not sharing any files yet.' : 'No peer files in this category.' }}
</div> </div>
<div v-else class="space-y-2"> <div v-else class="space-y-2">
@@ -216,6 +216,13 @@
<span class="text-[10px] px-2 py-0.5 rounded-full bg-purple-500/15 text-purple-400 shrink-0">{{ f.peerName }}</span> <span class="text-[10px] px-2 py-0.5 rounded-full bg-purple-500/15 text-purple-400 shrink-0">{{ f.peerName }}</span>
</button> </button>
</div> </div>
<p v-if="peerFilesPending > 0" class="text-[11px] text-white/35 text-center mt-3 flex items-center justify-center gap-2">
<svg class="animate-spin h-3 w-3" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Still fetching from {{ peerFilesPending }} peer{{ peerFilesPending === 1 ? '' : 's' }}
</p>
<p v-if="peerFilesErrors > 0" class="text-[11px] text-white/35 text-center mt-3"> <p v-if="peerFilesErrors > 0" class="text-[11px] text-white/35 text-center mt-3">
{{ peerFilesErrors }} peer{{ peerFilesErrors === 1 ? '' : 's' }} unreachable showing what answered. {{ peerFilesErrors }} peer{{ peerFilesErrors === 1 ? '' : 's' }} unreachable showing what answered.
</p> </p>
@@ -301,12 +308,23 @@
<span class="w-1.5 h-1.5 rounded-full" :class="peer.trust_level === 'trusted' ? 'bg-green-400' : 'bg-purple-400'"></span> <span class="w-1.5 h-1.5 rounded-full" :class="peer.trust_level === 'trusted' ? 'bg-green-400' : 'bg-purple-400'"></span>
{{ peer.trust_level }} {{ peer.trust_level }}
</span> </span>
<span class="text-white/30">Peer Node</span> <!-- Live transport badge which route actually served the last
browse (FIPS = direct mesh, fast; Tor = fallback, slow). -->
<span
v-if="peerTransport(peer.onion)"
class="inline-flex items-center gap-1.5 px-2 py-1 rounded-full"
:class="peerTransport(peer.onion)!.transport === 'fips' ? 'bg-emerald-500/15 text-emerald-300' : 'bg-amber-500/15 text-amber-300'"
:title="`Last browse served via ${peerTransport(peer.onion)!.transport.toUpperCase()}`"
>
<span class="w-1.5 h-1.5 rounded-full" :class="peerTransport(peer.onion)!.transport === 'fips' ? 'bg-emerald-400' : 'bg-amber-400'"></span>
{{ peerTransport(peer.onion)!.transport.toUpperCase() }} · {{ (peerTransport(peer.onion)!.latencyMs / 1000).toFixed(1) }}s
</span>
<span v-else class="text-white/30">Peer Node</span>
</div> </div>
</div> </div>
<div <div
v-if="peersLoading && peerNodes.length > 0" v-if="(peersLoading || peersRefreshing) && peerNodes.length > 0"
class="glass-card p-3 text-center text-white/45 text-xs md:col-span-2 lg:col-span-3 flex items-center justify-center gap-2" class="glass-card p-3 text-center text-white/45 text-xs md:col-span-2 lg:col-span-3 flex items-center justify-center gap-2"
> >
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24"> <svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
@@ -388,6 +406,8 @@ import { computed, ref, watch, onMounted } from 'vue'
import { useRouter, RouterLink } from 'vue-router' import { useRouter, RouterLink } from 'vue-router'
import { useAppStore } from '../stores/app' import { useAppStore } from '../stores/app'
import { useCloudStore } from '../stores/cloud' import { useCloudStore } from '../stores/cloud'
import { useResourcesStore } from '../stores/resources'
import { useCachedResource } from '../composables/useCachedResource'
import { fileBrowserClient, type FileBrowserItem } from '@/api/filebrowser-client' import { fileBrowserClient, type FileBrowserItem } from '@/api/filebrowser-client'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
import { getFileCategory } from '../composables/useFileType' import { getFileCategory } from '../composables/useFileType'
@@ -399,9 +419,19 @@ import MediaLightbox from '../components/cloud/MediaLightbox.vue'
const router = useRouter() const router = useRouter()
const store = useAppStore() const store = useAppStore()
const cloudStore = useCloudStore() const cloudStore = useCloudStore()
const resources = useResourcesStore()
const audioPlayer = useAudioPlayer() const audioPlayer = useAudioPlayer()
const sectionCounts = ref<Record<string, number>>({})
const countsLoading = ref(false) // Section counts cached: revisits render the last-known counts instantly
// and refresh in the background (sticky-ready never regresses to "Loading").
const countsResource = useCachedResource<Record<string, number>>({
key: 'cloud.section-counts',
fetcher: fetchCounts,
ttlMs: 30_000,
immediate: false, // gated on fileBrowserRunning; kicked from onMounted/watch
})
const sectionCounts = computed(() => countsResource.entry.data ?? {})
const countsLoading = computed(() => countsResource.entry.loadState === 'loading')
// Tabs / categories / search state // Tabs / categories / search state
type TabId = 'folders' | 'mine' | 'peers' | 'paid' type TabId = 'folders' | 'mine' | 'peers' | 'paid'
@@ -424,14 +454,18 @@ const activeTab = ref<TabId>('folders')
// Paid Files tab // Paid Files tab
interface PaidItem { onion: string; content_id: string; filename: string; mime_type: string; size_bytes: number; paid_sats: number; purchased_at: string } interface PaidItem { onion: string; content_id: string; filename: string; mime_type: string; size_bytes: number; paid_sats: number; purchased_at: string }
const paidItems = ref<PaidItem[]>([]) const paidResource = useCachedResource<PaidItem[]>({
const paidLoading = ref(false) key: 'cloud.paid-items',
async function loadPaidItems() { fetcher: async (signal) => {
paidLoading.value = true const res = await rpcClient.call<{ items: PaidItem[] }>({ method: 'content.owned-list', signal, dedup: true })
try { return (res.items || []).slice().reverse()
const res = await rpcClient.call<{ items: PaidItem[] }>({ method: 'content.owned-list' }) },
paidItems.value = (res.items || []).slice().reverse() immediate: false, // loaded when the Paid tab is opened
} catch { paidItems.value = [] } finally { paidLoading.value = false } })
const paidItems = computed(() => paidResource.entry.data ?? [])
const paidLoading = computed(() => paidResource.entry.loadState === 'loading')
function loadPaidItems() {
return paidResource.refresh()
} }
async function viewPaidItem(it: PaidItem) { async function viewPaidItem(it: PaidItem) {
try { try {
@@ -473,8 +507,21 @@ interface PeerNode {
trust_level: string trust_level: string
} }
const peerNodes = ref<PeerNode[]>([]) // Federation peers cached so the Folders tab's peer cards paint instantly
const peersLoading = ref(true) // on revisit while the list revalidates behind them.
const peersResource = useCachedResource<PeerNode[]>({
key: 'cloud.peer-nodes',
fetcher: async (signal) => {
const result = await rpcClient.federationListNodes()
void signal
return result?.nodes ?? []
},
ttlMs: 30_000,
immediate: false, // kicked from onMounted (keeps the legacy load order)
})
const peerNodes = computed(() => peersResource.entry.data ?? [])
const peersLoading = computed(() => peersResource.entry.loadState === 'loading')
const peersRefreshing = computed(() => peersResource.entry.loadState === 'refreshing')
const loadError = ref('') const loadError = ref('')
const APP_ALIASES: Record<string, string[]> = { const APP_ALIASES: Record<string, string[]> = {
@@ -579,18 +626,22 @@ function formatSize(bytes: number): string {
} }
// My Files (flat list of every own file across the sections) // My Files (flat list of every own file across the sections)
const myFiles = ref<FileBrowserItem[]>([]) // Cached: revisiting the tab renders the last walk instantly and re-walks in
const myFilesLoading = ref(false) // the background only when stale.
const myFilesLoaded = ref(false) const myFilesResource = useCachedResource<FileBrowserItem[]>({
key: 'cloud.my-files',
fetcher: fetchMyFiles,
ttlMs: 60_000,
immediate: false, // loaded when the My Files tab (or search) needs it
})
const myFiles = computed(() => myFilesResource.entry.data ?? [])
const myFilesLoading = computed(() => myFilesResource.entry.loadState === 'loading')
/** Depth-limited walk of the section folders; flat file list, capped. */ /** Depth-limited walk of the section folders; flat file list, capped. */
async function loadMyFiles(force = false) { async function fetchMyFiles(): Promise<FileBrowserItem[]> {
if (myFilesLoading.value || (myFilesLoaded.value && !force)) return if (!fileBrowserRunning.value) return []
if (!fileBrowserRunning.value) { myFilesLoaded.value = true; return }
myFilesLoading.value = true
try {
const ok = await cloudStore.init() const ok = await cloudStore.init()
if (!ok) return if (!ok) return []
const out: FileBrowserItem[] = [] const out: FileBrowserItem[] = []
for (const [sectionId, root] of Object.entries(SECTION_PATHS)) { for (const [sectionId, root] of Object.entries(SECTION_PATHS)) {
if (sectionId === 'files') continue // '/' would double-visit the sections if (sectionId === 'files') continue // '/' would double-visit the sections
@@ -610,11 +661,16 @@ async function loadMyFiles(force = false) {
} }
} }
out.sort((a, b) => a.name.localeCompare(b.name)) out.sort((a, b) => a.name.localeCompare(b.name))
myFiles.value = out return out
myFilesLoaded.value = true }
} finally {
myFilesLoading.value = false /** Load if never fetched or stale; `force` always re-walks. */
function loadMyFiles(force = false): Promise<void> {
if (force) return myFilesResource.refresh()
if (myFilesResource.entry.data === null || myFilesResource.isStale.value) {
return myFilesResource.refresh()
} }
return Promise.resolve()
} }
const filteredMyFiles = computed(() => const filteredMyFiles = computed(() =>
@@ -668,7 +724,8 @@ function handlePreview(path: string, context: FileBrowserItem[]) {
async function handleDelete(path: string) { async function handleDelete(path: string) {
try { try {
await cloudStore.deleteItem(path) await cloudStore.deleteItem(path)
myFiles.value = myFiles.value.filter(f => f.path !== path) // Delete confirmed update the cache in place (no rollback needed).
myFilesResource.optimistic((cur) => (cur ?? []).filter(f => f.path !== path))
searchResults.value = searchResults.value.filter(r => r.item?.path !== path) searchResults.value = searchResults.value.filter(r => r.item?.path !== path)
} catch (e) { } catch (e) {
loadError.value = e instanceof Error ? e.message : 'Delete failed' loadError.value = e instanceof Error ? e.message : 'Delete failed'
@@ -686,11 +743,6 @@ interface PeerFileEntry {
peerOnion: string peerOnion: string
} }
const peerFiles = ref<PeerFileEntry[]>([])
const peerFilesLoading = ref(false)
const peerFilesLoaded = ref(false)
const peerFilesErrors = ref(0)
interface CatalogItem { interface CatalogItem {
id: string id: string
filename: string filename: string
@@ -704,29 +756,54 @@ function priceOf(access: CatalogItem['access']): number {
return typeof access === 'object' && access?.paid ? access.paid.price_sats : 0 return typeof access === 'object' && access?.paid ? access.paid.price_sats : 0
} }
/** Fan out content.browse-peer over every federation node; tolerate stragglers. */ // Per-peer browse results live as individual cached entries so (a) each
async function loadPeerFiles(force = false) { // peer's card/rows render the moment THAT peer answers no more blocking on
if (peerFilesLoading.value || (peerFilesLoaded.value && !force)) return // the slowest peer via Promise.allSettled and (b) revisits paint from
peerFilesLoading.value = true // cache. The response's `transport` (fips/tor) + measured latency ride
peerFilesErrors.value = 0 // along, giving every peer a live transport badge.
try { interface PeerBrowse {
if (peerNodes.value.length === 0) await loadPeers() items: CatalogItem[]
const results = await Promise.allSettled( transport: string | null
peerNodes.value.map(async (peer) => { latencyMs: number
const res = await rpcClient.call<{ items?: CatalogItem[] }>({ }
const peerBrowseKey = (onion: string) => `cloud.peer-browse:${onion}`
function browsePeer(peer: PeerNode): Promise<void> {
return resources.refresh<PeerBrowse>(peerBrowseKey(peer.onion), async () => {
const t0 = Date.now()
const res = await rpcClient.call<{ items?: CatalogItem[]; transport?: string }>({
method: 'content.browse-peer', method: 'content.browse-peer',
params: { onion: peer.onion }, params: { onion: peer.onion },
timeout: 30000, timeout: 30000,
// One slow/unreachable peer must cost its timeout ONCE, not ×3
// the retry loop is why one dead peer meant a 90s spinner.
maxRetries: 1,
dedup: true,
}) })
return { peer, items: res?.items ?? [] } return { items: res?.items ?? [], transport: res?.transport ?? null, latencyMs: Date.now() - t0 }
}), })
) }
function peerBrowseEntry(onion: string) {
return resources.entry<PeerBrowse>(peerBrowseKey(onion))
}
/** Transport badge data for a peer (null until its first browse resolves). */
function peerTransport(onion: string): { transport: string; latencyMs: number } | null {
const e = peerBrowseEntry(onion)
if (!e.data?.transport) return null
return { transport: e.data.transport, latencyMs: e.data.latencyMs }
}
/** Aggregated peer files, incrementally updated as each peer resolves. */
const peerFiles = computed<PeerFileEntry[]>(() => {
const merged: PeerFileEntry[] = [] const merged: PeerFileEntry[] = []
for (const r of results) { for (const peer of peerNodes.value) {
if (r.status !== 'fulfilled') { peerFilesErrors.value++; continue } const e = peerBrowseEntry(peer.onion)
const { peer, items } = r.value if (!e.data) continue
const peerName = peer.name || peerDisplayName(peer.did) const peerName = peer.name || peerDisplayName(peer.did)
for (const item of items) { for (const item of e.data.items) {
merged.push({ merged.push({
key: `${peer.onion}:${item.id}`, key: `${peer.onion}:${item.id}`,
filename: item.filename, filename: item.filename,
@@ -739,11 +816,36 @@ async function loadPeerFiles(force = false) {
} }
} }
merged.sort((a, b) => a.filename.localeCompare(b.filename)) merged.sort((a, b) => a.filename.localeCompare(b.filename))
peerFiles.value = merged return merged
peerFilesLoaded.value = true })
} finally {
peerFilesLoading.value = false /** Peers still on their first in-flight browse (nothing cached yet). */
} const peerFilesPending = computed(() =>
peerNodes.value.filter(p => {
const s = peerBrowseEntry(p.onion).loadState
return s === 'loading' || s === 'idle'
}).length,
)
/** Peers whose browse failed with no cached data to show. */
const peerFilesErrors = computed(() =>
peerNodes.value.filter(p => peerBrowseEntry(p.onion).loadState === 'error').length,
)
/** All-or-nothing spinner ONLY when nothing has ever been cached. */
const peerFilesLoading = computed(() =>
peerNodes.value.length > 0 && peerFiles.value.length === 0 && peerFilesPending.value > 0 && peerFilesErrors.value < peerNodes.value.length,
)
/** Fan out content.browse-peer; each peer renders as it resolves. */
async function loadPeerFiles(force = false) {
if (peerNodes.value.length === 0) await loadPeers()
const targets = peerNodes.value.filter(p => {
if (force) return true
const e = peerBrowseEntry(p.onion)
const stale = e.fetchedAt === null || Date.now() - e.fetchedAt > 30_000
return e.loadState === 'idle' || e.loadState === 'error' ? true : stale
})
// Fire-and-collect: the computed aggregation updates per resolution.
await Promise.allSettled(targets.map(p => browsePeer(p)))
} }
const filteredPeerFiles = computed(() => const filteredPeerFiles = computed(() =>
@@ -821,47 +923,50 @@ const searchMineItems = computed(() =>
) )
// Existing counts / peers loading // Existing counts / peers loading
async function loadCounts() { async function fetchCounts(): Promise<Record<string, number>> {
if (!fileBrowserRunning.value) return if (!fileBrowserRunning.value) return {}
countsLoading.value = true
try {
const ok = await fileBrowserClient.login() const ok = await fileBrowserClient.login()
if (!ok) return if (!ok) throw new Error('File Browser login failed')
const counts: Record<string, number> = {}
for (const section of contentSections) { for (const section of contentSections) {
const path = SECTION_PATHS[section.id] const path = SECTION_PATHS[section.id]
if (!path) continue if (!path) continue
try { try {
const items = await fileBrowserClient.listDirectory(path) counts[section.id] = (await fileBrowserClient.listDirectory(path)).length
sectionCounts.value[section.id] = items.length
} catch { } catch {
sectionCounts.value[section.id] = 0 counts[section.id] = 0
} }
} }
} catch (e) { return counts
loadError.value = e instanceof Error ? e.message : 'Failed to load file counts' }
if (import.meta.env.DEV) console.warn('FileBrowser count loading failed', e)
} finally { function loadCounts() {
countsLoading.value = false if (countsResource.entry.data === null || countsResource.isStale.value) {
void countsResource.refresh()
} }
} }
onMounted(() => { onMounted(async () => {
loadCounts() loadCounts()
loadPeers() await loadPeers()
// Warm the per-peer browse cache in the background: peer cards get their
// FIPS/Tor badge and the Peer Files tab is instant. Staleness-gated, so
// quick revisits don't refetch.
void loadPeerFiles()
})
// File Browser can finish its startup scan after we mount pick counts up
// the moment it becomes available instead of showing a permanent blank.
watch(fileBrowserRunning, (running) => {
if (running) loadCounts()
}) })
async function loadPeers() { async function loadPeers() {
const hadPeers = peerNodes.value.length > 0 await peersResource.refresh()
peersLoading.value = true // Surface refresh failures in the banner the cached peer list stays
try { // visible either way (keep-last-known-value).
const result = await rpcClient.federationListNodes() const e = peersResource.entry
peerNodes.value = result?.nodes ?? [] if (e.error) loadError.value = e.error
} catch (e) {
if (!hadPeers) peerNodes.value = []
loadError.value = e instanceof Error ? e.message : 'Failed to load peer nodes'
} finally {
peersLoading.value = false
}
} }
function peerDisplayName(did: string): string { function peerDisplayName(did: string): string {
+4 -4
View File
@@ -306,12 +306,12 @@ const backLabel = computed(() => {
return atSectionRoot.value ? 'Back to Cloud' : 'Back to Parent Folder' return atSectionRoot.value ? 'Back to Cloud' : 'Back to Parent Folder'
}) })
// Initialize native file browser when entering a native-UI section // Initialize native file browser when entering a native-UI section.
// No reset() here: navigate() serves the per-path cache instantly and
// revalidates underneath resetting wiped the listing and forced a
// spinner on every folder entry.
watch([useNativeUI, section, routeFolderPath], async ([native, sec, path]) => { watch([useNativeUI, section, routeFolderPath], async ([native, sec, path]) => {
if (native && sec) { if (native && sec) {
if (cloudStore.currentPath !== path) {
cloudStore.reset()
}
const ok = await cloudStore.init() const ok = await cloudStore.init()
if (ok) { if (ok) {
await cloudStore.navigate(path) await cloudStore.navigate(path)
+31 -31
View File
@@ -199,8 +199,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, computed } from 'vue'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
import { useCachedResource } from '@/composables/useCachedResource'
import BackButton from '@/components/BackButton.vue' import BackButton from '@/components/BackButton.vue'
interface Identity { interface Identity {
@@ -228,9 +229,30 @@ interface Credential {
status: string status: string
} }
const identities = ref<Identity[]>([]) // Cached: revisits paint identities/credentials instantly and revalidate
const credentials = ref<Credential[]>([]) // behind them (errors keep the last-known lists).
const loadingCreds = ref(false) const identitiesRes = useCachedResource<Identity[]>({
key: 'credentials.identities',
fetcher: async (signal) => {
const result = await rpcClient.call<{ identities: Identity[] }>({
method: 'identity.list', params: {}, signal, dedup: true, maxRetries: 1,
})
return result?.identities || []
},
})
const credentialsRes = useCachedResource<Credential[]>({
key: 'credentials.list',
fetcher: async (signal) => {
const result = await rpcClient.call<{ credentials: Credential[] }>({
method: 'identity.list-credentials', params: {}, signal, dedup: true, maxRetries: 1,
})
return result?.credentials || []
},
})
const identities = computed(() => identitiesRes.data.value ?? [])
const credentials = computed(() => credentialsRes.data.value ?? [])
const loadingCreds = computed(() =>
credentialsRes.loadState.value === 'loading' || credentialsRes.loadState.value === 'refreshing')
const selectedCredential = ref<Credential | null>(null) const selectedCredential = ref<Credential | null>(null)
const credCopied = ref(false) const credCopied = ref(false)
const revoking = ref(false) const revoking = ref(false)
@@ -280,31 +302,10 @@ function formatClaims(subject: Record<string, unknown>): string {
return JSON.stringify(claims, null, 2) return JSON.stringify(claims, null, 2)
} }
async function loadIdentities() {
try {
const result = await rpcClient.call<{ identities: Identity[] }>({
method: 'identity.list',
params: {},
})
identities.value = result.identities || []
} catch (e) {
identities.value = []
if (import.meta.env.DEV) console.warn('Failed to load identities:', e)
}
}
async function loadCredentials() { async function loadCredentials() {
loadingCreds.value = true await credentialsRes.refresh()
try { if (credentialsRes.error.value) {
const result = await rpcClient.call<{ credentials: Credential[] }>({ showToast(`Failed to load credentials: ${credentialsRes.error.value}`, 'error')
method: 'identity.list-credentials',
params: {},
})
credentials.value = result.credentials || []
} catch (e) {
showToast(`Failed to load credentials: ${e instanceof Error ? e.message : 'Unknown error'}`, 'error')
} finally {
loadingCreds.value = false
} }
} }
@@ -404,9 +405,8 @@ async function copyCredentialJson() {
setTimeout(() => { credCopied.value = false }, 2000) setTimeout(() => { credCopied.value = false }, 2000)
} }
onMounted(async () => { // Both resources fetch themselves on first use (skipping the fetch entirely
await Promise.all([loadIdentities(), loadCredentials()]) // when the cached value is fresh).
})
defineExpose({ credentials, loadCredentials }) defineExpose({ credentials, loadCredentials })
</script> </script>
+25 -30
View File
@@ -229,6 +229,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue' import { ref, computed, onMounted, onUnmounted } from 'vue'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
import { useCachedResource } from '@/composables/useCachedResource'
import { useTransportStore } from '@/stores/transport' import { useTransportStore } from '@/stores/transport'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { useSyncStore } from '@/stores/sync' import { useSyncStore } from '@/stores/sync'
@@ -250,8 +251,15 @@ const transportStore = useTransportStore()
const appStore = useAppStore() const appStore = useAppStore()
const syncStore = useSyncStore() const syncStore = useSyncStore()
const nodes = ref<FederatedNode[]>([]) // Cached: revisits paint the node list instantly; the 5s poll and mutation
const loading = ref(true) // refreshes revalidate behind it. `loading` is initial-load only (background
// refreshes keep content on screen the old showLoader:false semantics).
const nodesRes = useCachedResource<FederatedNode[]>({
key: 'federation.nodes',
fetcher: async () => (await rpcClient.federationListNodes()).nodes,
})
const nodes = computed(() => nodesRes.data.value ?? [])
const loading = computed(() => nodesRes.loadState.value === 'loading')
const error = ref('') const error = ref('')
const selectedNode = ref<FederatedNode | null>(null) const selectedNode = ref<FederatedNode | null>(null)
const inviteType = ref<'trusted' | 'observer'>('trusted') const inviteType = ref<'trusted' | 'observer'>('trusted')
@@ -320,7 +328,12 @@ const mapLinks = computed(() => {
})) }))
}) })
const dwnStatus = ref<DwnStatus | null>(null) const dwnStatusRes = useCachedResource<DwnStatus>({
key: 'federation.dwn-status',
fetcher: (signal) => rpcClient.call<DwnStatus>({ method: 'dwn.status', signal, dedup: true, maxRetries: 1 }),
immediate: false,
})
const dwnStatus = computed(() => dwnStatusRes.data.value)
const dwnSyncing = ref(false) const dwnSyncing = ref(false)
const dwnSyncDotClass = computed(() => { const dwnSyncDotClass = computed(() => {
@@ -500,25 +513,13 @@ function isOnlineCheck(node: FederatedNode): boolean {
return lastSeen > tenMinutesAgo return lastSeen > tenMinutesAgo
} }
/** Explicit reload (mutations, retry): surfaces a load failure in the error
* banner. The background poll calls nodesRes.refresh() directly and stays
* silent, like the old surfaceErrors:false path. */
async function loadNodes() { async function loadNodes() {
return loadNodesWithOptions() await nodesRes.refresh()
} if (nodesRes.error.value) error.value = nodesRes.error.value
else error.value = ''
async function loadNodesWithOptions(options: { showLoader?: boolean; surfaceErrors?: boolean } = {}) {
const showLoader = options.showLoader ?? nodes.value.length === 0
const surfaceErrors = options.surfaceErrors ?? true
try {
if (showLoader) loading.value = true
const result = await rpcClient.federationListNodes()
nodes.value = result.nodes
error.value = ''
} catch (e) {
if (surfaceErrors) {
error.value = e instanceof Error ? e.message : 'Failed to load nodes'
}
} finally {
if (showLoader) loading.value = false
}
} }
function handleGenerateInvite(type: 'trusted' | 'observer') { function handleGenerateInvite(type: 'trusted' | 'observer') {
@@ -610,13 +611,8 @@ async function deployApp(did: string, appId: string) {
} }
} }
async function loadDwnStatus() { function loadDwnStatus() {
try { return dwnStatusRes.refresh()
const result = await rpcClient.call<DwnStatus>({ method: 'dwn.status' })
dwnStatus.value = result
} catch {
dwnStatus.value = null
}
} }
async function triggerDwnSync() { async function triggerDwnSync() {
@@ -681,7 +677,6 @@ async function rotateDid(password: string) {
let autoRefreshTimer: ReturnType<typeof setInterval> | null = null let autoRefreshTimer: ReturnType<typeof setInterval> | null = null
onMounted(async () => { onMounted(async () => {
loadNodesWithOptions({ showLoader: true })
loadDwnStatus() loadDwnStatus()
loadDiscoveryState() loadDiscoveryState()
loadPendingRequests() loadPendingRequests()
@@ -694,7 +689,7 @@ onMounted(async () => {
// Self DID not available // Self DID not available
} }
autoRefreshTimer = setInterval(() => { autoRefreshTimer = setInterval(() => {
loadNodesWithOptions({ showLoader: false, surfaceErrors: false }) void nodesRes.refresh()
loadPendingRequests() loadPendingRequests()
}, 5000) }, 5000)
}) })
+76 -7
View File
@@ -38,6 +38,8 @@ const activeChatChannel = ref<{ index: number; name: string } | null>(null)
const messageText = ref('') const messageText = ref('')
const sendError = ref('') const sendError = ref('')
const broadcasting = ref(false) const broadcasting = ref(false)
const broadcastResult = ref<string | null>(null) // 'ok' | error message
const refreshing = ref(false)
const configuring = ref(false) const configuring = ref(false)
const connectingDevice = ref<string | null>(null) const connectingDevice = ref<string | null>(null)
// Device-detected onboarding now lives in the global MeshDeviceSetupModal (App.vue). // Device-detected onboarding now lives in the global MeshDeviceSetupModal (App.vue).
@@ -383,12 +385,22 @@ onMounted(async () => {
archPollInterval = setInterval(loadArchMessages, 15000) archPollInterval = setInterval(loadArchMessages, 15000)
} }
if (!pollInterval) { if (!pollInterval) {
let tick = 0
pollInterval = setInterval(() => { pollInterval = setInterval(() => {
mesh.fetchStatus() mesh.fetchStatus()
mesh.fetchPeers() mesh.fetchPeers()
mesh.fetchMessages() mesh.fetchMessages()
mesh.fetchDeadmanStatus() mesh.fetchDeadmanStatus()
mesh.fetchBlockHeaders() mesh.fetchBlockHeaders()
// Contacts/aliases, federation nodes and the outbox badge previously
// loaded ONCE at mount and went permanently stale new federation
// peers or renames never appeared without a full page reload. Every
// 6th tick (~30s) keeps them fresh without adding per-5s load.
if (++tick % 6 === 0) {
void refreshContacts()
void refreshFederationNodes()
void refreshOutboxCount()
}
}, 5000) }, 5000)
} }
@@ -1021,7 +1033,37 @@ function onChatWheel(e: WheelEvent) {
async function handleBroadcast() { async function handleBroadcast() {
broadcasting.value = true broadcasting.value = true
try { await mesh.broadcastIdentity() } finally { broadcasting.value = false } broadcastResult.value = null
try {
await mesh.broadcastIdentity()
broadcastResult.value = 'ok'
} catch (e) {
broadcastResult.value = e instanceof Error ? e.message : 'Broadcast failed'
} finally {
broadcasting.value = false
setTimeout(() => { broadcastResult.value = null }, 4000)
}
}
async function handleRefresh() {
if (refreshing.value) return
refreshing.value = true
try {
// Backend first: re-query the radio's contact table (mesh.refresh), then
// re-read EVERYTHING the list is built from peers, contacts/aliases,
// federation nodes, outbox not just the mesh caches.
await Promise.allSettled([
mesh.refreshRadio(),
mesh.refreshAll(),
refreshContacts(),
refreshFederationNodes(),
refreshOutboxCount(),
])
// Radio contact refresh is async on the backend pick up its result.
await mesh.fetchPeers()
} finally {
refreshing.value = false
}
} }
async function handleToggleEnabled() { async function handleToggleEnabled() {
@@ -1830,8 +1872,14 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
<button class="glass-button mesh-action-btn" :disabled="configuring" @click="handleToggleEnabled"> <button class="glass-button mesh-action-btn" :disabled="configuring" @click="handleToggleEnabled">
{{ mesh.status?.enabled ? 'Disable' : 'Enable' }} {{ mesh.status?.enabled ? 'Disable' : 'Enable' }}
</button> </button>
<button class="glass-button mesh-action-btn" :disabled="!mesh.status?.device_connected || broadcasting" @click="handleBroadcast"> <button
{{ broadcasting ? 'Sending...' : 'Broadcast' }} class="glass-button mesh-action-btn"
:class="broadcastResult === 'ok' ? 'mesh-action-ok' : ''"
:disabled="!mesh.status?.device_connected || broadcasting"
:title="broadcastResult && broadcastResult !== 'ok' ? broadcastResult : 'Announce this node so nearby radios learn about it'"
@click="handleBroadcast"
>
{{ broadcasting ? 'Sending…' : broadcastResult === 'ok' ? 'Sent ✓' : broadcastResult ? 'Failed ✕' : 'Broadcast' }}
</button> </button>
<button <button
class="glass-button mesh-action-btn" class="glass-button mesh-action-btn"
@@ -1841,7 +1889,10 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
> >
{{ transport.meshOnly ? 'Go Online' : 'Off-Grid' }} {{ transport.meshOnly ? 'Go Online' : 'Off-Grid' }}
</button> </button>
<button class="glass-button mesh-action-btn" @click="mesh.refreshAll()">Refresh</button> <button class="glass-button mesh-action-btn" :disabled="refreshing" @click="handleRefresh">
<span v-if="refreshing" class="mesh-refresh-spinner" aria-hidden="true"></span>
{{ refreshing ? 'Refreshing…' : 'Refresh' }}
</button>
</div> </div>
<!-- Peers list --> <!-- Peers list -->
@@ -1871,7 +1922,10 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
>&times;</button> >&times;</button>
</div> </div>
<div v-if="mesh.peers.length === 0 && !mesh.status?.device_connected" class="mesh-empty"> <!-- Only claim "no peers" when the MERGED list (radio + federation)
is truly empty with no radio attached the federation rows and
the two channel rows must still render. -->
<div v-if="displayedPeers.length === 0 && !mesh.status?.device_connected" class="mesh-empty">
No peers discovered yet. No peers discovered yet.
</div> </div>
@@ -2140,8 +2194,14 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
<button <button
class="mesh-typed-content-download-btn" class="mesh-typed-content-download-btn"
title="Download" title="Download"
aria-label="Download image"
@click="downloadAttachment(msg.typed_payload as any)" @click="downloadAttachment(msg.typed_payload as any)"
>&#x2B07;</button> >
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 4v11m0 0l-4.5-4.5M12 15l4.5-4.5" />
<path d="M5 19h14" />
</svg>
</button>
</div> </div>
<audio <audio
v-else-if="(msg.typed_payload.mime || '').startsWith('audio/')" v-else-if="(msg.typed_payload.mime || '').startsWith('audio/')"
@@ -2165,10 +2225,19 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
@click="openMeshLightbox(msg.typed_payload as any)" @click="openMeshLightbox(msg.typed_payload as any)"
/> />
<button <button
class="btn" class="mesh-typed-content-fetch-btn"
:disabled="fetchingCids.has(msg.typed_payload.cid)" :disabled="fetchingCids.has(msg.typed_payload.cid)"
@click="handleFetchContent(msg.typed_payload as any)" @click="handleFetchContent(msg.typed_payload as any)"
> >
<span
v-if="fetchingCids.has(msg.typed_payload.cid)"
class="mesh-refresh-spinner"
aria-hidden="true"
></span>
<svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 4v11m0 0l-4.5-4.5M12 15l4.5-4.5" />
<path d="M5 19h14" />
</svg>
{{ fetchingCids.has(msg.typed_payload.cid) ? 'Fetching…' : 'Download' }} {{ fetchingCids.has(msg.typed_payload.cid) ? 'Fetching…' : 'Download' }}
</button> </button>
</template> </template>
+58 -73
View File
@@ -218,6 +218,7 @@ import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
import { useCachedResource } from '@/composables/useCachedResource'
import { useHomeStatusStore } from '@/stores/homeStatus' import { useHomeStatusStore } from '@/stores/homeStatus'
import BackButton from '@/components/BackButton.vue' import BackButton from '@/components/BackButton.vue'
import LineChart from '@/components/LineChart.vue' import LineChart from '@/components/LineChart.vue'
@@ -291,11 +292,51 @@ const backTarget = computed(() => (cameFromHome.value ? '/dashboard' : '/dashboa
const backLabel = computed(() => (cameFromHome.value ? t('common.back') : 'Web5')) const backLabel = computed(() => (cameFromHome.value ? t('common.back') : 'Web5'))
const homeStatus = useHomeStatusStore() const homeStatus = useHomeStatusStore()
const current = ref<MetricSnapshot | null>(null) // Cached: revisits paint the last snapshot/chart/alerts instantly and the 5s
const history = ref<MetricSnapshot[]>([]) // poll revalidates behind them; errors keep the last-known values.
const containers = ref<ContainerMetrics[]>([]) const currentRes = useCachedResource<MetricSnapshot>({
const alerts = ref<FiredAlert[]>([]) key: 'monitoring.current',
const alertRules = ref<AlertRule[]>([]) fetcher: async (signal) => {
const data = await rpcClient.call<MetricSnapshot | { status: string }>({
method: 'monitoring.current', signal, dedup: true, maxRetries: 1,
})
if (!data || !('system' in data)) throw new Error('metrics not ready')
return data
},
})
const historyRes = useCachedResource<MetricSnapshot[]>({
key: 'monitoring.history.minute60',
fetcher: async (signal) => {
const data = await rpcClient.call<HistoryResponse>({
method: 'monitoring.history', params: { resolution: 'minute', count: 60 },
signal, dedup: true, maxRetries: 1,
})
return data?.data ?? []
},
})
const alertsRes = useCachedResource<FiredAlert[]>({
key: 'monitoring.alerts',
fetcher: async (signal) => {
const data = await rpcClient.call<{ alerts: FiredAlert[] }>({
method: 'monitoring.alerts', params: { count: 50 }, signal, dedup: true, maxRetries: 1,
})
return (data?.alerts ?? []).reverse()
},
})
const alertRulesRes = useCachedResource<AlertRule[]>({
key: 'monitoring.alert-rules',
fetcher: async (signal) => {
const data = await rpcClient.call<{ rules: AlertRule[] }>({
method: 'monitoring.alert-rules', signal, dedup: true, maxRetries: 1,
})
return data?.rules ?? []
},
})
const current = computed(() => currentRes.data.value)
const history = computed(() => historyRes.data.value ?? [])
const containers = computed<ContainerMetrics[]>(() => currentRes.data.value?.containers ?? [])
const alerts = computed(() => alertsRes.data.value ?? [])
const alertRules = computed(() => alertRulesRes.data.value ?? [])
const showAlertConfig = ref(false) const showAlertConfig = ref(false)
const chartWidth = ref(380) const chartWidth = ref(380)
let pollTimer: ReturnType<typeof setInterval> | null = null let pollTimer: ReturnType<typeof setInterval> | null = null
@@ -464,66 +505,10 @@ async function exportMetrics(format: 'csv' | 'json') {
} }
} }
async function fetchCurrent() {
try {
await homeStatus.refreshSystemStats()
const data = await rpcClient.call<MetricSnapshot | { status: string }>({
method: 'monitoring.current',
})
if (data && 'system' in data) {
current.value = data
containers.value = data.containers ?? []
}
} catch {
// Silently retry on next poll
}
}
async function fetchHistory() {
try {
const data = await rpcClient.call<HistoryResponse>({
method: 'monitoring.history',
params: { resolution: 'minute', count: 60 },
})
if (data?.data) {
history.value = data.data
}
} catch {
// Silently retry on next poll
}
}
async function fetchAlerts() {
try {
const data = await rpcClient.call<{ alerts: FiredAlert[] }>({
method: 'monitoring.alerts',
params: { count: 50 },
})
if (data?.alerts) {
alerts.value = data.alerts.reverse()
}
} catch {
// Silently retry on next poll
}
}
async function fetchAlertRules() {
try {
const data = await rpcClient.call<{ rules: AlertRule[] }>({
method: 'monitoring.alert-rules',
})
if (data?.rules) {
alertRules.value = data.rules
}
} catch {
// Non-critical
}
}
async function toggleAlertRule(kind: string, enabled: boolean) { async function toggleAlertRule(kind: string, enabled: boolean) {
try { try {
await rpcClient.call({ method: 'monitoring.configure-alert', params: { kind, enabled } }) await rpcClient.call({ method: 'monitoring.configure-alert', params: { kind, enabled } })
await fetchAlertRules() await alertRulesRes.refresh()
} catch { } catch {
// Non-critical // Non-critical
} }
@@ -534,7 +519,7 @@ async function updateThreshold(kind: string, value: string) {
if (isNaN(threshold) || threshold <= 0) return if (isNaN(threshold) || threshold <= 0) return
try { try {
await rpcClient.call({ method: 'monitoring.configure-alert', params: { kind, threshold } }) await rpcClient.call({ method: 'monitoring.configure-alert', params: { kind, threshold } })
await fetchAlertRules() await alertRulesRes.refresh()
} catch { } catch {
// Non-critical // Non-critical
} }
@@ -543,7 +528,7 @@ async function updateThreshold(kind: string, value: string) {
async function acknowledgeAlert(id: string) { async function acknowledgeAlert(id: string) {
try { try {
await rpcClient.call({ method: 'monitoring.acknowledge-alert', params: { id } }) await rpcClient.call({ method: 'monitoring.acknowledge-alert', params: { id } })
await fetchAlerts() await alertsRes.refresh()
} catch { } catch {
// Non-critical // Non-critical
} }
@@ -556,18 +541,18 @@ function updateChartWidth() {
} }
} }
onMounted(async () => { onMounted(() => {
updateChartWidth() updateChartWidth()
window.addEventListener('resize', updateChartWidth) window.addEventListener('resize', updateChartWidth)
await Promise.all([fetchCurrent(), fetchHistory(), fetchAlerts(), fetchAlertRules()]) // The cached resources fetch themselves on first use; the poll keeps the
// live view fresh (refreshes dedup in the store).
pollTimer = setInterval(async () => { void homeStatus.refreshSystemStats()
try { pollTimer = setInterval(() => {
await Promise.all([fetchCurrent(), fetchHistory(), fetchAlerts()]) void homeStatus.refreshSystemStats()
} catch { void currentRes.refresh()
// Background poll ignore transient errors void historyRes.refresh()
} void alertsRes.refresh()
}, 5000) }, 5000)
}) })
+66 -28
View File
@@ -594,10 +594,11 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, reactive, watch, onMounted } from 'vue' import { ref, computed, reactive, watch, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import QRCode from 'qrcode' import QRCode from 'qrcode'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
import { useResourcesStore } from '@/stores/resources'
import { useAudioPlayer } from '@/composables/useAudioPlayer' import { useAudioPlayer } from '@/composables/useAudioPlayer'
import { pipSupported, togglePip } from '@/utils/pip' import { pipSupported, togglePip } from '@/utils/pip'
import BackButton from '@/components/BackButton.vue' import BackButton from '@/components/BackButton.vue'
@@ -625,16 +626,33 @@ interface CatalogItem {
access: string | { paid: { price_sats: number } } access: string | { paid: { price_sats: number } }
} }
const loading = ref(true) const resources = useResourcesStore()
const currentPeer = ref<PeerNode | null>(null) const currentPeer = ref<PeerNode | null>(null)
const catalogError = ref('')
const catalogItems = ref<CatalogItem[]>([])
const downloading = ref<string | null>(null) const downloading = ref<string | null>(null)
const playing = ref<string | null>(null) const playing = ref<string | null>(null)
const purchaseError = ref<string | null>(null) const purchaseError = ref<string | null>(null)
// The catalog is the SAME cached entry Cloud.vue's per-peer fan-in fills
// (`cloud.peer-browse:<onion>`): arriving here from the Cloud page paints
// the file list instantly from cache and revalidates behind it.
interface PeerBrowse {
items: CatalogItem[]
transport: string | null
latencyMs: number
}
const peerOnion = computed(() => props.peerId || currentPeer.value?.onion || '')
function browseEntry() {
return resources.entry<PeerBrowse>(`cloud.peer-browse:${peerOnion.value}`)
}
const catalogItems = computed(() => browseEntry().data?.items ?? [])
const catalogError = computed(() => browseEntry().error ?? '')
const loading = computed(() => {
const s = browseEntry().loadState
return s === 'loading' || s === 'refreshing' || (s === 'idle' && !!peerOnion.value)
})
// Transport actually used to reach this peer (returned by content.browse-peer) // Transport actually used to reach this peer (returned by content.browse-peer)
// so we can show a FIPS/Tor pill instead of always assuming Tor (B21). // so we can show a FIPS/Tor pill instead of always assuming Tor (B21).
const transport = ref<string | null>(null) const transport = computed(() => browseEntry().data?.transport ?? null)
const transportPill = computed(() => { const transportPill = computed(() => {
switch (transport.value) { switch (transport.value) {
case 'fips': case 'fips':
@@ -807,44 +825,62 @@ onMounted(async () => {
loadCatalog(), loadCatalog(),
loadOwned(), loadOwned(),
]) ])
} else {
loading.value = false
} }
// No peerId peerOnion is empty and `loading` stays false on its own.
}) })
async function loadCatalog() { function loadCatalog(): Promise<void> {
const onion = props.peerId || currentPeer.value?.onion const onion = peerOnion.value
if (!onion) return if (!onion) return Promise.resolve()
const hadItems = catalogItems.value.length > 0 return resources.refresh<PeerBrowse>(`cloud.peer-browse:${onion}`, async () => {
loading.value = true const t0 = Date.now()
catalogError.value = ''
try {
const result = await rpcClient.call<{ items?: CatalogItem[]; transport?: string }>({ const result = await rpcClient.call<{ items?: CatalogItem[]; transport?: string }>({
method: 'content.browse-peer', method: 'content.browse-peer',
params: { onion }, params: { onion },
timeout: 30000, timeout: 30000,
// The caller has its own timeout UX; retry×3 turned one slow peer
// into a 90s spinner.
maxRetries: 1,
dedup: true,
})
return { items: result?.items ?? [], transport: result?.transport ?? null, latencyMs: Date.now() - t0 }
})
}
// Load visual previews for image and video items when catalog loads.
// Audio files don't need visual thumbnails they show a waveform icon.
// The fan-out is capped (3 concurrent) and aborts on unmount it used to
// fire one 30s RPC per media item all at once, unbounded.
const previewAborter = new AbortController()
onUnmounted(() => previewAborter.abort())
const previewQueued = new Set<string>()
let previewQueue: CatalogItem[] = []
let previewWorkers = 0
const PREVIEW_CONCURRENCY = 3
function pumpPreviews(onion: string) {
while (previewWorkers < PREVIEW_CONCURRENCY && previewQueue.length > 0) {
const item = previewQueue.shift()!
previewWorkers++
void loadPreview(onion, item).finally(() => {
previewWorkers--
pumpPreviews(onion)
}) })
catalogItems.value = result?.items ?? []
transport.value = result?.transport ?? null
} catch (e: unknown) {
catalogError.value = e instanceof Error ? e.message : 'Failed to connect to peer'
if (!hadItems) catalogItems.value = []
} finally {
loading.value = false
} }
} }
// Load visual previews for image and video items when catalog loads watch(catalogItems, (items) => {
// Audio files don't need visual thumbnails they show a waveform icon const onion = peerOnion.value
watch(catalogItems, async (items) => {
const onion = props.peerId || currentPeer.value?.onion
if (!onion) return if (!onion) return
for (const item of items) { for (const item of items) {
if ((item.mime_type.startsWith('image/') || item.mime_type.startsWith('video/')) && !previewUrls[item.id]) { const isVisual = item.mime_type.startsWith('image/') || item.mime_type.startsWith('video/')
loadPreview(onion, item) if (isVisual && !previewUrls[item.id] && !previewQueued.has(item.id)) {
previewQueued.add(item.id)
previewQueue.push(item)
} }
} }
}) pumpPreviews(onion)
}, { immediate: true })
async function loadPreview(onion: string, item: CatalogItem) { async function loadPreview(onion: string, item: CatalogItem) {
try { try {
@@ -852,6 +888,8 @@ async function loadPreview(onion: string, item: CatalogItem) {
method: 'content.preview-peer', method: 'content.preview-peer',
params: { onion, content_id: item.id }, params: { onion, content_id: item.id },
timeout: 30000, timeout: 30000,
maxRetries: 1,
signal: previewAborter.signal,
}) })
if (result?.data) { if (result?.data) {
const mime = result.content_type || item.mime_type const mime = result.content_type || item.mime_type
+106 -67
View File
@@ -407,6 +407,7 @@
import { ref, computed, onMounted, onUnmounted, watch } from 'vue' import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import DOMPurify from 'dompurify' import DOMPurify from 'dompurify'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
import { useCachedResource, type CachedResource } from '@/composables/useCachedResource'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import QuickActionsCard from './server/QuickActionsCard.vue' import QuickActionsCard from './server/QuickActionsCard.vue'
import TorServicesCard from './server/TorServicesCard.vue' import TorServicesCard from './server/TorServicesCard.vue'
@@ -434,18 +435,51 @@ const torStatusColor = computed(() => {
const autoSyncEnabled = ref(true) const autoSyncEnabled = ref(true)
const logCount = ref(0) const logCount = ref(0)
// Network data // Network data a cached aggregate over four RPCs (allSettled: a failing
const networkLoading = ref(true) // one keeps that slice's previous values). Revisits paint instantly.
const networkRefreshing = ref(false) interface NetworkData {
const networkHasLoaded = ref(false) wifiCount: string; wifiSsid: string | null; torConnected: boolean; forwardCount: string
const networkData = ref({ vpnConnected: boolean; vpnProvider: string; vpnIp: string; wgIp: string; wgPubkey: string
wifiCount: 'N/A', wifiSsid: null as string | null, torConnected: false, forwardCount: 'N/A', vpnHostname: string; vpnPeers: number
dnsProvider: string; dnsServers: string[]; dnsDoH: boolean
}
const defaultNetworkData = (): NetworkData => ({
wifiCount: 'N/A', wifiSsid: null, torConnected: false, forwardCount: 'N/A',
vpnConnected: false, vpnProvider: '', vpnIp: '', wgIp: '', wgPubkey: '', vpnHostname: '', vpnPeers: 0, vpnConnected: false, vpnProvider: '', vpnIp: '', wgIp: '', wgPubkey: '', vpnHostname: '', vpnPeers: 0,
dnsProvider: 'system', dnsServers: [] as string[], dnsDoH: false, dnsProvider: 'system', dnsServers: [], dnsDoH: false,
}) })
// immediate:false the fetcher merges onto the previous value via
// networkRes, so it must not run during this initializer (onMounted loads
// it). The explicit annotation breaks the self-referential inference cycle.
const networkRes: CachedResource<NetworkData> = useCachedResource<NetworkData>({
key: 'server.network-summary',
immediate: false,
fetcher: async () => {
const next = { ...(networkRes.data.value ?? defaultNetworkData()) }
const [diagRes, fwdRes, vpnRes, dnsRes] = await Promise.allSettled([
rpcClient.call<{ wan_ip: string | null; nat_type: string; upnp_available: boolean; tor_connected: boolean; wifi_count?: number }>({ method: 'network.diagnostics' }),
rpcClient.call<{ forwards: unknown[] }>({ method: 'router.list-forwards' }),
rpcClient.vpnStatus(),
rpcClient.dnsStatus(),
])
if (diagRes.status === 'fulfilled') { next.torConnected = diagRes.value.tor_connected; next.wifiCount = diagRes.value.wifi_count !== undefined ? `${diagRes.value.wifi_count} configured` : 'N/A'; next.wifiSsid = (diagRes.value as { wifi_ssid?: string | null }).wifi_ssid ?? null }
if (fwdRes.status === 'fulfilled') { const c = fwdRes.value.forwards?.length ?? 0; next.forwardCount = `${c} rule${c !== 1 ? 's' : ''}` }
if (vpnRes.status === 'fulfilled') { next.vpnConnected = vpnRes.value.connected; next.vpnProvider = vpnRes.value.provider ?? ''; next.vpnIp = (vpnRes.value.ip_address ?? '').replace(/\/\d+$/, ''); next.wgIp = vpnRes.value.wg_ip ?? ''; next.wgPubkey = (vpnRes.value as Record<string, unknown>).wg_pubkey as string ?? '' }
if (dnsRes.status === 'fulfilled') { next.dnsProvider = dnsRes.value.provider; next.dnsServers = dnsRes.value.resolv_conf_servers ?? []; next.dnsDoH = dnsRes.value.doh_enabled }
return next
},
})
const networkData = computed(() => networkRes.data.value ?? defaultNetworkData())
const networkLoading = computed(() => networkRes.loadState.value === 'loading')
const networkRefreshing = computed(() => networkRes.loadState.value === 'refreshing')
// FIPS status row for the Local Network card. Full FIPS card lives below. // FIPS status row for the Local Network card. Full FIPS card lives below.
const fipsSummary = ref<{ installed: boolean; service_active: boolean; key_present: boolean; anchor_connected?: boolean; authenticated_peer_count?: number } | null>(null) const fipsSummaryRes = useCachedResource<{ installed: boolean; service_active: boolean; key_present: boolean; anchor_connected?: boolean; authenticated_peer_count?: number }>({
key: 'server.fips-summary',
immediate: false,
fetcher: (signal) => rpcClient.call({ method: 'fips.status', signal, dedup: true, maxRetries: 1 }),
})
const fipsSummary = computed(() => fipsSummaryRes.data.value)
const fipsRowLabel = computed(() => { const fipsRowLabel = computed(() => {
const s = fipsSummary.value const s = fipsSummary.value
if (!s) return '…' if (!s) return '…'
@@ -467,32 +501,12 @@ const fipsRowTextClass = computed(() => {
if (s.anchor_connected === false) return 'text-orange-400' if (s.anchor_connected === false) return 'text-orange-400'
return 'text-green-400' return 'text-green-400'
}) })
async function loadFipsSummary() { function loadFipsSummary() {
try { return fipsSummaryRes.refresh()
fipsSummary.value = await rpcClient.call<{ installed: boolean; service_active: boolean; key_present: boolean; anchor_connected?: boolean; authenticated_peer_count?: number }>({ method: 'fips.status' })
} catch { /* backend too old */ }
} }
async function loadNetworkData() { function loadNetworkData() {
const initialLoad = !networkHasLoaded.value return networkRes.refresh()
networkLoading.value = initialLoad
networkRefreshing.value = !initialLoad
try {
const [diagRes, fwdRes, vpnRes, dnsRes] = await Promise.allSettled([
rpcClient.call<{ wan_ip: string | null; nat_type: string; upnp_available: boolean; tor_connected: boolean; wifi_count?: number }>({ method: 'network.diagnostics' }),
rpcClient.call<{ forwards: unknown[] }>({ method: 'router.list-forwards' }),
rpcClient.vpnStatus(),
rpcClient.dnsStatus(),
])
if (diagRes.status === 'fulfilled') { networkData.value.torConnected = diagRes.value.tor_connected; networkData.value.wifiCount = diagRes.value.wifi_count !== undefined ? `${diagRes.value.wifi_count} configured` : 'N/A'; networkData.value.wifiSsid = (diagRes.value as { wifi_ssid?: string | null }).wifi_ssid ?? null }
if (fwdRes.status === 'fulfilled') { const c = fwdRes.value.forwards?.length ?? 0; networkData.value.forwardCount = `${c} rule${c !== 1 ? 's' : ''}` }
if (vpnRes.status === 'fulfilled') { networkData.value.vpnConnected = vpnRes.value.connected; networkData.value.vpnProvider = vpnRes.value.provider ?? ''; networkData.value.vpnIp = (vpnRes.value.ip_address ?? '').replace(/\/\d+$/, ''); networkData.value.wgIp = vpnRes.value.wg_ip ?? ''; networkData.value.wgPubkey = (vpnRes.value as Record<string, unknown>).wg_pubkey as string ?? '' }
if (dnsRes.status === 'fulfilled') { networkData.value.dnsProvider = dnsRes.value.provider; networkData.value.dnsServers = dnsRes.value.resolv_conf_servers ?? []; networkData.value.dnsDoH = dnsRes.value.doh_enabled }
} catch { /* keep existing/default values */ } finally {
networkHasLoaded.value = true
networkLoading.value = false
networkRefreshing.value = false
}
} }
// VPN peer management // VPN peer management
@@ -507,13 +521,18 @@ const sanitizedPeerQrSvg = computed(() =>
) )
const peerError = ref('') const peerError = ref('')
const copiedConfig = ref(false) const copiedConfig = ref(false)
const vpnPeers = ref<{ name: string; ip: string; type?: string; npub?: string }[]>([]) const vpnPeersRes = useCachedResource<{ name: string; ip: string; type?: string; npub?: string }[]>({
key: 'server.vpn-peers',
immediate: false,
fetcher: async (signal) => {
const res = await rpcClient.call<{ peers: { name: string; ip: string }[] }>({ method: 'vpn.list-peers', signal, dedup: true, maxRetries: 1 })
return res.peers || []
},
})
const vpnPeers = computed(() => vpnPeersRes.data.value ?? [])
async function loadVpnPeers() { function loadVpnPeers() {
try { return vpnPeersRes.refresh()
const res = await rpcClient.call<{ peers: { name: string; ip: string }[] }>({ method: 'vpn.list-peers' })
vpnPeers.value = res.peers || []
} catch { /* no peers */ }
} }
async function createPeer() { async function createPeer() {
@@ -557,7 +576,7 @@ async function removePeer(name: string) {
removingPeer.value = name removingPeer.value = name
try { try {
await rpcClient.call({ method: 'vpn.remove-peer', params: { name } }) await rpcClient.call({ method: 'vpn.remove-peer', params: { name } })
vpnPeers.value = vpnPeers.value.filter(p => p.name !== name) vpnPeersRes.optimistic(cur => (cur ?? []).filter(p => p.name !== name))
} catch { /* ignore */ } } catch { /* ignore */ }
finally { removingPeer.value = '' } finally { removingPeer.value = '' }
} }
@@ -583,10 +602,17 @@ async function copyPeerConfig() {
interface NetworkInterface { name: string; type: string; state: string; mac: string; ipv4: string[] } interface NetworkInterface { name: string; type: string; state: string; mac: string; ipv4: string[] }
interface WifiNetwork { ssid: string; signal: number; security: string } interface WifiNetwork { ssid: string; signal: number; security: string }
const interfacesLoading = ref(true) const interfacesRes = useCachedResource<NetworkInterface[]>({
const interfacesRefreshing = ref(false) key: 'server.interfaces',
const interfacesHaveLoaded = ref(false) immediate: false,
const allInterfaces = ref<NetworkInterface[]>([]) fetcher: async (signal) => {
const res = await rpcClient.call<{ interfaces: NetworkInterface[] }>({ method: 'network.list-interfaces', signal, dedup: true, maxRetries: 1 })
return res.interfaces
},
})
const interfacesLoading = computed(() => interfacesRes.loadState.value === 'loading')
const interfacesRefreshing = computed(() => interfacesRes.loadState.value === 'refreshing')
const allInterfaces = computed(() => interfacesRes.data.value ?? [])
const physicalInterfaces = computed(() => allInterfaces.value.filter(i => i.type === 'ethernet' || i.type === 'wifi')) const physicalInterfaces = computed(() => allInterfaces.value.filter(i => i.type === 'ethernet' || i.type === 'wifi'))
const wifiAvailable = computed(() => allInterfaces.value.some(i => i.type === 'wifi')) const wifiAvailable = computed(() => allInterfaces.value.some(i => i.type === 'wifi'))
@@ -637,19 +663,19 @@ async function applyDnsConfig(customServers: string) {
const res = await rpcClient.configureDns(params) const res = await rpcClient.configureDns(params)
// Never trust the response shape: an undefined `servers` used to reach the // Never trust the response shape: an undefined `servers` used to reach the
// dnsDisplayLabel computed and crash the whole page render on `.length`. // dnsDisplayLabel computed and crash the whole page render on `.length`.
networkData.value.dnsProvider = res?.provider ?? provider // Write-through to the cached aggregate (the RPC already succeeded).
networkData.value.dnsServers = Array.isArray(res?.servers) ? res.servers : (params.servers ?? []) networkRes.optimistic(cur => ({
networkData.value.dnsDoH = !!res?.doh_enabled ...(cur ?? defaultNetworkData()),
dnsProvider: res?.provider ?? provider,
dnsServers: Array.isArray(res?.servers) ? res.servers : (params.servers ?? []),
dnsDoH: !!res?.doh_enabled,
}))
showDnsModal.value = false showDnsModal.value = false
} catch (e) { dnsError.value = e instanceof Error ? e.message : 'DNS configuration failed.' } finally { dnsApplying.value = false } } catch (e) { dnsError.value = e instanceof Error ? e.message : 'DNS configuration failed.' } finally { dnsApplying.value = false }
} }
async function loadInterfaces() { function loadInterfaces() {
const initialLoad = !interfacesHaveLoaded.value return interfacesRes.refresh()
const hadInterfaces = allInterfaces.value.length > 0
interfacesLoading.value = initialLoad
interfacesRefreshing.value = !initialLoad
try { const res = await rpcClient.call<{ interfaces: NetworkInterface[] }>({ method: 'network.list-interfaces' }); allInterfaces.value = res.interfaces } catch { if (!hadInterfaces) allInterfaces.value = [] } finally { interfacesHaveLoaded.value = true; interfacesLoading.value = false; interfacesRefreshing.value = false }
} }
async function toggleWifiRadio(iface: NetworkInterface) { async function toggleWifiRadio(iface: NetworkInterface) {
@@ -731,9 +757,18 @@ function formatBytes(bytes: number): string {
} }
// Tor Services // Tor Services
const torServices = ref<TorServiceInfo[]>([]) const torServicesRes = useCachedResource<{ services: TorServiceInfo[]; tor_running: boolean }>({
const torServicesLoading = ref(false) key: 'server.tor-services',
const torDaemonRunning = ref(false) immediate: false,
fetcher: async (signal) => {
const res = await rpcClient.call<{ services: TorServiceInfo[]; tor_running: boolean }>({ method: 'tor.list-services', signal, dedup: true, maxRetries: 1 })
return { services: res.services || [], tor_running: res.tor_running ?? false }
},
})
const torServices = computed(() => torServicesRes.data.value?.services ?? [])
const torServicesLoading = computed(() =>
torServicesRes.loadState.value === 'loading' || torServicesRes.loadState.value === 'refreshing')
const torDaemonRunning = computed(() => torServicesRes.data.value?.tor_running ?? false)
const torRestarting = ref(false) const torRestarting = ref(false)
const torRotating = ref<string | false>(false) const torRotating = ref<string | false>(false)
const torDeleting = ref<string | false>(false) const torDeleting = ref<string | false>(false)
@@ -750,11 +785,8 @@ const availableAppsForTor = computed(() => {
.sort((a, b) => a.title.localeCompare(b.title)) .sort((a, b) => a.title.localeCompare(b.title))
}) })
async function loadTorServices() { function loadTorServices() {
const hadServices = torServices.value.length > 0 return torServicesRes.refresh()
torServicesLoading.value = true
try { const res = await rpcClient.call<{ services: TorServiceInfo[]; tor_running: boolean }>({ method: 'tor.list-services' }); torServices.value = res.services || []; torDaemonRunning.value = res.tor_running ?? false }
catch { if (!hadServices) { torServices.value = []; torDaemonRunning.value = false } } finally { torServicesLoading.value = false }
} }
async function copyTorAddress(address: string) { async function copyTorAddress(address: string) {
@@ -798,14 +830,18 @@ async function createService(name: string, port: number | null) {
onMounted(() => { checkTorStatus(); loadNetworkData(); loadInterfaces(); loadDiskStatus(); loadTorServices(); loadVpnPeers(); loadFipsSummary() }) onMounted(() => { checkTorStatus(); loadNetworkData(); loadInterfaces(); loadDiskStatus(); loadTorServices(); loadVpnPeers(); loadFipsSummary() })
// Poll VPN status every 15s so IP updates after pairing // Poll VPN status every 15s so IP updates after pairing (write-through to
// the cached aggregate without refetching the other three RPCs)
const vpnPollInterval = setInterval(async () => { const vpnPollInterval = setInterval(async () => {
try { try {
const vpnRes = await rpcClient.vpnStatus() const vpnRes = await rpcClient.vpnStatus()
networkData.value.vpnConnected = vpnRes.connected networkRes.optimistic(cur => ({
networkData.value.vpnProvider = vpnRes.provider ?? '' ...(cur ?? defaultNetworkData()),
networkData.value.vpnIp = (vpnRes.ip_address ?? '').replace(/\/\d+$/, '') vpnConnected: vpnRes.connected,
networkData.value.wgIp = vpnRes.wg_ip ?? '' vpnProvider: vpnRes.provider ?? '',
vpnIp: (vpnRes.ip_address ?? '').replace(/\/\d+$/, ''),
wgIp: vpnRes.wg_ip ?? '',
}))
} catch { /* ignore */ } } catch { /* ignore */ }
}, 15000) }, 15000)
onUnmounted(() => clearInterval(vpnPollInterval)) onUnmounted(() => clearInterval(vpnPollInterval))
@@ -829,8 +865,11 @@ async function restartServices() {
async function checkTorStatus() { async function checkTorStatus() {
checkingTor.value = true; torStatusLabel.value = 'checking' checkingTor.value = true; torStatusLabel.value = 'checking'
try { const res = await rpcClient.call<{ services: TorServiceInfo[] }>({ method: 'tor.list-services' }); torServices.value = res.services || []; torStatusLabel.value = torServices.value.some(s => s.onion_address) ? 'running' : 'stopped' } try {
catch { torStatusLabel.value = 'stopped' } finally { checkingTor.value = false } await torServicesRes.refresh()
if (torServicesRes.error.value) torStatusLabel.value = 'stopped'
else torStatusLabel.value = torServices.value.some(s => s.onion_address) ? 'running' : 'stopped'
} finally { checkingTor.value = false }
} }
const logsToast = ref('') const logsToast = ref('')
@@ -1,5 +1,6 @@
import { flushPromises, mount } from '@vue/test-utils' import { flushPromises, mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import { createPinia } from 'pinia'
import Credentials from '../Credentials.vue' import Credentials from '../Credentials.vue'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
@@ -42,6 +43,8 @@ describe('Credentials', () => {
const wrapper = mount(Credentials, { const wrapper = mount(Credentials, {
global: { global: {
// The cached-resource layer pulls the Pinia resources store in setup.
plugins: [createPinia()],
mocks: { mocks: {
$router: { push: vi.fn() }, $router: { push: vi.fn() },
}, },
@@ -1,5 +1,6 @@
import { flushPromises, mount } from '@vue/test-utils' import { flushPromises, mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import { createPinia } from 'pinia'
import PeerFiles from '../PeerFiles.vue' import PeerFiles from '../PeerFiles.vue'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
@@ -55,6 +56,8 @@ describe('PeerFiles', () => {
const wrapper = mount(PeerFiles, { const wrapper = mount(PeerFiles, {
props: { peerId: 'peer.onion' }, props: { peerId: 'peer.onion' },
global: { global: {
// The shared peer-browse cache lives in the Pinia resources store.
plugins: [createPinia()],
stubs: { stubs: {
Teleport: true, Teleport: true,
}, },
@@ -1,5 +1,6 @@
import { flushPromises, mount } from '@vue/test-utils' import { flushPromises, mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import { createPinia } from 'pinia'
import Server from '../Server.vue' import Server from '../Server.vue'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
@@ -29,6 +30,8 @@ function deferred<T>() {
function mountServer(options: { renderTorServices?: boolean } = {}) { function mountServer(options: { renderTorServices?: boolean } = {}) {
return mount(Server, { return mount(Server, {
global: { global: {
// The cached-resource layer pulls the Pinia resources store in setup.
plugins: [createPinia()],
stubs: { stubs: {
QuickActionsCard: true, QuickActionsCard: true,
TorServicesCard: options.renderTorServices ? false : true, TorServicesCard: options.renderTorServices ? false : true,
+6 -3
View File
@@ -142,12 +142,15 @@ async function saveSettings() {
lora_region: form.value.region, lora_region: form.value.region,
device_kind: form.value.deviceKind, device_kind: form.value.deviceKind,
channel_name: form.value.channel.trim() || 'archipelago', channel_name: form.value.channel.trim() || 'archipelago',
...(form.value.name.trim() ? { advert_name: form.value.name.trim() } : {}), // Always sent: an empty string CLEARS the custom mesh name (backend
// maps "" -> None -> fall back to the server name). The old omit-when-
// empty made clearing impossible once a name was ever set.
advert_name: form.value.name.trim(),
broadcast_identity: form.value.broadcastIdentity, broadcast_identity: form.value.broadcastIdentity,
...(rfParams ? { lora_radio_params: rfParams } : {}), ...(rfParams ? { lora_radio_params: rfParams } : {}),
}) })
saveDone.value = true saveDone.value = true
setTimeout(() => { saveDone.value = false }, 3000) setTimeout(() => { saveDone.value = false }, 5000)
} catch (e) { } catch (e) {
saveError.value = e instanceof Error ? e.message : 'Failed to save mesh settings' saveError.value = e instanceof Error ? e.message : 'Failed to save mesh settings'
} finally { } finally {
@@ -286,7 +289,7 @@ async function saveSettings() {
> >
{{ saving ? 'Saving…' : 'Save Settings' }} {{ saving ? 'Saving…' : 'Save Settings' }}
</button> </button>
<span v-if="saveDone" class="text-xs text-green-400">Saved applies on next radio session</span> <span v-if="saveDone" class="text-xs text-green-400">Saved applying to the radio now</span>
<span v-if="saveError" class="text-xs text-red-400">{{ saveError }}</span> <span v-if="saveError" class="text-xs text-red-400">{{ saveError }}</span>
</div> </div>
</div> </div>
+40 -5
View File
@@ -83,6 +83,19 @@
.mesh-offgrid-active { border-color: rgba(251, 146, 60, 0.4) !important; color: #fb923c !important; } .mesh-offgrid-active { border-color: rgba(251, 146, 60, 0.4) !important; color: #fb923c !important; }
.mesh-actions { display: flex; gap: 8px; flex-shrink: 0; } .mesh-actions { display: flex; gap: 8px; flex-shrink: 0; }
.mesh-action-btn { flex: 1; padding: 8px 0; font-size: 0.8rem; } .mesh-action-btn { flex: 1; padding: 8px 0; font-size: 0.8rem; }
.mesh-action-ok { color: #34d399; border-color: rgba(52, 211, 153, 0.4); }
.mesh-refresh-spinner {
display: inline-block;
width: 10px;
height: 10px;
margin-right: 4px;
border-radius: 9999px;
border: 2px solid rgba(251, 146, 60, 0.7);
border-top-color: transparent;
animation: mesh-refresh-spin 0.8s linear infinite;
vertical-align: -1px;
}
@keyframes mesh-refresh-spin { to { transform: rotate(360deg); } }
.mesh-peers-card { padding: 14px; flex: 1; min-height: 0; display: flex; flex-direction: column; } .mesh-peers-card { padding: 14px; flex: 1; min-height: 0; display: flex; flex-direction: column; }
.mesh-peers-card .mesh-section-title { margin-bottom: 10px; flex-shrink: 0; } .mesh-peers-card .mesh-section-title { margin-bottom: 10px; flex-shrink: 0; }
.mesh-peer-list { display: flex; flex-direction: column; gap: 4px; overflow-y: auto; flex: 1; min-height: 0; } .mesh-peer-list { display: flex; flex-direction: column; gap: 4px; overflow-y: auto; flex: 1; min-height: 0; }
@@ -361,13 +374,35 @@
.mesh-typed-content-audio { width: 220px; max-width: 100%; display: block; } .mesh-typed-content-audio { width: 220px; max-width: 100%; display: block; }
.mesh-typed-content-image-wrap { position: relative; display: inline-block; } .mesh-typed-content-image-wrap { position: relative; display: inline-block; }
.mesh-typed-content-download-btn { .mesh-typed-content-download-btn {
position: absolute; bottom: 6px; right: 6px; width: 1.75rem; height: 1.75rem; position: absolute; bottom: 8px; right: 8px;
border-radius: 50%; border: 1px solid rgba(255,255,255,0.15); width: 2.25rem; height: 2.25rem; min-width: 2.25rem; flex-shrink: 0;
background: rgba(0,0,0,0.55); color: rgba(255,255,255,0.85); font-size: 0.85rem; border-radius: 50%; border: 1px solid rgba(255,255,255,0.18);
background: rgba(10,10,14,0.55); color: rgba(255,255,255,0.9);
display: flex; align-items: center; justify-content: center; cursor: pointer; display: flex; align-items: center; justify-content: center; cursor: pointer;
backdrop-filter: blur(6px); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
box-shadow: 0 2px 8px rgba(0,0,0,0.35);
transition: background 0.15s ease, transform 0.15s ease;
} }
.mesh-typed-content-download-btn:hover { background: rgba(0,0,0,0.75); color: #fff; } .mesh-typed-content-download-btn svg { width: 1.05rem; height: 1.05rem; }
.mesh-typed-content-download-btn:hover { background: rgba(251,146,60,0.35); color: #fff; transform: scale(1.06); }
.mesh-typed-content-download-btn:active { transform: scale(0.96); }
/* Pre-fetch "Download" pill under an incoming attachment. The generic .btn it
replaced collapsed to its text width inside the narrow mobile bubble and
looked squashed this is a full-width glass pill in the house style. */
.mesh-typed-content-fetch-btn {
display: flex; align-items: center; justify-content: center; gap: 7px;
width: 100%; min-height: 2.4rem; padding: 8px 14px; margin-top: 2px;
border-radius: 12px; border: 1px solid rgba(255,255,255,0.14);
background: rgba(255,255,255,0.07); color: rgba(255,255,255,0.9);
font-size: 0.82rem; font-weight: 500; cursor: pointer; white-space: nowrap;
backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
transition: background 0.15s ease, border-color 0.15s ease;
}
.mesh-typed-content-fetch-btn svg { width: 1rem; height: 1rem; flex-shrink: 0; }
.mesh-typed-content-fetch-btn:hover:not(:disabled) {
background: rgba(251,146,60,0.18); border-color: rgba(251,146,60,0.4); color: #fff;
}
.mesh-typed-content-fetch-btn:disabled { opacity: 0.6; cursor: default; }
.mesh-tab-bar { display: flex; gap: 2px; background: rgba(0,0,0,0.3); border-radius: 10px; padding: 3px; flex-shrink: 0; } .mesh-tab-bar { display: flex; gap: 2px; background: rgba(0,0,0,0.3); border-radius: 10px; padding: 3px; flex-shrink: 0; }
.mesh-tab { flex: 1; padding: 8px 12px; border: none; background: transparent; color: rgba(255,255,255,0.5); font-size: 0.82rem; font-weight: 500; border-radius: 8px; cursor: pointer; transition: all 0.2s ease; display: flex; align-items: center; justify-content: center; gap: 6px; } .mesh-tab { flex: 1; padding: 8px 12px; border: none; background: transparent; color: rgba(255,255,255,0.5); font-size: 0.82rem; font-weight: 500; border-radius: 8px; cursor: pointer; transition: all 0.2s ease; display: flex; align-items: center; justify-content: center; gap: 6px; }
.mesh-tab:hover { color: rgba(255,255,255,0.8); background: rgba(255,255,255,0.05); } .mesh-tab:hover { color: rgba(255,255,255,0.8); background: rgba(255,255,255,0.05); }
+16 -13
View File
@@ -121,6 +121,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue' import { computed, onMounted, onUnmounted, ref } from 'vue'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
import { useCachedResource } from '@/composables/useCachedResource'
import { safeClipboardWrite } from '@/views/web5/utils' import { safeClipboardWrite } from '@/views/web5/utils'
import FipsSeedAnchorsCard from './FipsSeedAnchorsCard.vue' import FipsSeedAnchorsCard from './FipsSeedAnchorsCard.vue'
@@ -136,7 +137,14 @@ interface FipsStatus {
anchor_connected?: boolean anchor_connected?: boolean
} }
const status = ref<FipsStatus>({ // Shares `server.fips-summary` with the Local Network card's FIPS row, so
// both paint from the same cache instantly on revisit and never disagree.
const statusRes = useCachedResource<FipsStatus>({
key: 'server.fips-summary',
ttlMs: 15_000,
fetcher: (signal) => rpcClient.call<FipsStatus>({ method: 'fips.status', signal, dedup: true, maxRetries: 1 }),
})
const status = computed<FipsStatus>(() => statusRes.data.value ?? {
installed: false, installed: false,
version: null, version: null,
service_state: 'unknown', service_state: 'unknown',
@@ -199,18 +207,11 @@ function flash(msg: string, isError = false) {
setTimeout(() => { statusMessage.value = '' }, 6000) setTimeout(() => { statusMessage.value = '' }, 6000)
} }
async function loadStatus() {
try {
status.value = await rpcClient.call<FipsStatus>({ method: 'fips.status' })
} catch (e) {
if (import.meta.env.DEV) console.warn('fips.status failed', e)
}
}
async function installAndActivate() { async function installAndActivate() {
installing.value = true installing.value = true
try { try {
status.value = await rpcClient.call<FipsStatus>({ method: 'fips.install' }) const next = await rpcClient.call<FipsStatus>({ method: 'fips.install' })
statusRes.optimistic(() => next) // confirmed server state, not a guess
flash('FIPS started') flash('FIPS started')
} catch (e: unknown) { } catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e) const msg = e instanceof Error ? e.message : String(e)
@@ -235,7 +236,7 @@ async function reconnectAnchor() {
}>({ method: 'fips.reconnect', timeout: 60_000 }) }>({ method: 'fips.reconnect', timeout: 60_000 })
// Update the card with the post-reconnect status returned by the // Update the card with the post-reconnect status returned by the
// backend avoids an extra status fetch race. // backend avoids an extra status fetch race.
status.value = { ...status.value, ...res.after } statusRes.optimistic((cur) => ({ ...(cur ?? status.value), ...res.after }))
if (res.recovered) { if (res.recovered) {
flash('Anchor reconnected.') flash('Anchor reconnected.')
} else if (res.likely_cause === 'connected') { } else if (res.likely_cause === 'connected') {
@@ -258,8 +259,10 @@ async function reconnectAnchor() {
// stuck showing whatever anchor state existed at mount time forever. // stuck showing whatever anchor state existed at mount time forever.
let statusInterval: ReturnType<typeof setInterval> | null = null let statusInterval: ReturnType<typeof setInterval> | null = null
onMounted(() => { onMounted(() => {
loadStatus() statusInterval = setInterval(() => {
statusInterval = setInterval(loadStatus, 15000) if (document.hidden) return
void statusRes.refresh()
}, 15000)
}) })
onUnmounted(() => { onUnmounted(() => {
if (statusInterval) clearInterval(statusInterval) if (statusInterval) clearInterval(statusInterval)
@@ -88,8 +88,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, reactive, ref } from 'vue' import { computed, reactive, ref } from 'vue'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
import { useCachedResource } from '@/composables/useCachedResource'
defineProps<{ closable?: boolean }>() defineProps<{ closable?: boolean }>()
defineEmits<{ (e: 'close'): void }>() defineEmits<{ (e: 'close'): void }>()
@@ -107,7 +108,16 @@ interface ApplyResult {
message: string message: string
} }
const anchors = ref<SeedAnchor[]>([]) const anchorsRes = useCachedResource<SeedAnchor[]>({
key: 'server.fips-seed-anchors',
fetcher: async (signal) => {
const res = await rpcClient.call<{ seed_anchors: SeedAnchor[] }>({
method: 'fips.list-seed-anchors', signal, dedup: true, maxRetries: 1,
})
return res.seed_anchors
},
})
const anchors = computed(() => anchorsRes.data.value ?? [])
const adding = ref(false) const adding = ref(false)
const applying = ref(false) const applying = ref(false)
const statusMessage = ref('') const statusMessage = ref('')
@@ -126,15 +136,6 @@ function flash(msg: string, isError = false) {
setTimeout(() => { statusMessage.value = '' }, 6000) setTimeout(() => { statusMessage.value = '' }, 6000)
} }
async function load() {
try {
const res = await rpcClient.call<{ seed_anchors: SeedAnchor[] }>({ method: 'fips.list-seed-anchors' })
anchors.value = res.seed_anchors
} catch (e: unknown) {
if (import.meta.env.DEV) console.warn('fips.list-seed-anchors failed', e)
}
}
async function addAnchor() { async function addAnchor() {
if (!draft.npub.trim() || !draft.address.trim()) return if (!draft.npub.trim() || !draft.address.trim()) return
adding.value = true adding.value = true
@@ -148,7 +149,7 @@ async function addAnchor() {
label: draft.label.trim(), label: draft.label.trim(),
}, },
}) })
anchors.value = res.seed_anchors anchorsRes.optimistic(() => res.seed_anchors) // authoritative post-add list
draft.npub = '' draft.npub = ''
draft.address = '' draft.address = ''
draft.label = '' draft.label = ''
@@ -168,7 +169,7 @@ async function removeAnchor(npub: string) {
method: 'fips.remove-seed-anchor', method: 'fips.remove-seed-anchor',
params: { npub }, params: { npub },
}) })
anchors.value = res.seed_anchors anchorsRes.optimistic(() => res.seed_anchors)
flash('Anchor removed.') flash('Anchor removed.')
} catch (e: unknown) { } catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e) const msg = e instanceof Error ? e.message : String(e)
@@ -189,6 +190,4 @@ async function applyAll() {
applying.value = false applying.value = false
} }
} }
onMounted(load)
</script> </script>
+22 -130
View File
@@ -2,6 +2,7 @@
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
import { useResourcesStore } from '@/stores/resources'
import BackButton from '@/components/BackButton.vue' import BackButton from '@/components/BackButton.vue'
const router = useRouter() const router = useRouter()
@@ -73,8 +74,15 @@ interface ScannedNetwork {
encryption: string encryption: string
} }
const status = ref<RouterStatus | null>(null) const status = computed(() => statusEntry().data)
const loading = ref(true) // Router status is cached in the shared resources store so revisits paint
// the last-known state instantly while a fresh read runs behind it.
const resources = useResourcesStore()
const statusEntry = () => resources.entry<RouterStatus>('server.openwrt-status')
const loading = computed(() => {
const s = statusEntry().loadState
return s === 'loading' || s === 'refreshing' || (s === 'idle' && statusEntry().data === null)
})
const error = ref('') const error = ref('')
const host = ref('') const host = ref('')
const sshUser = ref('root') const sshUser = ref('root')
@@ -87,15 +95,6 @@ const detecting = ref(false)
const detectError = ref('') const detectError = ref('')
const detectedCandidates = ref<string[]>([]) const detectedCandidates = ref<string[]>([])
// Set/change router password lets a fresh-flash router (root has no
// password yet) get fully connected without the user ever opening a
// terminal or LuCI themselves.
const showSetPassword = ref(false)
const newPassword = ref('')
const confirmPassword = ref('')
const settingPassword = ref(false)
const setPasswordError = ref('')
const provisioning = ref(false) const provisioning = ref(false)
const provisionError = ref('') const provisionError = ref('')
const provisionSuccess = ref(false) const provisionSuccess = ref(false)
@@ -124,25 +123,25 @@ const dhcpLimit = ref(150)
const masqEnabled = ref(true) const masqEnabled = ref(true)
async function load(params?: Record<string, string>) { async function load(params?: Record<string, string>) {
loading.value = true
error.value = '' error.value = ''
try { await resources.refresh<RouterStatus>('server.openwrt-status', () =>
status.value = await rpcClient.call<RouterStatus>({ rpcClient.call<RouterStatus>({
method: 'openwrt.get-status', method: 'openwrt.get-status',
params: params ?? {}, params: params ?? {},
timeout: 30000, timeout: 30000,
}) dedup: true,
showConnectForm.value = false maxRetries: 1,
if (params) connectedParams.value = params }))
} catch (e) { const err = statusEntry().error
const msg = e instanceof Error ? e.message : String(e) if (err) {
if (msg.includes('No router configured')) { if (err.includes('No router configured')) {
showConnectForm.value = true showConnectForm.value = true
} else { } else {
error.value = msg error.value = err
} }
} finally { } else {
loading.value = false showConnectForm.value = false
if (params) connectedParams.value = params
} }
} }
@@ -157,39 +156,6 @@ async function connect() {
} }
} }
async function setPasswordAndConnect() {
if (!host.value.trim() || !newPassword.value) return
setPasswordError.value = ''
if (newPassword.value !== confirmPassword.value) {
setPasswordError.value = 'Passwords do not match.'
return
}
settingPassword.value = true
try {
await rpcClient.call({
method: 'openwrt.set-password',
params: {
host: host.value.trim(),
ssh_user: sshUser.value,
current_password: sshPassword.value,
new_password: newPassword.value,
},
timeout: 20000,
})
// Router accepted the new password log in with it right away so the
// user never has to separately "Connect" after this.
sshPassword.value = newPassword.value
showSetPassword.value = false
newPassword.value = ''
confirmPassword.value = ''
await connect()
} catch (e) {
setPasswordError.value = e instanceof Error ? e.message : String(e)
} finally {
settingPassword.value = false
}
}
interface WiredInterface { name: string; type: string; state: string; ipv4: string[] } interface WiredInterface { name: string; type: string; state: string; ipv4: string[] }
async function detectRouter() { async function detectRouter() {
@@ -245,33 +211,6 @@ function disconnectRouter() {
showConnectForm.value = true showConnectForm.value = true
} }
const forgetting = ref(false)
const forgetError = ref('')
// Unlike disconnectRouter() (which only resets local state, leaving the saved
// router_config.json in place so onMounted reconnects on next visit), this
// deletes the saved credentials server-side so the router is truly forgotten.
async function forgetRouter() {
if (!confirm('Forget this router? You will need to log in again next time.')) return
forgetting.value = true
forgetError.value = ''
try {
await rpcClient.call({ method: 'openwrt.forget', timeout: 10000 })
status.value = null
connectedParams.value = null
host.value = ''
sshUser.value = 'root'
sshPassword.value = ''
detectError.value = ''
detectedCandidates.value = []
showConnectForm.value = true
} catch (e) {
forgetError.value = e instanceof Error ? e.message : String(e)
} finally {
forgetting.value = false
}
}
async function provisionTollgate() { async function provisionTollgate() {
provisioning.value = true provisioning.value = true
provisionError.value = '' provisionError.value = ''
@@ -506,45 +445,6 @@ onMounted(() => load())
> >
{{ connecting ? 'Connecting…' : 'Connect' }} {{ connecting ? 'Connecting…' : 'Connect' }}
</button> </button>
<button
class="w-full text-xs text-white/40 hover:text-white/70 transition-colors text-center"
@click="showSetPassword = !showSetPassword"
>
First login, or don't know the password? Set one
</button>
<div v-if="showSetPassword" class="space-y-3 pt-2 border-t border-white/10">
<p class="text-xs text-white/40">
Sets the router's SSH password directly no need to SSH in yourself first.
Leave "Password" above blank if this is a fresh flash (no password set yet).
</p>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs text-white/40 mb-1">New password</label>
<input
v-model="newPassword"
type="password"
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/20 transition-colors"
/>
</div>
<div>
<label class="block text-xs text-white/40 mb-1">Confirm password</label>
<input
v-model="confirmPassword"
type="password"
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/20 transition-colors"
/>
</div>
</div>
<button
:disabled="settingPassword || !host.trim() || !newPassword"
class="glass-button w-full text-sm font-medium"
:class="settingPassword || !host.trim() || !newPassword ? 'opacity-40 cursor-not-allowed' : ''"
@click="setPasswordAndConnect"
>
{{ settingPassword ? 'Setting password…' : 'Set Password & Connect' }}
</button>
<p v-if="setPasswordError" class="text-xs text-red-400">{{ setPasswordError }}</p>
</div>
</div> </div>
<p v-if="error" class="mt-3 text-xs text-red-400">{{ error }}</p> <p v-if="error" class="mt-3 text-xs text-red-400">{{ error }}</p>
</div> </div>
@@ -607,15 +507,7 @@ onMounted(() => load())
<button class="text-xs text-white/40 hover:text-white/70 transition-colors" @click="disconnectRouter"> <button class="text-xs text-white/40 hover:text-white/70 transition-colors" @click="disconnectRouter">
Switch router Switch router
</button> </button>
<button
class="text-xs text-red-400/70 hover:text-red-400 transition-colors"
:disabled="forgetting"
@click="forgetRouter"
>
{{ forgetting ? 'Forgetting…' : 'Forget router' }}
</button>
</div> </div>
<p v-if="forgetError" class="mt-2 text-xs text-red-400">{{ forgetError }}</p>
</div> </div>
<!-- WAN / Uplink --> <!-- WAN / Uplink -->
+34 -75
View File
@@ -93,8 +93,9 @@ let web5AnimationDone = false
import { ref, computed, onMounted, onUnmounted } from 'vue' import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
import { useCachedResource } from '@/composables/useCachedResource'
import { safeClipboardWrite } from './utils' import { safeClipboardWrite } from './utils'
import type { ProfitsData, WalletTransaction, HwWalletDevice } from './types' import type { ProfitsData, HwWalletDevice } from './types'
import Web5QuickActions from './Web5QuickActions.vue' import Web5QuickActions from './Web5QuickActions.vue'
// import Web5Wallet from './Web5Wallet.vue' // hidden for now // import Web5Wallet from './Web5Wallet.vue' // hidden for now
@@ -136,7 +137,13 @@ function showToast(text: string) {
} }
// --- Networking Profits --- // --- Networking Profits ---
const profitsBreakdown = ref<ProfitsData | null>(null) const profitsRes = useCachedResource<ProfitsData>({
key: 'web5.networking-profits',
fetcher: (signal) => rpcClient.call<ProfitsData>({ method: 'wallet.networking-profits', signal, dedup: true, maxRetries: 1 }),
})
const profitsBreakdown = computed<ProfitsData | null>(() =>
profitsRes.data.value
?? (profitsRes.error.value ? { total_sats: 0, content_sales_sats: 0, routing_fees_sats: 0 } : null))
const networkingProfitsDisplay = computed(() => { const networkingProfitsDisplay = computed(() => {
if (!profitsBreakdown.value) return '...' if (!profitsBreakdown.value) return '...'
const sats = profitsBreakdown.value.total_sats const sats = profitsBreakdown.value.total_sats
@@ -146,15 +153,6 @@ const networkingProfitsDisplay = computed(() => {
return `\u20BF${btc.toFixed(8).replace(/0+$/, '').replace(/\.$/, '')}` return `\u20BF${btc.toFixed(8).replace(/0+$/, '').replace(/\.$/, '')}`
}) })
async function loadNetworkingProfits() {
try {
const res = await rpcClient.call<ProfitsData>({ method: 'wallet.networking-profits' })
profitsBreakdown.value = res
} catch {
profitsBreakdown.value = { total_sats: 0, content_sales_sats: 0, routing_fees_sats: 0 }
}
}
// --- DID State --- // --- DID State ---
const storedDid = ref<string | null>(null) const storedDid = ref<string | null>(null)
try { try {
@@ -290,64 +288,35 @@ async function copyDidDocument() {
} }
// --- Wallet / LND Balances --- // --- Wallet / LND Balances ---
const walletConnected = ref(false) // Cached: balances/transactions paint instantly on revisit and revalidate
// behind the cached value; errors keep the last-known data.
const lndInfoRes = useCachedResource<{
balance_sats: number
channel_balance_sats: number
synced_to_chain: boolean
}>({
key: 'web5.lnd-info',
fetcher: (signal) => rpcClient.call({ method: 'lnd.getinfo', signal, dedup: true, maxRetries: 1 }),
})
// connectWallet() can still "disconnect" the (hidden) wallet card UI-side.
const walletManuallyDisconnected = ref(false)
const walletConnected = computed(() =>
!walletManuallyDisconnected.value && lndInfoRes.data.value !== null && !lndInfoRes.error.value)
const connectingWallet = ref(false) const connectingWallet = ref(false)
const lndOnchainBalance = ref(0) // Ecash/transaction/balance display lives in the hidden wallet card when it
const lndChannelBalance = ref(0) // returns, add cached resources for wallet.ecash-balance / lnd.gettransactions
const walletError = ref('') // here rather than reviving the old eager loaders.
const ecashBalance = ref(0)
// Transactions wallet card hidden, but loadTransactions still called for QuickActions walletConnected state
const walletTransactions = ref<WalletTransaction[]>([])
// Hardware wallets // Hardware wallets
const detectedHwWallets = ref<HwWalletDevice[]>([]) const detectedHwWallets = ref<HwWalletDevice[]>([])
async function loadLndBalances() {
try {
const res = await rpcClient.call<{
balance_sats: number
channel_balance_sats: number
synced_to_chain: boolean
}>({ method: 'lnd.getinfo' })
lndOnchainBalance.value = res.balance_sats || 0
lndChannelBalance.value = res.channel_balance_sats || 0
walletConnected.value = true
walletError.value = ''
} catch (e) {
walletConnected.value = false
lndOnchainBalance.value = 0
lndChannelBalance.value = 0
walletError.value = e instanceof Error ? e.message : 'Failed to load wallet balances'
}
}
async function loadEcashBalance() {
try {
const res = await rpcClient.call<{ balance_sats: number; token_count: number }>({ method: 'wallet.ecash-balance' })
ecashBalance.value = res.balance_sats ?? 0
} catch {
// Keep last-known balance on a transient failure rather than flashing 0.
}
}
async function loadTransactions() {
try {
const res = await rpcClient.call<{ transactions: WalletTransaction[]; incoming_pending_count: number }>({ method: 'lnd.gettransactions' })
walletTransactions.value = res.transactions || []
walletError.value = ''
} catch (e) {
walletTransactions.value = []
walletError.value = e instanceof Error ? e.message : 'Failed to load transactions'
}
}
async function connectWallet() { async function connectWallet() {
if (walletConnected.value) { if (walletConnected.value) {
walletConnected.value = false walletManuallyDisconnected.value = true
} else { } else {
connectingWallet.value = true connectingWallet.value = true
await loadLndBalances() walletManuallyDisconnected.value = false
await lndInfoRes.refresh()
connectingWallet.value = false connectingWallet.value = false
} }
} }
@@ -361,13 +330,8 @@ async function detectHardwareWallets() {
} }
} }
// function reloadBalances() { // wallet hidden // Auto-refresh wallet data every 30s while mounted (B5 will move this to
// loadLndBalances() // WS-push invalidation; the store dedups overlapping refreshes).
// loadEcashBalance()
// loadTransactions()
// }
// Auto-refresh wallet data every 30s
let walletRefreshInterval: ReturnType<typeof setInterval> | null = null let walletRefreshInterval: ReturnType<typeof setInterval> | null = null
onMounted(() => { onMounted(() => {
@@ -392,20 +356,15 @@ onMounted(() => {
// credentialsRef.value?.loadCredentials() // hidden for now // credentialsRef.value?.loadCredentials() // hidden for now
// sharedContentRef.value?.loadContentItems() // hidden for now // sharedContentRef.value?.loadContentItems() // hidden for now
// Load local state data // Wallet/profits resources fetch themselves on first use (and skip the
loadEcashBalance() // fetch entirely when the cached value is still fresh).
loadNetworkingProfits()
loadLndBalances()
loadTransactions()
detectHardwareWallets() detectHardwareWallets()
// Shared content loaded by the component itself via expose // Shared content loaded by the component itself via expose
// The SharedContent component manages its own loadContentItems // The SharedContent component manages its own loadContentItems
walletRefreshInterval = setInterval(() => { walletRefreshInterval = setInterval(() => {
loadLndBalances() void lndInfoRes.refresh()
loadTransactions()
loadEcashBalance()
}, 30000) }, 30000)
}) })
+6 -1
View File
@@ -47,11 +47,16 @@ if [ -n "$RNODECONF_SRC" ] && [ -f "$RNODECONF_SRC" ]; then
# exit()/quit() builtins, which only exist in interactive Python (site.py # exit()/quit() builtins, which only exist in interactive Python (site.py
# injects them) — a frozen app hits NameError right as it tries to quit # injects them) — a frozen app hits NameError right as it tries to quit
# cleanly, after all the real work already succeeded. See # cleanly, after all the real work already succeeded. See
# pyi_rthook_exit_builtins.py. # pyi_rthook_exit_builtins.py. A second hook fixes rnodeconf's board-flash
# step, which shells out to a bundled esptool.py via `sys.executable` —
# under a frozen binary that's the binary itself, not a real interpreter,
# so the flash subprocess call breaks. See
# pyi_rthook_fix_flasher_executable.py.
.venv/bin/pyinstaller --onefile --name archy-rnodeconf --clean --noconfirm \ .venv/bin/pyinstaller --onefile --name archy-rnodeconf --clean --noconfirm \
--collect-submodules RNS \ --collect-submodules RNS \
--collect-data RNS \ --collect-data RNS \
--runtime-hook pyi_rthook_exit_builtins.py \ --runtime-hook pyi_rthook_exit_builtins.py \
--runtime-hook pyi_rthook_fix_flasher_executable.py \
-d noarchive \ -d noarchive \
"$RNODECONF_SRC" "$RNODECONF_SRC"
echo "Built dist/archy-rnodeconf ($(du -h dist/archy-rnodeconf | cut -f1))" echo "Built dist/archy-rnodeconf ($(du -h dist/archy-rnodeconf | cut -f1))"
@@ -0,0 +1,34 @@
# PyInstaller runtime hook — see build.sh.
#
# rnodeconf's own board-flashing code shells out to a bundled esptool.py as
# `[sys.executable, flasher_path, "--chip", ..., "write_flash", ...]` (RNS's
# rnodeconf.py, ~line 2794 as of RNS 1.3.5). That's correct for a normal
# `python rnodeconf.py` invocation, but under a frozen PyInstaller binary
# `sys.executable` is the frozen binary itself, not a real interpreter — so
# the "subprocess" just re-invokes archy-rnodeconf's OWN argparse CLI with
# esptool-shaped flags, which it doesn't recognize, and the flash step fails
# immediately with "unrecognized arguments: --chip ...". Confirmed live
# against a real Heltec V4 (2026-07-23): device selection, band selection,
# and firmware download all worked; only the final `write_flash` subprocess
# call broke this way.
#
# Fix: point sys.executable at a real Python interpreter that has rnodeconf's
# own runtime deps available (esptool.py only needs pyserial, which RNS
# already depends on) before any of rnodeconf's code runs. Prefer the build
# venv this exact binary was frozen from — see build.sh — falling back to a
# bare `python3` on PATH if that venv isn't present on this node.
import os
import sys
if getattr(sys, "frozen", False):
_candidates = [
os.environ.get("ARCHY_RNODECONF_PYTHON", ""),
os.path.join(os.path.dirname(os.path.abspath(sys.argv[0])), "..", "reticulum-daemon", ".venv", "bin", "python3"),
os.path.expanduser("~/archy/reticulum-daemon/.venv/bin/python3"),
]
for _candidate in _candidates:
if _candidate and os.path.isfile(_candidate):
sys.executable = _candidate
break
else:
sys.executable = "python3"
+102 -23
View File
@@ -14,12 +14,13 @@ Security posture (see the plan's "most secure way" section):
RPC (one JSON object per line, both directions): RPC (one JSON object per line, both directions):
in : {"cmd":"send","dest_hash":"<hex16>","content":"","title":"","method":"direct|opportunistic"} in : {"cmd":"send","dest_hash":"<hex16>","content":"","title":"","method":"direct|opportunistic"}
{"cmd":"announce"} {"cmd":"announce"}
{"cmd":"set_name","name":""}
{"cmd":"status"} {"cmd":"status"}
{"cmd":"send_resource","id":"<correlation>","dest_hash":"<hex16>","data_b64":""} {"cmd":"send_resource","id":"<correlation>","dest_hash":"<hex16>","data_b64":""}
{"cmd":"shutdown"} {"cmd":"shutdown"}
out: {"event":"ready","dest_hash":"<hex16>","display_name":""} 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":"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":"delivered","dest_hash":"<hex16>","state":"delivered|failed","id":"<hex>"}
{"event":"status","connected":bool,"dest_hash":"<hex16>","interfaces":[]} {"event":"status","connected":bool,"dest_hash":"<hex16>","interfaces":[]}
{"event":"resource_progress","id":"<correlation>","transferred":n,"total":n} {"event":"resource_progress","id":"<correlation>","transferred":n,"total":n}
@@ -227,31 +228,47 @@ class ReticulumDaemon:
if self.delivery_destination is not None: if self.delivery_destination is not None:
self.delivery_destination.announce(app_data=self._announce_app_data()) self.delivery_destination.announce(app_data=self._announce_app_data())
def _announce_app_data(self) -> bytes: def _archy_identity_blob(self):
"""Carry the Archy identity so peers bind this RNS destination onto the """The ``ARCHY:2:{ed25519_hex}:{x25519_hex}`` identity string the Rust
existing contact, the same way a meshcore/Meshtastic identity advert does. side parses (``protocol::parse_identity_broadcast``) and binds via
``handle_identity_received`` so a Reticulum-carried identity merges
Reuses the exact ``ARCHY:2:{ed25519_hex}:{x25519_hex}`` wire format the into the SAME conversation as the meshcore/Meshtastic/federation twins
Rust side already parses (``protocol::parse_identity_broadcast``) and of the same Archy node. The keys are the node's real Archipelago
binds via ``handle_identity_received``/``bind_federation_twins`` so a pubkeys (passed in by the Rust side) NOT this daemon's
Reticulum-carried identity merges into the SAME conversation as the internally-HKDF-derived RNS keys, which exist only to make the RNS
meshcore/Meshtastic/federation twins of the same Archy node, satisfying destination hash deterministic. ``None`` when the pubkeys weren't
cross-protocol DM convergence. The keys are the node's real Archipelago supplied (dev/selftest run)."""
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`.
"""
if self.args.archy_ed_pubkey_hex and self.args.archy_x25519_pubkey_hex: if self.args.archy_ed_pubkey_hex and self.args.archy_x25519_pubkey_hex:
return ( return (
f"ARCHY:2:{self.args.archy_ed_pubkey_hex}:" f"ARCHY:2:{self.args.archy_ed_pubkey_hex}:"
f"{self.args.archy_x25519_pubkey_hex}" f"{self.args.archy_x25519_pubkey_hex}"
).encode("ascii") ).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 ---- # ---- RNS-thread callbacks → asyncio ----
def _on_lxmf_delivery(self, message): def _on_lxmf_delivery(self, message):
@@ -328,6 +345,15 @@ class ReticulumDaemon:
self._send(req) self._send(req)
elif cmd == "announce": elif cmd == "announce":
self.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": elif cmd == "status":
self._broadcast(self._status()) self._broadcast(self._status())
elif cmd == "send_resource": elif cmd == "send_resource":
@@ -521,10 +547,41 @@ class _AnnounceHandler:
self.receive_path_responses = True self.receive_path_responses = True
def received_announce(self, destination_hash, announced_identity, app_data): 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({ self.daemon._emit_threadsafe({
"event": "announce", "event": "announce",
"dest_hash": destination_hash.hex(), "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: if args.selftest:
args.no_radio = True args.no_radio = True
daemon.bring_up() 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} " 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 return 0
for sig in (signal.SIGINT, signal.SIGTERM): for sig in (signal.SIGINT, signal.SIGTERM):
-278
View File
@@ -1,278 +0,0 @@
#!/usr/bin/env bash
# Flash stock OpenWrt on a GL.iNet GL-MT3000 (Beryl AX)
#
# Usage:
# ./flash-mt3000-openwrt.sh <router-password> [router-ip] [--image /path/to/image.bin]
#
# Defaults to 192.168.8.1.
# Requires: curl, sshpass, ssh, sha256sum
# Optional: scp (falls back to pipe if unavailable on router)
#
# What it does:
# 1. Verifies tools and connectivity
# 2. Downloads the stock OpenWrt sysupgrade image (or uses a pre-downloaded one)
# 3. Verifies SHA256 checksum
# 4. Uploads image to the router (SCP or pipe)
# 5. Flashes via sysupgrade (no settings preserved)
# 6. Waits for reboot and verifies new firmware
#
# After flash, stock OpenWrt is at 192.168.1.1 (root, no password).
set -euo pipefail
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
log() { echo -e "${GREEN}[+]${NC} $*"; }
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
err() { echo -e "${RED}[-]${NC} $*" >&2; }
# --- Parse args ---
PASSWORD=""
ROUTER="192.168.8.1"
LOCAL_IMAGE=""
while [[ $# -gt 0 ]]; do
case "$1" in
--image)
LOCAL_IMAGE="$2"
shift 2
;;
--router)
ROUTER="$2"
shift 2
;;
*)
if [ -z "$PASSWORD" ]; then
PASSWORD="$1"
else
ROUTER="$1"
fi
shift
;;
esac
done
if [ -z "$PASSWORD" ]; then
echo "Usage: $0 <router-password> [router-ip] [--image /path/to/sysupgrade.bin]"
echo ""
echo "Options:"
echo " --image PATH Use a pre-downloaded sysupgrade image instead of downloading"
echo " --router IP Router IP (default: 192.168.8.1)"
exit 1
fi
SSH_USER="root"
OPENWRT_VERSION="24.10.2"
IMAGE_NAME="openwrt-${OPENWRT_VERSION}-mediatek-filogic-glinet_gl-mt3000-squashfs-sysupgrade.bin"
IMAGE_URL="https://downloads.openwrt.org/releases/${OPENWRT_VERSION}/targets/mediatek/filogic/${IMAGE_NAME}"
CHECKSUM_URL="https://downloads.openwrt.org/releases/${OPENWRT_VERSION}/targets/mediatek/filogic/sha256sums"
DOWNLOAD_DIR="/tmp/openwrt-flash"
DEFAULT_IMAGE="${DOWNLOAD_DIR}/${IMAGE_NAME}"
NEW_IP="192.168.1.1"
SSH_CMD="sshpass -p ${PASSWORD} ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 ${SSH_USER}@${ROUTER}"
# --- Step 1: Check tools ---
log "Checking required tools..."
MISSING=()
for cmd in curl sshpass ssh sha256sum; do
command -v "$cmd" >/dev/null 2>&1 || MISSING+=("$cmd")
done
if [ ${#MISSING[@]} -gt 0 ]; then
err "Missing tools: ${MISSING[*]}"
exit 1
fi
log "All tools found."
# --- Step 2: Check router connectivity ---
log "Checking router connectivity at ${ROUTER}..."
if ! $SSH_CMD "echo ok" >/dev/null 2>&1; then
err "Cannot SSH into ${ROUTER}. Is the router reachable and is the password correct?"
exit 1
fi
log "Router reachable."
# Verify it's a GL-MT3000
BOARD=$($SSH_CMD "cat /tmp/sysinfo/board_name 2>/dev/null" || true)
if [[ "$BOARD" != *"mt3000"* ]]; then
err "Router board is '${BOARD}', expected GL-MT3000. Aborting."
exit 1
fi
log "Confirmed GL-MT3000 (board: ${BOARD})."
# --- Step 3: Get image ---
if [ -n "$LOCAL_IMAGE" ]; then
# User provided a local image
if [ ! -f "$LOCAL_IMAGE" ]; then
err "Image file not found: ${LOCAL_IMAGE}"
exit 1
fi
log "Using provided image: ${LOCAL_IMAGE}"
else
# Download the image
log "Downloading OpenWrt ${OPENWRT_VERSION} sysupgrade image..."
mkdir -p "$DOWNLOAD_DIR"
if [ -f "$DEFAULT_IMAGE" ]; then
warn "Image already exists locally, skipping download."
else
# Test if HTTPS is being intercepted (common with GL.iNet routers)
CERT_ISSUE=false
CERT_INFO=$(curl -v --connect-timeout 5 "https://downloads.openwrt.org" 2>&1 || true)
if echo "$CERT_INFO" | grep -qi "GLiNet\|gl-inet\|self-signed"; then
CERT_ISSUE=true
warn "HTTPS interception detected — the router is MITM-ing TLS connections."
warn "This is the same issue that breaks Tailscale."
fi
if [ "$CERT_ISSUE" = true ]; then
warn "Attempting download via HTTP..."
HTTP_URL="${IMAGE_URL/https:/http:}"
if ! curl -fSL --progress-bar --connect-timeout 10 -o "$DEFAULT_IMAGE" "$HTTP_URL" 2>/dev/null; then
err ""
err "Download failed. The router is intercepting HTTPS and HTTP is also blocked."
err ""
err "Workaround: download the image on a machine NOT behind this router,"
err "then run this script with --image /path/to/${IMAGE_NAME}"
err ""
err "Direct download URL:"
err " ${IMAGE_URL}"
err ""
exit 1
fi
else
if ! curl -fSL --progress-bar -o "$DEFAULT_IMAGE" "$IMAGE_URL"; then
err "Download failed from ${IMAGE_URL}"
exit 1
fi
fi
fi
LOCAL_IMAGE="$DEFAULT_IMAGE"
fi
log "Image ready: $(ls -lh "$LOCAL_IMAGE" | awk '{print $5}')"
# --- Step 4: Verify checksum ---
log "Verifying SHA256 checksum..."
EXPECTED_HASH=""
CHECKSUM_ATTEMPTS=(
"https://downloads.openwrt.org/releases/${OPENWRT_VERSION}/targets/mediatek/filogic/sha256sums"
"http://downloads.openwrt.org/releases/${OPENWRT_VERSION}/targets/mediatek/filogic/sha256sums"
)
for url in "${CHECKSUM_ATTEMPTS[@]}"; do
EXPECTED_HASH=$(curl -fsSL --connect-timeout 5 "$url" 2>/dev/null | grep "${IMAGE_NAME}" | awk '{print $1}' || true)
if [ -n "$EXPECTED_HASH" ]; then
break
fi
done
if [ -n "$EXPECTED_HASH" ]; then
ACTUAL_HASH=$(sha256sum "$LOCAL_IMAGE" | awk '{print $1}')
if [ "$EXPECTED_HASH" != "$ACTUAL_HASH" ]; then
err "Checksum mismatch!"
err " Expected: ${EXPECTED_HASH}"
err " Actual: ${ACTUAL_HASH}"
exit 1
fi
log "Checksum OK: ${ACTUAL_HASH:0:16}..."
else
warn "Could not fetch expected checksum (network issue?), skipping verification."
fi
# --- Step 5: Pre-flash checks ---
log "Running pre-flash checks on router..."
# Check free space on /tmp
FREE_KB=$($SSH_CMD "df /tmp | tail -1 | awk '{print \$4}'")
IMAGE_SIZE_KB=$(( $(stat -c%s "$LOCAL_IMAGE") / 1024 ))
if [ "$FREE_KB" -lt "$((IMAGE_SIZE_KB + 10240))" ]; then
err "Not enough space on /tmp. Need ~$((IMAGE_SIZE_KB/1024))MB, have $((FREE_KB/1024))MB."
exit 1
fi
log "Free space on /tmp: $((FREE_KB/1024))MB, image: $((IMAGE_SIZE_KB/1024))MB — OK."
# Verify current firmware
CURRENT_FW=$($SSH_CMD "cat /etc/openwrt_release 2>/dev/null | grep DISTRIB_DESCRIPTION" || true)
log "Current firmware: ${CURRENT_FW}"
# --- Step 6: Upload image ---
log "Uploading image to router..."
# Try SCP first, fall back to pipe (GL.iNet firmware lacks sftp-server)
if command -v scp >/dev/null 2>&1 && \
sshpass -p "$PASSWORD" scp -o StrictHostKeyChecking=no -o ConnectTimeout=5 -o BatchMode=yes "$LOCAL_IMAGE" "${SSH_USER}@${ROUTER}:/tmp/openwrt-sysupgrade.bin" 2>/dev/null; then
log "Upload complete (via SCP)."
else
log "SCP unavailable on router, using pipe transfer..."
cat "$LOCAL_IMAGE" | $SSH_CMD "cat > /tmp/openwrt-sysupgrade.bin"
log "Upload complete (via pipe)."
fi
# Verify uploaded file
REMOTE_HASH=$($SSH_CMD "sha256sum /tmp/openwrt-sysupgrade.bin | awk '{print \$1}'")
LOCAL_HASH=$(sha256sum "$LOCAL_IMAGE" | awk '{print $1}')
if [ "$REMOTE_HASH" != "$LOCAL_HASH" ]; then
err "Remote file hash mismatch after upload!"
$SSH_CMD "rm -f /tmp/openwrt-sysupgrade.bin"
exit 1
fi
log "Remote file verified."
# --- Step 7: Flash ---
echo ""
log "============================================"
log "FLASHING STOCK OPENWRT ${OPENWRT_VERSION}"
log "The router will reboot. This takes ~3-5 min."
log "After flash, OpenWrt will be at ${NEW_IP}"
log "============================================"
echo ""
$SSH_CMD "sysupgrade -n /tmp/openwrt-sysupgrade.bin" &
SCP_PID=$!
# sysupgrade disconnects SSH, wait for it
wait $SCP_PID 2>/dev/null || true
log "Flash initiated. Waiting for router to reboot..."
sleep 15
# --- Step 8: Wait for new router ---
log "Waiting for stock OpenWrt at ${NEW_IP}..."
for i in $(seq 1 90); do
if ping -c1 -W2 "$NEW_IP" >/dev/null 2>&1; then
log "Router is up at ${NEW_IP}!"
break
fi
if [ $i -eq 90 ]; then
warn "Router not responding at ${NEW_IP} after 3 minutes."
warn "It may still be booting. Try: ssh root@${NEW_IP}"
warn "If unreachable, hold reset for U-Boot recovery at 192.168.1.1"
exit 1
fi
printf "\r Waiting... (%d/90)" $i
sleep 2
done
echo ""
# --- Step 9: Verify new firmware ---
log "Verifying new firmware..."
for attempt in 1 2 3; do
if ssh -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new root@${NEW_IP} "cat /etc/openwrt_release" 2>/dev/null; then
echo ""
log "============================================"
log "FLASH COMPLETE"
log "Stock OpenWrt is running at ${NEW_IP}"
log "Login: root (no password)"
log "Run 'passwd' to set a root password."
log "============================================"
exit 0
fi
sleep 5
done
warn "Could not verify firmware. Router is reachable but SSH may still be starting."
warn "Try: ssh root@${NEW_IP}"
-85
View File
@@ -1,85 +0,0 @@
#!/usr/bin/env bash
# ./gl-inet-enable-ssh.sh <password> [router-ip]
#
# Defaults to 192.168.8.1. It does:
#
# 1. Completes OOBE/init (sets password + enables SSH)
# 2. Challenge-response login
# 3. Explicitly enables SSH via API
# 4. Verifies SSH is working
#
# Needs curl, python3, openssl, sshpass, and md5sum on the machine running it.
set -euo pipefail
ROUTER="${GL_ROUTER:-192.168.8.1}"
PASSWORD="${1:?Usage: $0 <password> [router-ip]}"
HOST="${2:-$ROUTER}"
challenge() {
curl -sk "http://$HOST/rpc" \
-H "Content-Type: application/json" \
-d "{\"jsonrpc\":\"2.0\",\"method\":\"challenge\",\"params\":{\"username\":\"root\"},\"id\":1}"
}
login() {
local hash="$1"
curl -sk "http://$HOST/rpc" \
-H "Content-Type: application/json" \
-d "{\"jsonrpc\":\"2.0\",\"method\":\"login\",\"params\":{\"username\":\"root\",\"hash\":\"$hash\"},\"id\":2}"
}
rpc_call() {
local sid="$1" module="$2" method="$3" params="$4"
curl -sk "http://$HOST/rpc" \
-H "Content-Type: application/json" \
-d "{\"jsonrpc\":\"2.0\",\"method\":\"call\",\"params\":[\"$sid\",\"$module\",\"$method\",$params],\"id\":3}"
}
# Step 1: Complete OOBE / init (enables SSH)
echo "Initializing router..."
INIT=$(curl -sk "http://$HOST/rpc" \
-H "Content-Type: application/json" \
-d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"call\",\"params\":[\"\",\"ui\",\"init\",{\"lang\":\"en\",\"username\":\"root\",\"password\":\"$PASSWORD\",\"security_rule\":0}]}")
echo "$INIT"
if echo "$INIT" | grep -q '"error"'; then
echo "Init returned error (may already be initialized), continuing..."
fi
sleep 1
# Step 2: Challenge-response login
echo "Logging in..."
CHALLENGE=$(challenge)
SALT=$(echo "$CHALLENGE" | python3 -c "import sys,json; print(json.load(sys.stdin)['result']['salt'])")
NONCE=$(echo "$CHALLENGE" | python3 -c "import sys,json; print(json.load(sys.stdin)['result']['nonce'])")
ALG=$(echo "$CHALLENGE" | python3 -c "import sys,json; print(json.load(sys.stdin)['result']['alg'])")
CIPHER=$(openssl passwd "-$ALG" -salt "$SALT" "$PASSWORD" 2>/dev/null)
HASH=$(printf "root:%s:%s" "$CIPHER" "$NONCE" | md5sum | cut -d' ' -f1)
LOGIN=$(login "$HASH")
SID=$(echo "$LOGIN" | python3 -c "import sys,json; print(json.load(sys.stdin)['result']['sid'])" 2>/dev/null)
if [ -z "$SID" ]; then
echo "Login failed: $LOGIN"
echo "SSH should still be enabled from the init call. Try: ssh root@$HOST"
exit 1
fi
echo "Logged in. SID: $SID"
# Step 3: Enable SSH explicitly
echo "Enabling SSH..."
SSH_RESULT=$(rpc_call "$SID" "system" "set_settings" '{"key":"ssh","value":{"enable":true}}')
echo "$SSH_RESULT"
# Step 4: Verify SSH
echo "Verifying SSH on $HOST:22..."
if sshpass -p "$PASSWORD" ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no root@$HOST "echo SSH OK" 2>/dev/null; then
echo "Done. SSH is enabled on root@$HOST"
else
echo "SSH port not responding yet. May need a moment or SSH may already be enabled."
echo "Try: ssh root@$HOST"
fi
+82
View File
@@ -84,6 +84,64 @@ if ! command -v nano >/dev/null 2>&1; then
fi fi
fi fi
if ! command -v ping >/dev/null 2>&1; then
log "Installing iputils-ping..."
if sudo apt-get update -qq 2>>"$LOG_FILE" && sudo apt-get install -y -qq iputils-ping 2>>"$LOG_FILE"; then
ok "ping installed"
else
warn "Unable to install ping automatically; continuing update"
fi
fi
if ! command -v esptool >/dev/null 2>&1; then
log "Installing esptool for LoRa radio firmware flashing..."
if sudo apt-get update -qq 2>>"$LOG_FILE" && sudo apt-get install -y -qq esptool 2>>"$LOG_FILE"; then
ok "esptool installed"
else
warn "Unable to install esptool automatically; radio firmware flashing will be unavailable"
fi
fi
# Debian's esptool package (4.7.0+dfsg-0.1) ships without the precompiled
# esp32s3 "stub flasher" blob (stripped for DFSG compliance — no
# buildable-from-source path Debian could verify). Without it, esptool's
# normal stub-loader mode fails outright (FileNotFoundError), and the ROM
# bootloader fallback (--no-stub) doesn't implement a full-chip erase at
# all — confirmed live 2026-07-23 flashing a real Heltec V4, both ways.
# Fetching the exact same file from the matching upstream esptool release
# tag restores full (and correct) flashing behavior — it's the same
# open-source codebase, just the one blob Debian's packaging couldn't
# include.
if command -v esptool >/dev/null 2>&1; then
STUB_DIR="/usr/lib/python3/dist-packages/esptool/targets/stub_flasher"
STUB_FILE="$STUB_DIR/stub_flasher_32s3.json"
if [ ! -f "$STUB_FILE" ]; then
log "Fetching esptool's esp32s3 stub flasher (missing from the Debian package)..."
ESPTOOL_VERSION=$(esptool version 2>/dev/null | tail -1 | tr -d ' \t')
if [ -n "$ESPTOOL_VERSION" ] && sudo curl -fsSL -o "$STUB_FILE" \
"https://raw.githubusercontent.com/espressif/esptool/v${ESPTOOL_VERSION}/esptool/targets/stub_flasher/stub_flasher_32s3.json" \
2>>"$LOG_FILE"; then
sudo chmod 644 "$STUB_FILE"
ok "esp32s3 stub flasher installed"
else
sudo rm -f "$STUB_FILE" 2>/dev/null
warn "Unable to fetch esp32s3 stub flasher; LoRa firmware flashing will be unavailable"
fi
fi
fi
# Build-time prerequisites for reticulum-daemon/build.sh's PyInstaller step
# below (discovered the hard way: ensurepip needs python3-venv, and
# PyInstaller itself needs objdump + libpython3.13.so at build time — none
# of these are pulled in by a bare `python3` package on Debian trixie).
for pkg in python3-venv binutils libpython3.13; do
if ! dpkg -s "$pkg" >/dev/null 2>&1; then
log "Installing $pkg (reticulum-daemon build prerequisite)..."
sudo apt-get update -qq 2>>"$LOG_FILE" && sudo apt-get install -y -qq "$pkg" 2>>"$LOG_FILE" \
|| warn "Unable to install $pkg automatically; reticulum-daemon tools build may fail"
fi
done
# Fetch latest # Fetch latest
log "Fetching from origin..." log "Fetching from origin..."
git fetch origin main --quiet 2>>"$LOG_FILE" git fetch origin main --quiet 2>>"$LOG_FILE"
@@ -155,6 +213,30 @@ sudo cp "$BUILT_BIN" "$INSTALL_BIN"
sudo chmod +x "$INSTALL_BIN" sudo chmod +x "$INSTALL_BIN"
ok "Backend installed" ok "Backend installed"
# Build + install reticulum-daemon tools (archy-reticulum-daemon, archy-rnodeconf).
# Non-fatal: archipelago falls back to its dev venv path if the packaged
# binaries aren't present, so a missing/failed build here degrades mesh
# Reticulum support rather than breaking the update. This mirrors
# deploy-to-target.sh's existing manual-deploy step, which until now was the
# only path that ever installed these — a node that only ever received OTA
# self-updates had neither binary.
if [ -f "$REPO_DIR/reticulum-daemon/build.sh" ]; then
log "Building reticulum-daemon tools (archy-reticulum-daemon, archy-rnodeconf)..."
if (cd "$REPO_DIR/reticulum-daemon" && ./build.sh) 2>>"$LOG_FILE"; then
for tool in archy-reticulum-daemon archy-rnodeconf; do
if [ -f "$REPO_DIR/reticulum-daemon/dist/$tool" ]; then
sudo cp "$REPO_DIR/reticulum-daemon/dist/$tool" /usr/local/bin/
sudo chmod +x "/usr/local/bin/$tool"
ok "$tool installed"
else
warn "$tool not built — leaving existing /usr/local/bin/$tool (if any) in place"
fi
done
else
warn "reticulum-daemon tools build failed — continuing without updating them"
fi
fi
# Build frontend # Build frontend
log "Building Vue frontend (production)..." log "Building Vue frontend (production)..."
cd "$FRONTEND_DIR" cd "$FRONTEND_DIR"
+85
View File
@@ -0,0 +1,85 @@
#!/bin/bash
# Mesh / Reticulum test suite — the "is the mesh stack healthy" gate.
#
# Three layers, cheapest first:
# 1. Rust unit tests (no hardware, ~2s once built)
# 2. Daemon selftest (full RNS+LXMF bring-up, no radio; also verifies
# the announce app_data wire contract + set_name)
# 3. Live-node assertions (optional; needs a running archipelago with a
# radio — set MESH_TEST_LIVE=1 MESH_TEST_PW=...)
#
# Usage:
# tests/mesh/run-mesh-tests.sh # layers 1+2
# MESH_TEST_LIVE=1 MESH_TEST_PW='...' tests/mesh/run-mesh-tests.sh
# MESH_TEST_HOST=100.113.100.55 ... # live-test a remote node
set -u
cd "$(dirname "$0")/../.."
FAIL=0
ok() { echo "ok - $1"; }
bad() { echo "not ok - $1"; FAIL=1; }
# ── 1. Rust unit tests ────────────────────────────────────────────────
RUST_RESULTS=$(cd core && cargo test -p archipelago --bin archipelago mesh 2>&1 | grep "^test result:")
if [ -n "$RUST_RESULTS" ] && ! echo "$RUST_RESULTS" | grep -vq " 0 failed"; then
ok "rust mesh unit tests ($(echo "$RUST_RESULTS" | grep -o '[0-9]* passed' | head -1))"
else
bad "rust mesh unit tests"
fi
# ── 2. Reticulum daemon selftest (no radio) ───────────────────────────
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
head -c 32 /dev/urandom > "$TMP/key"
DAEMON=reticulum-daemon/.venv/bin/python
if [ -x "$DAEMON" ]; then
if "$DAEMON" reticulum-daemon/reticulum_daemon.py \
--identity-key "$TMP/key" --rns-config "$TMP/rns" \
--socket "$TMP/sock" --display-name "SelftestNode" --selftest 2>/dev/null \
| grep -q "announce_app_data=verified set_name=verified"; then
ok "daemon selftest (announce wire contract + set_name)"
else
bad "daemon selftest"
fi
else
echo "skip - daemon selftest (no venv at $DAEMON)"
fi
# ── 3. Live node assertions (opt-in) ──────────────────────────────────
if [ "${MESH_TEST_LIVE:-0}" = "1" ]; then
HOST="${MESH_TEST_HOST:-127.0.0.1}"
PW="${MESH_TEST_PW:?set MESH_TEST_PW}"
# Nodes differ: dev boxes serve plain http on :80, ISO installs https.
RPC=""
for base in "http://$HOST" "https://$HOST" "http://$HOST:5678"; do
code=$(curl -ksS -o /dev/null -w '%{http_code}' -m 5 -X POST "$base/rpc/v1" 2>/dev/null || true)
case "$code" in 000|"") continue ;; *) RPC="$base/rpc/v1"; break ;; esac
done
[ -n "$RPC" ] || { bad "live: no RPC endpoint reachable on $HOST"; echo FAIL; exit 1; }
JAR="$TMP/jar"
curl -ksS -c "$JAR" -H "Content-Type: application/json" \
-d "{\"jsonrpc\":\"2.0\",\"method\":\"auth.login\",\"params\":{\"password\":\"$PW\"},\"id\":1}" \
"$RPC" > "$TMP/login"
if grep -q '"error":null' "$TMP/login"; then ok "live: rpc login"; else bad "live: rpc login"; fi
call() {
local csrf; csrf=$(awk '/^[^#]/ && /csrf_token/ {print $7; exit}' "$JAR")
curl -ksS -b "$JAR" -c "$JAR" -H "Content-Type: application/json" \
-H "X-CSRF-Token: $csrf" \
-d "{\"jsonrpc\":\"2.0\",\"method\":\"$1\",\"params\":${2:-{\}},\"id\":2}" \
--max-time 60 "$RPC"
}
ST=$(call mesh.status)
echo "$ST" | grep -q '"device_connected":true' \
&& ok "live: radio connected ($(echo "$ST" | grep -o '"device_type":"[a-z]*"'))" \
|| bad "live: radio connected"
echo "$ST" | grep -q '"self_advert_name":"[^"]' \
&& ok "live: node has a mesh name" || bad "live: node has a mesh name"
call mesh.refresh | grep -q '"refreshed":true' \
&& ok "live: mesh.refresh" || bad "live: mesh.refresh"
call mesh.broadcast | grep -q '"broadcast":true' \
&& ok "live: mesh.broadcast" || bad "live: mesh.broadcast"
# No peer may ever display a raw identity blob as its name.
call mesh.peers | grep -q '"advert_name":"ARCHY:' \
&& bad "live: no ARCHY-blob peer names" || ok "live: no ARCHY-blob peer names"
fi
[ "$FAIL" = 0 ] && echo "PASS" || { echo "FAIL"; exit 1; }