Compare commits

..
Author SHA1 Message Date
ssmithx 0651e8c615 Merge branch 'openwrt-enhancements' of http://146.59.87.168:3000/lfg2025/archy into openwrt-enhancements 2026-07-28 13:39:51 -04:00
ssmithx 7b69200244 adding some buttons 2026-07-28 13:39:00 -04:00
ssmithxandClaude Sonnet 5 2da8e2c698 feat(openwrt): add "Forget router" to actually clear saved config
"Switch router" only reset local Vue state, leaving router_config.json
on disk — a reload silently reconnected with the old saved SSH
password. Add openwrt.forget RPC + forget_router_config() to delete
the saved config, and a "Forget router" button in OpenWrtGateway.vue.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 18:14:54 -04:00
ssmithxandssmithx cebbd10127 updated flashing script 2026-07-27 18:11:13 -04:00
ssmithxandssmithx 5d05529d11 adding flashing script - needs work 2026-07-27 18:11:12 -04:00
ssmithxandssmithx 72e480e9f7 first of openwrt-tollgate-UI enhancements 2026-07-27 18:11:12 -04:00
ssmithx 74d50719ac updated flashing script 2026-07-26 23:24:01 +00:00
ssmithx eb74bc3fe4 adding flashing script - needs work 2026-07-26 23:15:26 +00:00
ssmithx b13751e6d1 first of openwrt-tollgate-UI enhancements 2026-07-26 23:07:17 +00:00
44 changed files with 1133 additions and 1940 deletions
@@ -465,7 +465,6 @@ impl RpcHandler {
signing_key.as_ref().map(|i| i.signing_key()),
Some(&peer.pubkey),
data.server_info.name.as_deref(),
Some(&self.config.data_dir),
)
.await
}
@@ -244,6 +244,8 @@ impl RpcHandler {
// OpenWrt / TollGate
"openwrt.scan" => self.handle_openwrt_scan(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.scan-wifi" => self.handle_openwrt_scan_wifi(params).await,
"openwrt.configure-wan" => self.handle_openwrt_configure_wan(params).await,
@@ -866,8 +866,7 @@ impl RpcHandler {
)
.service(crate::settings::transport::PeerService::Peers)
.timeout(std::time::Duration::from_secs(30))
.fips_timeout(std::time::Duration::from_secs(6))
.record_transport(&self.config.data_dir);
.fips_timeout(std::time::Duration::from_secs(6));
match req.send_json(&body).await {
Ok((resp, transport)) if resp.status().is_success() => {
+1 -8
View File
@@ -13,14 +13,7 @@ use anyhow::Result;
impl RpcHandler {
pub(super) async fn handle_fips_status(&self) -> Result<serde_json::Value> {
let status = fips::FipsStatus::query(&self.config.data_dir).await;
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)
Ok(serde_json::to_value(status)?)
}
/// Everything the companion app needs to join this node's mesh, embedded
@@ -821,7 +821,6 @@ impl RpcHandler {
.service(crate::settings::transport::PeerService::MeshFileSharing)
.timeout(std::time::Duration::from_secs(120))
.fips_timeout(std::time::Duration::from_secs(8))
.record_transport(&self.config.data_dir)
.send_get()
.await
.map_err(|e| anyhow::anyhow!("Fetch failed: {}", e))?;
-2
View File
@@ -137,7 +137,6 @@ impl RpcHandler {
None,
None,
None,
Some(&self.config.data_dir),
)
.await?;
@@ -226,7 +225,6 @@ impl RpcHandler {
signing_key.as_ref().map(|i| i.signing_key()),
Some(&req.from_pubkey),
data.server_info.name.as_deref(),
Some(&self.config.data_dir),
)
.await
{
+60
View File
@@ -165,6 +165,66 @@ 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.
///
/// Params: `{ "host": "192.168.1.1", "ssh_user": "root", "ssh_password": "",
-1
View File
@@ -133,7 +133,6 @@ impl RpcHandler {
Some(node_id.signing_key()),
recipient_pubkey.as_deref(),
node_name.as_deref(),
Some(&self.config.data_dir),
)
.await?;
Ok(serde_json::json!({ "ok": true, "sent_to": onion }))
+1 -2
View File
@@ -499,8 +499,7 @@ pub(super) async fn notify_federation_peers_address_change(
)
.service(crate::settings::transport::PeerService::Peers)
.timeout(std::time::Duration::from_secs(30))
.fips_timeout(std::time::Duration::from_secs(6))
.record_transport(data_dir);
.fips_timeout(std::time::Duration::from_secs(6));
match req.send_json(&payload).await {
Ok((_, transport)) => {
info!(peer_did = %peer.did, transport = %transport, "Notified peer of address change")
+8 -74
View File
@@ -22,10 +22,8 @@
//! single declarative call.
use anyhow::{Context, Result};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{LazyLock, Mutex};
use std::time::{Duration, Instant};
use std::time::Duration;
use tokio::fs;
use tokio::process::Command;
use tracing::{info, warn};
@@ -37,15 +35,6 @@ const COMPANION_REGISTRY: &str = "146.59.87.168:3000/lfg2025";
const COMPANION_IMAGE_CHECK_TIMEOUT: Duration = Duration::from_secs(15);
const COMPANION_BUILD_TIMEOUT: Duration = Duration::from_secs(900);
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
/// app_id lives in `companions_for`.
@@ -475,28 +464,12 @@ pub async fn reconcile(installed_apps: &[String]) -> Vec<(String, anyhow::Error)
match needs_repair(spec).await {
Ok(false) => {}
Ok(true) => {
if let Some(failed_at) =
REPAIR_FAILED_AT.lock().unwrap().get(spec.name).copied()
{
if failed_at.elapsed() < REPAIR_COOLDOWN {
continue;
}
}
info!(
companion = spec.name,
"reconcile: companion not active, repairing"
);
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));
}
if let Err(e) = install_one(spec).await {
failures.push((spec.name.to_string(), e));
}
}
Err(e) => {
@@ -511,58 +484,19 @@ pub async fn reconcile(installed_apps: &[String]) -> Vec<(String, anyhow::Error)
/// 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.
///
/// 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> {
let dir = quadlet::unit_dir().await?;
let unit_path = dir.join(format!("{}.container", spec.name));
if !fs::try_exists(&unit_path).await.unwrap_or(false) {
return Ok(true);
}
let svc = format!("{}.service", spec.name);
// A hung `systemctl is-active` under IO pressure must not read as
// "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);
}
}
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 {
let expected_image = ensure_image_present(spec).await?;
let expected_unit = build_unit(spec, &expected_image);
if expected_unit.render() != fs::read_to_string(&unit_path).await.unwrap_or_default() {
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)
let svc = format!("{}.service", spec.name);
Ok(!quadlet::is_active(&svc).await)
}
#[cfg(test)]
+19 -30
View File
@@ -232,32 +232,26 @@ pub async fn remove(data_dir: &Path, npub: &str) -> Result<Vec<SeedAnchor>> {
/// leaving `anchor_connected=false` and every peer dial falling back to
/// a slow Tor timeout.
pub async fn apply(anchors: &[SeedAnchor]) -> Vec<ApplyResult> {
// Concurrent, each connect hard-capped: the old serial loop waited
// unbounded on every `sudo fipsctl connect`, so one hung subprocess
// 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([
"-n",
"fipsctl",
"connect",
&anchor.npub,
&anchor.address,
&anchor.transport,
])
.output(),
)
.await;
let mut results = Vec::with_capacity(anchors.len());
for anchor in anchors {
let out = Command::new("sudo")
.args([
"-n",
"fipsctl",
"connect",
&anchor.npub,
&anchor.address,
&anchor.transport,
])
.output()
.await;
let result = match out {
Ok(Ok(o)) if o.status.success() => ApplyResult {
Ok(o) if o.status.success() => ApplyResult {
npub: anchor.npub.clone(),
ok: true,
message: String::from_utf8_lossy(&o.stdout).trim().to_string(),
},
Ok(Ok(o)) => ApplyResult {
Ok(o) => ApplyResult {
npub: anchor.npub.clone(),
ok: false,
message: format!(
@@ -266,16 +260,11 @@ pub async fn apply(anchors: &[SeedAnchor]) -> Vec<ApplyResult> {
String::from_utf8_lossy(&o.stderr).trim()
),
},
Ok(Err(e)) => ApplyResult {
Err(e) => ApplyResult {
npub: anchor.npub.clone(),
ok: false,
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 {
tracing::debug!(npub = %result.npub, "Seed anchor applied");
@@ -286,9 +275,9 @@ pub async fn apply(anchors: &[SeedAnchor]) -> Vec<ApplyResult> {
"Seed anchor apply failed (non-fatal)"
);
}
result
});
futures_util::future::join_all(futs).await
results.push(result);
}
results
}
/// Outcome of a single `fipsctl connect` call.
+12 -121
View File
@@ -24,7 +24,6 @@
//! ```
#![allow(dead_code)]
use super::telemetry::{self, FallbackReason};
use anyhow::{Context, Result};
use std::net::{IpAddr, Ipv6Addr};
use std::time::Duration;
@@ -151,12 +150,6 @@ pub async fn warm_path(npub: &str) {
if !is_service_active().await {
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 {
return;
};
@@ -283,39 +276,21 @@ pub fn as_ip_addr(v6: Ipv6Addr) -> IpAddr {
// ── 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)
/// currently `systemctl is-active`? Cached for [`SERVICE_ACTIVE_TTL_MS`];
/// concurrent refreshes are harmless (idempotent probe, last write wins).
/// currently `systemctl is-active`? Async wrapper intended for the
/// migration call sites; unlike `FipsTransport::is_available` this does
/// not maintain a cache, so callers that poll frequently should cache
/// themselves.
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 [
crate::fips::SERVICE_UNIT,
crate::fips::UPSTREAM_SERVICE_UNIT,
] {
if crate::fips::service::unit_state(unit).await == "active" {
active = true;
break;
return true;
}
}
SERVICE_ACTIVE.store(active, Ordering::Relaxed);
SERVICE_PROBED_AT_MS.store(now_ms, Ordering::Relaxed);
active
false
}
/// Builder for a peer request that may be sent over FIPS (preferred) or
@@ -342,11 +317,6 @@ pub struct PeerRequest<'a> {
/// large content download needs so its long FIPS transfer isn't truncated.
pub fips_timeout: Option<std::time::Duration>,
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> {
@@ -359,31 +329,6 @@ impl<'a> PeerRequest<'a> {
timeout: std::time::Duration::from_secs(30),
fips_timeout: 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;
});
}
}
@@ -444,22 +389,8 @@ impl<'a> PeerRequest<'a> {
// fix (404 path-not-served / 5xx) and we're allowed to
// fall back. FIPS-only never falls back.
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));
}
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 => {
if pref == TransportPref::Fips {
@@ -471,7 +402,6 @@ impl<'a> PeerRequest<'a> {
}
}
let resp = self.send_tor_post_json(body).await?;
self.spawn_record(crate::transport::TransportKind::Tor);
Ok((resp, crate::transport::TransportKind::Tor))
}
@@ -483,22 +413,8 @@ impl<'a> PeerRequest<'a> {
match self.try_fips_get().await? {
Some(resp) => {
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));
}
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 => {
if pref == TransportPref::Fips {
@@ -510,7 +426,6 @@ impl<'a> PeerRequest<'a> {
}
}
let resp = self.send_tor_get().await?;
self.spawn_record(crate::transport::TransportKind::Tor);
Ok((resp, crate::transport::TransportKind::Tor))
}
@@ -519,23 +434,15 @@ impl<'a> PeerRequest<'a> {
body: &B,
) -> Result<Option<reqwest::Response>> {
let Some(npub) = self.fips_npub else {
telemetry::record_fallback(FallbackReason::NoNpub);
return Ok(None);
};
if !is_service_active().await {
telemetry::record_fallback(FallbackReason::ServiceInactive);
return Ok(None);
}
let base = match peer_base_url(npub).await {
Ok(b) => b,
Err(e) => {
telemetry::record_fallback(FallbackReason::DnsFail);
tracing::info!(
reason = FallbackReason::DnsFail.key(),
"FIPS resolve for {} failed: {}, falling back to Tor",
npub,
e
);
tracing::debug!("FIPS resolve for {} failed: {}", npub, e);
return Ok(None);
}
};
@@ -560,9 +467,7 @@ impl<'a> PeerRequest<'a> {
match tokio::time::timeout(budget, send_with_retry(rb)).await {
Ok(Ok(r)) => Ok(Some(r)),
Ok(Err(e)) => {
telemetry::record_fallback(FallbackReason::ConnectFail);
tracing::info!(
reason = FallbackReason::ConnectFail.key(),
tracing::debug!(
"FIPS POST {} failed after retry: {}, falling back to Tor",
url,
e
@@ -570,9 +475,7 @@ impl<'a> PeerRequest<'a> {
Ok(None)
}
Err(_) => {
telemetry::record_fallback(FallbackReason::ConnectFail);
tracing::info!(
reason = FallbackReason::ConnectFail.key(),
tracing::debug!(
"FIPS POST {} exceeded attempt budget {:?}, falling back to Tor",
url,
budget
@@ -584,23 +487,15 @@ impl<'a> PeerRequest<'a> {
async fn try_fips_get(&self) -> Result<Option<reqwest::Response>> {
let Some(npub) = self.fips_npub else {
telemetry::record_fallback(FallbackReason::NoNpub);
return Ok(None);
};
if !is_service_active().await {
telemetry::record_fallback(FallbackReason::ServiceInactive);
return Ok(None);
}
let base = match peer_base_url(npub).await {
Ok(b) => b,
Err(e) => {
telemetry::record_fallback(FallbackReason::DnsFail);
tracing::info!(
reason = FallbackReason::DnsFail.key(),
"FIPS resolve for {} failed: {}, falling back to Tor",
npub,
e
);
tracing::debug!("FIPS resolve for {} failed: {}", npub, e);
return Ok(None);
}
};
@@ -621,9 +516,7 @@ impl<'a> PeerRequest<'a> {
match tokio::time::timeout(budget, send_with_retry(rb)).await {
Ok(Ok(r)) => Ok(Some(r)),
Ok(Err(e)) => {
telemetry::record_fallback(FallbackReason::ConnectFail);
tracing::info!(
reason = FallbackReason::ConnectFail.key(),
tracing::debug!(
"FIPS GET {} failed after retry: {}, falling back to Tor",
url,
e
@@ -631,9 +524,7 @@ impl<'a> PeerRequest<'a> {
Ok(None)
}
Err(_) => {
telemetry::record_fallback(FallbackReason::ConnectFail);
tracing::info!(
reason = FallbackReason::ConnectFail.key(),
tracing::debug!(
"FIPS GET {} exceeded attempt budget {:?}, falling back to Tor",
url,
budget
-205
View File
@@ -1,205 +0,0 @@
//! Last-known-good FIPS peer endpoints (A3.10).
//!
//! The LAN direct-peering tick (`anchors::lan_fips_anchors`) only helps peers
//! we can currently see on the LAN. When a federation peer's LAN path is gone
//! (renumbered network, remote site, mDNS blackout) the only route left is the
//! anchor spanning tree — the exact hairpin RC2 calls out. But if we were EVER
//! connected to that peer directly, the daemon knew a working endpoint for it
//! (`fipsctl show peers` → `transport_addr`/`transport_type`, which covers
//! LAN, Tailscale, and WAN endpoints alike). This module persists those
//! npub-keyed endpoints and re-offers them as dial candidates when the live
//! paths disappear: LAN → last-known-good → anchor tree.
//!
//! Persisted at `<data_dir>/fips-endpoints.json`. Entries are refreshed every
//! time the peer is seen connected and dropped after `RETENTION` without a
//! sighting, so a peer that genuinely moved doesn't get dialed at a stale
//! address forever ( `fipsctl connect` to a dead address is harmless but not
//! free).
use std::collections::HashMap;
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::Result;
use serde::{Deserialize, Serialize};
use tokio::fs;
use super::anchors::SeedAnchor;
const FILE_NAME: &str = "fips-endpoints.json";
/// Forget endpoints not seen connected for this long (seconds) — 30 days.
const RETENTION_SECS: u64 = 30 * 24 * 60 * 60;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct KnownEndpoint {
/// "ip:port" as reported by the daemon (`transport_addr`).
pub address: String,
/// "udp" | "tcp" (`transport_type`).
pub transport: String,
/// Unix seconds of the last time this peer was seen connected here.
pub last_ok_unix: u64,
}
/// A currently-connected peer as parsed from `fipsctl show peers`.
#[derive(Debug, Clone)]
pub struct ConnectedPeer {
pub npub: String,
pub address: String,
pub transport: String,
}
fn now_unix() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
pub async fn load(data_dir: &Path) -> HashMap<String, KnownEndpoint> {
let path = data_dir.join(FILE_NAME);
match fs::read(&path).await {
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_default(),
Err(_) => HashMap::new(),
}
}
async fn save(data_dir: &Path, map: &HashMap<String, KnownEndpoint>) -> Result<()> {
let path = data_dir.join(FILE_NAME);
let tmp = data_dir.join(format!("{FILE_NAME}.tmp"));
fs::write(&tmp, serde_json::to_vec_pretty(map)?).await?;
fs::rename(&tmp, &path).await?;
Ok(())
}
/// Merge the currently-connected peers into the store (refreshing their
/// timestamps), prune expired entries, persist, and return the updated map.
/// Persistence failures are non-fatal — the in-memory result is still
/// returned so this tick's fallback logic works.
pub async fn record_connected(
data_dir: &Path,
connected: &[ConnectedPeer],
) -> HashMap<String, KnownEndpoint> {
let mut map = load(data_dir).await;
let now = now_unix();
let before = map.clone();
for p in connected {
if p.npub.is_empty() || p.address.is_empty() {
continue;
}
map.insert(
p.npub.clone(),
KnownEndpoint {
address: p.address.clone(),
transport: p.transport.clone(),
last_ok_unix: now,
},
);
}
map.retain(|_, e| now.saturating_sub(e.last_ok_unix) <= RETENTION_SECS);
if map != before {
if let Err(e) = save(data_dir, &map).await {
tracing::debug!("fips endpoint store save failed (non-fatal): {e}");
}
}
map
}
/// Build fallback anchors for federation peers whose live paths are gone:
/// every `wanted_npub` that is neither currently connected nor covered by a
/// live LAN direct entry, but has a last-known-good endpoint, becomes a dial
/// candidate. `fipsctl connect` is idempotent and failure-tolerant, so a
/// stale candidate costs one failed dial, bounded by apply()'s per-connect
/// timeout.
pub fn fallback_anchors(
known: &HashMap<String, KnownEndpoint>,
wanted_npubs: &[String],
connected_npubs: &[String],
lan_direct: &[SeedAnchor],
) -> Vec<SeedAnchor> {
let mut out = Vec::new();
for npub in wanted_npubs {
if connected_npubs.iter().any(|c| c == npub) {
continue;
}
if lan_direct.iter().any(|a| &a.npub == npub) {
continue;
}
if let Some(e) = known.get(npub) {
out.push(SeedAnchor {
npub: npub.clone(),
address: e.address.clone(),
transport: e.transport.clone(),
label: "last-known-good endpoint (direct FIPS)".to_string(),
});
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn ep(addr: &str) -> KnownEndpoint {
KnownEndpoint {
address: addr.to_string(),
transport: "udp".to_string(),
last_ok_unix: now_unix(),
}
}
#[tokio::test]
async fn record_and_reload_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let connected = vec![ConnectedPeer {
npub: "npub1aaa".into(),
address: "100.114.134.21:2121".into(),
transport: "udp".into(),
}];
let map = record_connected(dir.path(), &connected).await;
assert_eq!(map["npub1aaa"].address, "100.114.134.21:2121");
let reloaded = load(dir.path()).await;
assert_eq!(reloaded, map);
}
#[tokio::test]
async fn expired_entries_are_pruned_on_record() {
let dir = tempfile::tempdir().unwrap();
let mut stale = HashMap::new();
stale.insert(
"npub1old".to_string(),
KnownEndpoint {
address: "10.0.0.1:2121".into(),
transport: "udp".into(),
last_ok_unix: now_unix() - RETENTION_SECS - 60,
},
);
save(dir.path(), &stale).await.unwrap();
let map = record_connected(dir.path(), &[]).await;
assert!(map.is_empty());
}
#[test]
fn fallback_skips_connected_and_lan_covered_peers() {
let mut known = HashMap::new();
known.insert("npub1gone".to_string(), ep("100.1.2.3:2121"));
known.insert("npub1conn".to_string(), ep("100.1.2.4:2121"));
known.insert("npub1lan".to_string(), ep("100.1.2.5:2121"));
let wanted: Vec<String> = ["npub1gone", "npub1conn", "npub1lan", "npub1never"]
.iter()
.map(|s| s.to_string())
.collect();
let connected = vec!["npub1conn".to_string()];
let lan = vec![SeedAnchor {
npub: "npub1lan".into(),
address: "192.168.63.198:2121".into(),
transport: "udp".into(),
label: "LAN".into(),
}];
let out = fallback_anchors(&known, &wanted, &connected, &lan);
assert_eq!(out.len(), 1);
assert_eq!(out[0].npub, "npub1gone");
assert_eq!(out[0].address, "100.1.2.3:2121");
// npub1never has no stored endpoint → nothing to dial.
}
}
+4 -54
View File
@@ -29,10 +29,8 @@ pub mod anchors;
pub mod app_ports;
pub mod config;
pub mod dial;
pub mod endpoints;
pub mod iface;
pub mod service;
pub mod telemetry;
pub mod update;
use serde::{Deserialize, Serialize};
@@ -81,73 +79,25 @@ pub async fn ensure_activated(data_dir: &std::path::Path) {
pub fn spawn_fips_supervisor(data_dir: std::path::PathBuf) {
tokio::spawn(async move {
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 {
tick.tick().await;
// Bring FIPS up on its own once onboarding has materialised the key.
ensure_activated(&data_dir).await;
if !dial::is_service_active().await {
prev_connected = None; // daemon restart = fresh edge detection
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)
.await
.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();
for npub in warm_npubs {
// Service-active was checked once above for the whole batch.
handles.push(tokio::spawn(
async move { dial::warm_path_unchecked(&npub).await },
));
for node in nodes {
if let Some(npub) = node.fips_npub.clone() {
handles.push(tokio::spawn(async move { dial::warm_path(&npub).await }));
}
}
for h in handles {
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;
}
}
});
}
-46
View File
@@ -227,52 +227,6 @@ pub async fn peer_connectivity_summary(anchor_candidates: &[String]) -> (u32, bo
(authenticated_peer_count, anchor_connected)
}
/// Currently-connected peers with their live endpoints, from
/// `fipsctl show peers` (`transport_addr`/`transport_type`). Feeds the
/// last-known-good endpoint store (A3.10); empty on any failure.
pub async fn connected_peer_endpoints() -> Vec<crate::fips::endpoints::ConnectedPeer> {
let peers_json = match Command::new("sudo")
.args(["-n", "fipsctl", "show", "peers"])
.output()
.await
{
Ok(o) if o.status.success() => o.stdout,
_ => return Vec::new(),
};
let parsed: serde_json::Value = match serde_json::from_slice(&peers_json) {
Ok(v) => v,
Err(_) => return Vec::new(),
};
parsed
.get("peers")
.and_then(|p| p.as_array())
.map(|peers| {
peers
.iter()
.filter(|p| {
p.get("connectivity")
.and_then(|c| c.as_str())
.map(|s| s == "connected")
.unwrap_or(false)
})
.filter_map(|p| {
let npub = p.get("npub").and_then(|n| n.as_str())?;
let address = p.get("transport_addr").and_then(|a| a.as_str())?;
let transport = p
.get("transport_type")
.and_then(|t| t.as_str())
.unwrap_or("udp");
Some(crate::fips::endpoints::ConnectedPeer {
npub: npub.to_string(),
address: address.to_string(),
transport: transport.to_string(),
})
})
.collect()
})
.unwrap_or_default()
}
/// Read the upstream daemon's public key at `/etc/fips/fips.pub` and return
/// it as a bech32 npub. Returns `Ok(None)` if the file doesn't exist — used
/// as a fallback on legacy/dev nodes where no seed-derived key exists.
-155
View File
@@ -1,155 +0,0 @@
//! 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");
}
}
-5
View File
@@ -134,7 +134,6 @@ pub async fn sync_with_peers(data_dir: &Path, peer_onions: &[String]) -> Result<
for onion in &unique_onions {
let fips_npub = crate::federation::fips_npub_for_onion(data_dir, onion).await;
match sync_single_peer(
data_dir,
fips_npub.as_deref(),
&store,
onion,
@@ -174,7 +173,6 @@ pub async fn sync_with_peers(data_dir: &Path, peer_onions: &[String]) -> Result<
/// Sync with a single peer: pull their messages and push ours.
/// Each HTTP call picks FIPS when a npub is known, otherwise Tor.
async fn sync_single_peer(
data_dir: &Path,
fips_npub: Option<&str>,
store: &crate::network::dwn_store::DwnStore,
onion: &str,
@@ -189,7 +187,6 @@ async fn sync_single_peer(
.service(crate::settings::transport::PeerService::Federation)
.timeout(std::time::Duration::from_secs(30))
.fips_timeout(std::time::Duration::from_secs(6))
.record_transport(data_dir)
.send_get()
.await
.context("Peer DWN unreachable")?;
@@ -216,7 +213,6 @@ async fn sync_single_peer(
.service(crate::settings::transport::PeerService::Federation)
.timeout(std::time::Duration::from_secs(30))
.fips_timeout(std::time::Duration::from_secs(6))
.record_transport(data_dir)
.send_json(&pull_body)
.await
.context("Failed to query peer DWN")?;
@@ -276,7 +272,6 @@ async fn sync_single_peer(
.service(crate::settings::transport::PeerService::Federation)
.timeout(std::time::Duration::from_secs(30))
.fips_timeout(std::time::Duration::from_secs(6))
.record_transport(data_dir)
.send_json(&push_body)
.await
{
+11
View File
@@ -366,6 +366,17 @@ pub async fn save_router_config(data_dir: &Path, config: &RouterConfig) -> Resul
.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).
fn is_valid_private_ip(ip_str: &str) -> bool {
let ip: std::net::IpAddr = match ip_str.parse() {
+2 -9
View File
@@ -342,9 +342,6 @@ pub async fn send_to_peer(
signing_key: Option<&ed25519_dalek::SigningKey>,
recipient_pubkey: 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<()> {
validate_onion(onion)?;
@@ -373,15 +370,11 @@ pub async fn send_to_peer(
body["from_name"] = serde_json::Value::String(name.to_string());
}
let mut req =
let (resp, transport) =
crate::fips::dial::PeerRequest::new(fips_npub, onion, "/archipelago/node-message")
.service(crate::settings::transport::PeerService::Messaging)
.timeout(std::time::Duration::from_secs(60))
.fips_timeout(std::time::Duration::from_secs(8));
if let Some(dir) = record_data_dir {
req = req.record_transport(dir);
}
let (resp, transport) = req
.fips_timeout(std::time::Duration::from_secs(8))
.send_json(&body)
.await
.map_err(|e| {
+13 -80
View File
@@ -786,42 +786,6 @@ impl Server {
if !direct.is_empty() {
let _ = crate::fips::anchors::apply(&direct).await;
}
// A3.10 — endpoint fallback for direct peering. Record
// where currently-connected peers actually are (their
// transport_addr covers LAN, Tailscale, and WAN alike),
// then re-dial the last-known-good endpoint of every
// federation peer whose live paths are gone: not
// connected now, no LAN direct entry this tick. Escala-
// tion order is LAN → last-known-good → anchor tree;
// a stale candidate costs one bounded failed dial.
let connected =
crate::fips::service::connected_peer_endpoints().await;
let known = crate::fips::endpoints::record_connected(
&data_dir, &connected,
)
.await;
let wanted: Vec<String> = reg
.all_peers()
.await
.iter()
.filter_map(|p| p.fips_npub.clone())
.collect();
let connected_npubs: Vec<String> =
connected.iter().map(|c| c.npub.clone()).collect();
let fallback = crate::fips::endpoints::fallback_anchors(
&known,
&wanted,
&connected_npubs,
&direct,
);
if !fallback.is_empty() {
tracing::info!(
count = fallback.len(),
"dialing last-known-good endpoints for disconnected federation peers"
);
let _ = crate::fips::anchors::apply(&fallback).await;
}
}
let next = if daemon_restarting && fast_retries < MAX_FAST_RETRIES {
@@ -1253,36 +1217,18 @@ async fn peer_late_bind_loop(
}
};
info!("FIPS peer listener bound {}", addr);
// Serve until shutdown, a persistent accept failure, or a
// fips0 ULA change. The listener must be REBINDABLE: a
// daemon re-key tears fips0 down and brings it back with a
// (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,
active_connections.clone(),
true, // peer listener: apply path filter
shutdown_rx.clone(),
addr,
) => {
if *shutdown_rx.borrow() { 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.
}
}
// Once bound, serve until shutdown fires. accept_loop
// returns on shutdown, which also ends this outer loop.
accept_loop(
handler,
listener,
active_connections,
true, // peer listener: apply path filter
shutdown_rx,
addr,
)
.await;
return;
}
_ = shutdown_rx.changed() => {
if *shutdown_rx.borrow() { return; }
@@ -1334,26 +1280,13 @@ async fn accept_loop(
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
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 {
tokio::select! {
result = listener.accept() => {
let (stream, peer_addr) = match result {
Ok(c) => { consecutive_errors = 0; c }
Ok(c) => c,
Err(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;
}
};
+16
View File
@@ -82,6 +82,22 @@ impl Router {
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.
pub fn verify_openwrt(&self) -> Result<String> {
let release = self
+1 -1
View File
@@ -60,6 +60,6 @@ impl Router {
}
/// Wrap a value in single quotes, escaping any embedded single quotes.
fn shell_quote(s: &str) -> String {
pub(crate) fn shell_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', r"'\''"))
}
+3 -22
View File
@@ -1,12 +1,12 @@
{
"name": "neode-ui",
"version": "1.7.115-alpha",
"version": "1.7.116-alpha",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "neode-ui",
"version": "1.7.115-alpha",
"version": "1.7.116-alpha",
"dependencies": {
"@scure/bip39": "^2.2.0",
"@types/dompurify": "^3.0.5",
@@ -150,7 +150,6 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -1812,7 +1811,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -1836,7 +1834,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3923,7 +3920,6 @@
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/geojson": "*"
}
@@ -3973,7 +3969,6 @@
"integrity": "sha512-MCbrb508JZHqe7bUibmZj/lyojdhLRnfkmyXnkrCM2zVrjTgL89U8UEfInpKTvPeTnxsw2hmyZxnhsdNR6yhwg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"cac": "^6.7.14",
"colorette": "^2.0.20",
@@ -4474,7 +4469,6 @@
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -4960,7 +4954,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -5979,7 +5972,6 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
@@ -8172,7 +8164,6 @@
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"jiti": "bin/jiti.js"
}
@@ -8222,7 +8213,6 @@
"integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"cssstyle": "^4.1.0",
"data-urls": "^5.0.0",
@@ -8325,8 +8315,7 @@
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause",
"peer": true
"license": "BSD-2-Clause"
},
"node_modules/leven": {
"version": "3.1.0",
@@ -9082,7 +9071,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -9744,7 +9732,6 @@
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@@ -11000,7 +10987,6 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -11256,7 +11242,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -11498,7 +11483,6 @@
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -11661,7 +11645,6 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -11675,7 +11658,6 @@
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
@@ -11768,7 +11750,6 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.30.tgz",
"integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.30",
"@vue/compiler-sfc": "3.5.30",
+2 -33
View File
@@ -4,16 +4,6 @@ export interface RPCOptions {
method: string
params?: Record<string, unknown>
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> {
@@ -84,35 +74,18 @@ function getCsrfToken(): string | null {
class RPCClient {
private static _sessionExpiredRedirecting = false
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') {
this.baseUrl = baseUrl
}
async call<T>(options: RPCOptions): Promise<T> {
if (options.dedup) {
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)
const { method, params = {}, timeout = 15000 } = options
const maxRetries = 3
for (let attempt = 0; attempt < maxRetries; attempt++) {
if (external?.aborted) throw new Error('Aborted')
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), timeout)
const onExternalAbort = () => controller.abort()
external?.addEventListener('abort', onExternalAbort, { once: true })
try {
const headers: Record<string, string> = {
@@ -132,7 +105,6 @@ class RPCClient {
})
clearTimeout(timeoutId)
external?.removeEventListener('abort', onExternalAbort)
if (!response.ok) {
// Session expired — debounced redirect to login
@@ -195,11 +167,8 @@ class RPCClient {
return data.result as T
} catch (error) {
clearTimeout(timeoutId)
external?.removeEventListener('abort', onExternalAbort)
if (error instanceof Error) {
if (error.name === 'AbortError') {
// Caller-initiated abort is final — never retried.
if (external?.aborted) throw new Error('Aborted')
const timeoutErr = new Error('Request timeout')
if (attempt < maxRetries - 1) {
const delay = 600 * (attempt + 1)
@@ -377,9 +377,8 @@
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { rpcClient } from '@/api/rpc-client'
import { useCachedResource } from '@/composables/useCachedResource'
import { useTxExplorer } from '@/composables/useTxExplorer'
defineProps<{ compact?: boolean }>()
@@ -463,41 +462,11 @@ const feePresets: { key: FeePreset; label: string; hint?: string; confTarget?: n
{ key: 'custom', label: 'Custom' },
]
// Cached: revisits paint the channel lists instantly and revalidate behind
// them. Open and closed history are separate entries so a closed-history
// failure keeps its last list without touching the main channel view.
interface ChannelsData { channels: Channel[]; total_inbound: number; total_outbound: number }
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,
}))
const loading = ref(true)
const error = ref<string | null>(null)
const channels = ref<Channel[]>([])
const closedChannels = ref<ClosedChannel[]>([])
const summary = ref({ total_inbound: 0, total_outbound: 0 })
// Olympus by ZEUS the LSP node behind the Zeus mobile wallet.
// Channel limits: min 150,000 / max 1,500,000 sats.
@@ -569,10 +538,37 @@ function capacityPercent(amount: number, capacity: number): number {
return Math.round((amount / capacity) * 100)
}
function loadChannels(): Promise<void> {
const main = channelsRes.refresh()
void closedRes.refresh()
return main
async function loadChannels() {
const hadChannels = channels.value.length > 0
loading.value = true
error.value = null
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 {
@@ -651,5 +647,7 @@ async function closeChannel() {
}
}
onMounted(loadChannels)
defineExpose({ channels, loadChannels })
</script>
@@ -1,107 +0,0 @@
// 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),
}
}
@@ -1,131 +0,0 @@
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()
})
})
+12 -28
View File
@@ -8,11 +8,6 @@ export const useCloudStore = defineStore('cloud', () => {
const loading = ref(false)
const error = ref<string | null>(null)
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 parts = currentPath.value.split('/').filter(Boolean)
@@ -41,22 +36,7 @@ export const useCloudStore = defineStore('cloud', () => {
}
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
try {
if (!authenticated.value) {
@@ -67,7 +47,9 @@ export const useCloudStore = defineStore('cloud', () => {
}
}
try {
apply(path, await fileBrowserClient.listDirectory(path))
const result = await fileBrowserClient.listDirectory(path)
items.value = result
currentPath.value = path
} catch {
// Directory may not exist — try to create it, then retry
if (path !== '/') {
@@ -75,20 +57,23 @@ export const useCloudStore = defineStore('cloud', () => {
const parentPath = path.substring(0, path.lastIndexOf('/')) || '/'
const dirName = path.substring(path.lastIndexOf('/') + 1)
await fileBrowserClient.createFolder(parentPath, dirName)
apply(path, await fileBrowserClient.listDirectory(path))
const result = await fileBrowserClient.listDirectory(path)
items.value = result
currentPath.value = path
} catch {
// Fall back to root
apply('/', await fileBrowserClient.listDirectory('/'))
const result = await fileBrowserClient.listDirectory('/')
items.value = result
currentPath.value = '/'
}
} else {
throw new Error('Failed to list root directory')
}
}
} catch (e) {
// Keep showing the cached listing on a failed revalidate.
if (!cached) error.value = e instanceof Error ? e.message : 'Failed to load files'
error.value = e instanceof Error ? e.message : 'Failed to load files'
} finally {
if (seq === navSeq) loading.value = false
loading.value = false
}
}
@@ -127,7 +112,6 @@ export const useCloudStore = defineStore('cloud', () => {
items.value = []
loading.value = false
error.value = null
pathCache.clear()
}
return {
-166
View File
@@ -1,166 +0,0 @@
// 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 }
})
+1 -31
View File
@@ -2,38 +2,9 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { DataModel, PatchOperation } from '../types/api'
import type { DataModel } from '../types/api'
import { wsClient, applyDataPatch } from '../api/websocket'
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', () => {
// State
@@ -137,7 +108,6 @@ export const useSyncStore = defineStore('sync', () => {
try {
if (import.meta.env.DEV) console.log('[Store] Applying patch at revision', update.rev || 'unknown')
data.value = applyDataPatch(data.value, update.patch)
invalidateResourcesForPatch(update.patch)
// Mark as connected once we receive any valid patch
if (!isConnected.value) {
isConnected.value = true
+123 -228
View File
@@ -194,7 +194,7 @@
Open Federation
</RouterLink>
</div>
<div v-else-if="filteredPeerFiles.length === 0 && peerFilesPending === 0" class="glass-card p-8 text-center text-white/40 text-sm">
<div v-else-if="filteredPeerFiles.length === 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.' }}
</div>
<div v-else class="space-y-2">
@@ -216,13 +216,6 @@
<span class="text-[10px] px-2 py-0.5 rounded-full bg-purple-500/15 text-purple-400 shrink-0">{{ f.peerName }}</span>
</button>
</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">
{{ peerFilesErrors }} peer{{ peerFilesErrors === 1 ? '' : 's' }} unreachable showing what answered.
</p>
@@ -308,23 +301,12 @@
<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 }}
</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>
<span class="text-white/30">Peer Node</span>
</div>
</div>
<div
v-if="(peersLoading || peersRefreshing) && peerNodes.length > 0"
v-if="peersLoading && 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"
>
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
@@ -406,8 +388,6 @@ import { computed, ref, watch, onMounted } from 'vue'
import { useRouter, RouterLink } from 'vue-router'
import { useAppStore } from '../stores/app'
import { useCloudStore } from '../stores/cloud'
import { useResourcesStore } from '../stores/resources'
import { useCachedResource } from '../composables/useCachedResource'
import { fileBrowserClient, type FileBrowserItem } from '@/api/filebrowser-client'
import { rpcClient } from '@/api/rpc-client'
import { getFileCategory } from '../composables/useFileType'
@@ -419,19 +399,9 @@ import MediaLightbox from '../components/cloud/MediaLightbox.vue'
const router = useRouter()
const store = useAppStore()
const cloudStore = useCloudStore()
const resources = useResourcesStore()
const audioPlayer = useAudioPlayer()
// 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')
const sectionCounts = ref<Record<string, number>>({})
const countsLoading = ref(false)
// Tabs / categories / search state
type TabId = 'folders' | 'mine' | 'peers' | 'paid'
@@ -454,18 +424,14 @@ const activeTab = ref<TabId>('folders')
// Paid Files tab
interface PaidItem { onion: string; content_id: string; filename: string; mime_type: string; size_bytes: number; paid_sats: number; purchased_at: string }
const paidResource = useCachedResource<PaidItem[]>({
key: 'cloud.paid-items',
fetcher: async (signal) => {
const res = await rpcClient.call<{ items: PaidItem[] }>({ method: 'content.owned-list', signal, dedup: true })
return (res.items || []).slice().reverse()
},
immediate: false, // loaded when the Paid tab is opened
})
const paidItems = computed(() => paidResource.entry.data ?? [])
const paidLoading = computed(() => paidResource.entry.loadState === 'loading')
function loadPaidItems() {
return paidResource.refresh()
const paidItems = ref<PaidItem[]>([])
const paidLoading = ref(false)
async function loadPaidItems() {
paidLoading.value = true
try {
const res = await rpcClient.call<{ items: PaidItem[] }>({ method: 'content.owned-list' })
paidItems.value = (res.items || []).slice().reverse()
} catch { paidItems.value = [] } finally { paidLoading.value = false }
}
async function viewPaidItem(it: PaidItem) {
try {
@@ -507,21 +473,8 @@ interface PeerNode {
trust_level: string
}
// Federation peers cached so the Folders tab's peer cards paint instantly
// 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 peerNodes = ref<PeerNode[]>([])
const peersLoading = ref(true)
const loadError = ref('')
const APP_ALIASES: Record<string, string[]> = {
@@ -626,51 +579,42 @@ function formatSize(bytes: number): string {
}
// My Files (flat list of every own file across the sections)
// Cached: revisiting the tab renders the last walk instantly and re-walks in
// the background only when stale.
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')
const myFiles = ref<FileBrowserItem[]>([])
const myFilesLoading = ref(false)
const myFilesLoaded = ref(false)
/** Depth-limited walk of the section folders; flat file list, capped. */
async function fetchMyFiles(): Promise<FileBrowserItem[]> {
if (!fileBrowserRunning.value) return []
const ok = await cloudStore.init()
if (!ok) return []
const out: FileBrowserItem[] = []
for (const [sectionId, root] of Object.entries(SECTION_PATHS)) {
if (sectionId === 'files') continue // '/' would double-visit the sections
const queue: Array<{ path: string; depth: number }> = [{ path: root, depth: 0 }]
while (queue.length > 0 && out.length < 500) {
const { path, depth } = queue.shift()!
let items: FileBrowserItem[]
try { items = await fileBrowserClient.listDirectory(path) } catch { continue }
for (const item of items) {
const itemPath = item.path || `${path.replace(/\/$/, '')}/${item.name}`
if (item.isDir) {
if (depth < 3) queue.push({ path: itemPath, depth: depth + 1 })
} else {
out.push({ ...item, path: itemPath })
async function loadMyFiles(force = false) {
if (myFilesLoading.value || (myFilesLoaded.value && !force)) return
if (!fileBrowserRunning.value) { myFilesLoaded.value = true; return }
myFilesLoading.value = true
try {
const ok = await cloudStore.init()
if (!ok) return
const out: FileBrowserItem[] = []
for (const [sectionId, root] of Object.entries(SECTION_PATHS)) {
if (sectionId === 'files') continue // '/' would double-visit the sections
const queue: Array<{ path: string; depth: number }> = [{ path: root, depth: 0 }]
while (queue.length > 0 && out.length < 500) {
const { path, depth } = queue.shift()!
let items: FileBrowserItem[]
try { items = await fileBrowserClient.listDirectory(path) } catch { continue }
for (const item of items) {
const itemPath = item.path || `${path.replace(/\/$/, '')}/${item.name}`
if (item.isDir) {
if (depth < 3) queue.push({ path: itemPath, depth: depth + 1 })
} else {
out.push({ ...item, path: itemPath })
}
}
}
}
out.sort((a, b) => a.name.localeCompare(b.name))
myFiles.value = out
myFilesLoaded.value = true
} finally {
myFilesLoading.value = false
}
out.sort((a, b) => a.name.localeCompare(b.name))
return out
}
/** 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(() =>
@@ -724,8 +668,7 @@ function handlePreview(path: string, context: FileBrowserItem[]) {
async function handleDelete(path: string) {
try {
await cloudStore.deleteItem(path)
// Delete confirmed update the cache in place (no rollback needed).
myFilesResource.optimistic((cur) => (cur ?? []).filter(f => f.path !== path))
myFiles.value = myFiles.value.filter(f => f.path !== path)
searchResults.value = searchResults.value.filter(r => r.item?.path !== path)
} catch (e) {
loadError.value = e instanceof Error ? e.message : 'Delete failed'
@@ -743,6 +686,11 @@ interface PeerFileEntry {
peerOnion: string
}
const peerFiles = ref<PeerFileEntry[]>([])
const peerFilesLoading = ref(false)
const peerFilesLoaded = ref(false)
const peerFilesErrors = ref(0)
interface CatalogItem {
id: string
filename: string
@@ -756,96 +704,46 @@ function priceOf(access: CatalogItem['access']): number {
return typeof access === 'object' && access?.paid ? access.paid.price_sats : 0
}
// Per-peer browse results live as individual cached entries so (a) each
// peer's card/rows render the moment THAT peer answers no more blocking on
// the slowest peer via Promise.allSettled and (b) revisits paint from
// cache. The response's `transport` (fips/tor) + measured latency ride
// along, giving every peer a live transport badge.
interface PeerBrowse {
items: CatalogItem[]
transport: string | null
latencyMs: number
}
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',
params: { onion: peer.onion },
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 { 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[] = []
for (const peer of peerNodes.value) {
const e = peerBrowseEntry(peer.onion)
if (!e.data) continue
const peerName = peer.name || peerDisplayName(peer.did)
for (const item of e.data.items) {
merged.push({
key: `${peer.onion}:${item.id}`,
filename: item.filename,
sizeBytes: item.size_bytes,
priceSats: priceOf(item.access),
category: categoryOf(item.mime_type || item.filename),
peerName,
peerOnion: peer.onion,
})
}
}
merged.sort((a, b) => a.filename.localeCompare(b.filename))
return merged
})
/** 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. */
/** Fan out content.browse-peer over every federation node; tolerate stragglers. */
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)))
if (peerFilesLoading.value || (peerFilesLoaded.value && !force)) return
peerFilesLoading.value = true
peerFilesErrors.value = 0
try {
if (peerNodes.value.length === 0) await loadPeers()
const results = await Promise.allSettled(
peerNodes.value.map(async (peer) => {
const res = await rpcClient.call<{ items?: CatalogItem[] }>({
method: 'content.browse-peer',
params: { onion: peer.onion },
timeout: 30000,
})
return { peer, items: res?.items ?? [] }
}),
)
const merged: PeerFileEntry[] = []
for (const r of results) {
if (r.status !== 'fulfilled') { peerFilesErrors.value++; continue }
const { peer, items } = r.value
const peerName = peer.name || peerDisplayName(peer.did)
for (const item of items) {
merged.push({
key: `${peer.onion}:${item.id}`,
filename: item.filename,
sizeBytes: item.size_bytes,
priceSats: priceOf(item.access),
category: categoryOf(item.mime_type || item.filename),
peerName,
peerOnion: peer.onion,
})
}
}
merged.sort((a, b) => a.filename.localeCompare(b.filename))
peerFiles.value = merged
peerFilesLoaded.value = true
} finally {
peerFilesLoading.value = false
}
}
const filteredPeerFiles = computed(() =>
@@ -923,50 +821,47 @@ const searchMineItems = computed(() =>
)
// Existing counts / peers loading
async function fetchCounts(): Promise<Record<string, number>> {
if (!fileBrowserRunning.value) return {}
const ok = await fileBrowserClient.login()
if (!ok) throw new Error('File Browser login failed')
const counts: Record<string, number> = {}
for (const section of contentSections) {
const path = SECTION_PATHS[section.id]
if (!path) continue
try {
counts[section.id] = (await fileBrowserClient.listDirectory(path)).length
} catch {
counts[section.id] = 0
async function loadCounts() {
if (!fileBrowserRunning.value) return
countsLoading.value = true
try {
const ok = await fileBrowserClient.login()
if (!ok) return
for (const section of contentSections) {
const path = SECTION_PATHS[section.id]
if (!path) continue
try {
const items = await fileBrowserClient.listDirectory(path)
sectionCounts.value[section.id] = items.length
} catch {
sectionCounts.value[section.id] = 0
}
}
}
return counts
}
function loadCounts() {
if (countsResource.entry.data === null || countsResource.isStale.value) {
void countsResource.refresh()
} catch (e) {
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 {
countsLoading.value = false
}
}
onMounted(async () => {
onMounted(() => {
loadCounts()
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()
loadPeers()
})
async function loadPeers() {
await peersResource.refresh()
// Surface refresh failures in the banner the cached peer list stays
// visible either way (keep-last-known-value).
const e = peersResource.entry
if (e.error) loadError.value = e.error
const hadPeers = peerNodes.value.length > 0
peersLoading.value = true
try {
const result = await rpcClient.federationListNodes()
peerNodes.value = result?.nodes ?? []
} 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 {
+4 -4
View File
@@ -306,12 +306,12 @@ const backLabel = computed(() => {
return atSectionRoot.value ? 'Back to Cloud' : 'Back to Parent Folder'
})
// 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.
// Initialize native file browser when entering a native-UI section
watch([useNativeUI, section, routeFolderPath], async ([native, sec, path]) => {
if (native && sec) {
if (cloudStore.currentPath !== path) {
cloudStore.reset()
}
const ok = await cloudStore.init()
if (ok) {
await cloudStore.navigate(path)
+31 -31
View File
@@ -199,9 +199,8 @@
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, onMounted } from 'vue'
import { rpcClient } from '@/api/rpc-client'
import { useCachedResource } from '@/composables/useCachedResource'
import BackButton from '@/components/BackButton.vue'
interface Identity {
@@ -229,30 +228,9 @@ interface Credential {
status: string
}
// Cached: revisits paint identities/credentials instantly and revalidate
// behind them (errors keep the last-known lists).
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 identities = ref<Identity[]>([])
const credentials = ref<Credential[]>([])
const loadingCreds = ref(false)
const selectedCredential = ref<Credential | null>(null)
const credCopied = ref(false)
const revoking = ref(false)
@@ -302,10 +280,31 @@ function formatClaims(subject: Record<string, unknown>): string {
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() {
await credentialsRes.refresh()
if (credentialsRes.error.value) {
showToast(`Failed to load credentials: ${credentialsRes.error.value}`, 'error')
loadingCreds.value = true
try {
const result = await rpcClient.call<{ credentials: Credential[] }>({
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
}
}
@@ -405,8 +404,9 @@ async function copyCredentialJson() {
setTimeout(() => { credCopied.value = false }, 2000)
}
// Both resources fetch themselves on first use (skipping the fetch entirely
// when the cached value is fresh).
onMounted(async () => {
await Promise.all([loadIdentities(), loadCredentials()])
})
defineExpose({ credentials, loadCredentials })
</script>
+30 -25
View File
@@ -229,7 +229,6 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { rpcClient } from '@/api/rpc-client'
import { useCachedResource } from '@/composables/useCachedResource'
import { useTransportStore } from '@/stores/transport'
import { useAppStore } from '@/stores/app'
import { useSyncStore } from '@/stores/sync'
@@ -251,15 +250,8 @@ const transportStore = useTransportStore()
const appStore = useAppStore()
const syncStore = useSyncStore()
// Cached: revisits paint the node list instantly; the 5s poll and mutation
// 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 nodes = ref<FederatedNode[]>([])
const loading = ref(true)
const error = ref('')
const selectedNode = ref<FederatedNode | null>(null)
const inviteType = ref<'trusted' | 'observer'>('trusted')
@@ -328,12 +320,7 @@ const mapLinks = computed(() => {
}))
})
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 dwnStatus = ref<DwnStatus | null>(null)
const dwnSyncing = ref(false)
const dwnSyncDotClass = computed(() => {
@@ -513,13 +500,25 @@ function isOnlineCheck(node: FederatedNode): boolean {
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() {
await nodesRes.refresh()
if (nodesRes.error.value) error.value = nodesRes.error.value
else error.value = ''
return loadNodesWithOptions()
}
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') {
@@ -611,8 +610,13 @@ async function deployApp(did: string, appId: string) {
}
}
function loadDwnStatus() {
return dwnStatusRes.refresh()
async function loadDwnStatus() {
try {
const result = await rpcClient.call<DwnStatus>({ method: 'dwn.status' })
dwnStatus.value = result
} catch {
dwnStatus.value = null
}
}
async function triggerDwnSync() {
@@ -677,6 +681,7 @@ async function rotateDid(password: string) {
let autoRefreshTimer: ReturnType<typeof setInterval> | null = null
onMounted(async () => {
loadNodesWithOptions({ showLoader: true })
loadDwnStatus()
loadDiscoveryState()
loadPendingRequests()
@@ -689,7 +694,7 @@ onMounted(async () => {
// Self DID not available
}
autoRefreshTimer = setInterval(() => {
void nodesRes.refresh()
loadNodesWithOptions({ showLoader: false, surfaceErrors: false })
loadPendingRequests()
}, 5000)
})
+73 -58
View File
@@ -218,7 +218,6 @@ import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import { useCachedResource } from '@/composables/useCachedResource'
import { useHomeStatusStore } from '@/stores/homeStatus'
import BackButton from '@/components/BackButton.vue'
import LineChart from '@/components/LineChart.vue'
@@ -292,51 +291,11 @@ const backTarget = computed(() => (cameFromHome.value ? '/dashboard' : '/dashboa
const backLabel = computed(() => (cameFromHome.value ? t('common.back') : 'Web5'))
const homeStatus = useHomeStatusStore()
// Cached: revisits paint the last snapshot/chart/alerts instantly and the 5s
// poll revalidates behind them; errors keep the last-known values.
const currentRes = useCachedResource<MetricSnapshot>({
key: 'monitoring.current',
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 current = ref<MetricSnapshot | null>(null)
const history = ref<MetricSnapshot[]>([])
const containers = ref<ContainerMetrics[]>([])
const alerts = ref<FiredAlert[]>([])
const alertRules = ref<AlertRule[]>([])
const showAlertConfig = ref(false)
const chartWidth = ref(380)
let pollTimer: ReturnType<typeof setInterval> | null = null
@@ -505,10 +464,66 @@ 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) {
try {
await rpcClient.call({ method: 'monitoring.configure-alert', params: { kind, enabled } })
await alertRulesRes.refresh()
await fetchAlertRules()
} catch {
// Non-critical
}
@@ -519,7 +534,7 @@ async function updateThreshold(kind: string, value: string) {
if (isNaN(threshold) || threshold <= 0) return
try {
await rpcClient.call({ method: 'monitoring.configure-alert', params: { kind, threshold } })
await alertRulesRes.refresh()
await fetchAlertRules()
} catch {
// Non-critical
}
@@ -528,7 +543,7 @@ async function updateThreshold(kind: string, value: string) {
async function acknowledgeAlert(id: string) {
try {
await rpcClient.call({ method: 'monitoring.acknowledge-alert', params: { id } })
await alertsRes.refresh()
await fetchAlerts()
} catch {
// Non-critical
}
@@ -541,18 +556,18 @@ function updateChartWidth() {
}
}
onMounted(() => {
onMounted(async () => {
updateChartWidth()
window.addEventListener('resize', updateChartWidth)
// The cached resources fetch themselves on first use; the poll keeps the
// live view fresh (refreshes dedup in the store).
void homeStatus.refreshSystemStats()
pollTimer = setInterval(() => {
void homeStatus.refreshSystemStats()
void currentRes.refresh()
void historyRes.refresh()
void alertsRes.refresh()
await Promise.all([fetchCurrent(), fetchHistory(), fetchAlerts(), fetchAlertRules()])
pollTimer = setInterval(async () => {
try {
await Promise.all([fetchCurrent(), fetchHistory(), fetchAlerts()])
} catch {
// Background poll ignore transient errors
}
}, 5000)
})
+28 -66
View File
@@ -594,11 +594,10 @@
</template>
<script setup lang="ts">
import { ref, computed, reactive, watch, onMounted, onUnmounted } from 'vue'
import { ref, computed, reactive, watch, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import QRCode from 'qrcode'
import { rpcClient } from '@/api/rpc-client'
import { useResourcesStore } from '@/stores/resources'
import { useAudioPlayer } from '@/composables/useAudioPlayer'
import { pipSupported, togglePip } from '@/utils/pip'
import BackButton from '@/components/BackButton.vue'
@@ -626,33 +625,16 @@ interface CatalogItem {
access: string | { paid: { price_sats: number } }
}
const resources = useResourcesStore()
const loading = ref(true)
const currentPeer = ref<PeerNode | null>(null)
const catalogError = ref('')
const catalogItems = ref<CatalogItem[]>([])
const downloading = ref<string | null>(null)
const playing = 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)
// so we can show a FIPS/Tor pill instead of always assuming Tor (B21).
const transport = computed(() => browseEntry().data?.transport ?? null)
const transport = ref<string | null>(null)
const transportPill = computed(() => {
switch (transport.value) {
case 'fips':
@@ -825,62 +807,44 @@ onMounted(async () => {
loadCatalog(),
loadOwned(),
])
} else {
loading.value = false
}
// No peerId peerOnion is empty and `loading` stays false on its own.
})
function loadCatalog(): Promise<void> {
const onion = peerOnion.value
if (!onion) return Promise.resolve()
return resources.refresh<PeerBrowse>(`cloud.peer-browse:${onion}`, async () => {
const t0 = Date.now()
async function loadCatalog() {
const onion = props.peerId || currentPeer.value?.onion
if (!onion) return
const hadItems = catalogItems.value.length > 0
loading.value = true
catalogError.value = ''
try {
const result = await rpcClient.call<{ items?: CatalogItem[]; transport?: string }>({
method: 'content.browse-peer',
params: { onion },
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
}
}
watch(catalogItems, (items) => {
const onion = peerOnion.value
// Load visual previews for image and video items when catalog loads
// Audio files don't need visual thumbnails they show a waveform icon
watch(catalogItems, async (items) => {
const onion = props.peerId || currentPeer.value?.onion
if (!onion) return
for (const item of items) {
const isVisual = item.mime_type.startsWith('image/') || item.mime_type.startsWith('video/')
if (isVisual && !previewUrls[item.id] && !previewQueued.has(item.id)) {
previewQueued.add(item.id)
previewQueue.push(item)
if ((item.mime_type.startsWith('image/') || item.mime_type.startsWith('video/')) && !previewUrls[item.id]) {
loadPreview(onion, item)
}
}
pumpPreviews(onion)
}, { immediate: true })
})
async function loadPreview(onion: string, item: CatalogItem) {
try {
@@ -888,8 +852,6 @@ async function loadPreview(onion: string, item: CatalogItem) {
method: 'content.preview-peer',
params: { onion, content_id: item.id },
timeout: 30000,
maxRetries: 1,
signal: previewAborter.signal,
})
if (result?.data) {
const mime = result.content_type || item.mime_type
+67 -106
View File
@@ -407,7 +407,6 @@
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import DOMPurify from 'dompurify'
import { rpcClient } from '@/api/rpc-client'
import { useCachedResource, type CachedResource } from '@/composables/useCachedResource'
import { useAppStore } from '@/stores/app'
import QuickActionsCard from './server/QuickActionsCard.vue'
import TorServicesCard from './server/TorServicesCard.vue'
@@ -435,51 +434,18 @@ const torStatusColor = computed(() => {
const autoSyncEnabled = ref(true)
const logCount = ref(0)
// Network data a cached aggregate over four RPCs (allSettled: a failing
// one keeps that slice's previous values). Revisits paint instantly.
interface NetworkData {
wifiCount: string; wifiSsid: string | null; torConnected: boolean; forwardCount: string
vpnConnected: boolean; vpnProvider: string; vpnIp: string; wgIp: string; wgPubkey: string
vpnHostname: string; vpnPeers: number
dnsProvider: string; dnsServers: string[]; dnsDoH: boolean
}
const defaultNetworkData = (): NetworkData => ({
wifiCount: 'N/A', wifiSsid: null, torConnected: false, forwardCount: 'N/A',
// Network data
const networkLoading = ref(true)
const networkRefreshing = ref(false)
const networkHasLoaded = ref(false)
const networkData = ref({
wifiCount: 'N/A', wifiSsid: null as string | null, torConnected: false, forwardCount: 'N/A',
vpnConnected: false, vpnProvider: '', vpnIp: '', wgIp: '', wgPubkey: '', vpnHostname: '', vpnPeers: 0,
dnsProvider: 'system', dnsServers: [], dnsDoH: false,
dnsProvider: 'system', dnsServers: [] as string[], 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.
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 fipsSummary = ref<{ installed: boolean; service_active: boolean; key_present: boolean; anchor_connected?: boolean; authenticated_peer_count?: number } | null>(null)
const fipsRowLabel = computed(() => {
const s = fipsSummary.value
if (!s) return '…'
@@ -501,12 +467,32 @@ const fipsRowTextClass = computed(() => {
if (s.anchor_connected === false) return 'text-orange-400'
return 'text-green-400'
})
function loadFipsSummary() {
return fipsSummaryRes.refresh()
async function loadFipsSummary() {
try {
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 */ }
}
function loadNetworkData() {
return networkRes.refresh()
async function loadNetworkData() {
const initialLoad = !networkHasLoaded.value
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
@@ -521,18 +507,13 @@ const sanitizedPeerQrSvg = computed(() =>
)
const peerError = ref('')
const copiedConfig = ref(false)
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 ?? [])
const vpnPeers = ref<{ name: string; ip: string; type?: string; npub?: string }[]>([])
function loadVpnPeers() {
return vpnPeersRes.refresh()
async function loadVpnPeers() {
try {
const res = await rpcClient.call<{ peers: { name: string; ip: string }[] }>({ method: 'vpn.list-peers' })
vpnPeers.value = res.peers || []
} catch { /* no peers */ }
}
async function createPeer() {
@@ -576,7 +557,7 @@ async function removePeer(name: string) {
removingPeer.value = name
try {
await rpcClient.call({ method: 'vpn.remove-peer', params: { name } })
vpnPeersRes.optimistic(cur => (cur ?? []).filter(p => p.name !== name))
vpnPeers.value = vpnPeers.value.filter(p => p.name !== name)
} catch { /* ignore */ }
finally { removingPeer.value = '' }
}
@@ -602,17 +583,10 @@ async function copyPeerConfig() {
interface NetworkInterface { name: string; type: string; state: string; mac: string; ipv4: string[] }
interface WifiNetwork { ssid: string; signal: number; security: string }
const interfacesRes = useCachedResource<NetworkInterface[]>({
key: 'server.interfaces',
immediate: false,
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 interfacesLoading = ref(true)
const interfacesRefreshing = ref(false)
const interfacesHaveLoaded = ref(false)
const allInterfaces = ref<NetworkInterface[]>([])
const physicalInterfaces = computed(() => allInterfaces.value.filter(i => i.type === 'ethernet' || i.type === 'wifi'))
const wifiAvailable = computed(() => allInterfaces.value.some(i => i.type === 'wifi'))
@@ -663,19 +637,19 @@ async function applyDnsConfig(customServers: string) {
const res = await rpcClient.configureDns(params)
// Never trust the response shape: an undefined `servers` used to reach the
// dnsDisplayLabel computed and crash the whole page render on `.length`.
// Write-through to the cached aggregate (the RPC already succeeded).
networkRes.optimistic(cur => ({
...(cur ?? defaultNetworkData()),
dnsProvider: res?.provider ?? provider,
dnsServers: Array.isArray(res?.servers) ? res.servers : (params.servers ?? []),
dnsDoH: !!res?.doh_enabled,
}))
networkData.value.dnsProvider = res?.provider ?? provider
networkData.value.dnsServers = Array.isArray(res?.servers) ? res.servers : (params.servers ?? [])
networkData.value.dnsDoH = !!res?.doh_enabled
showDnsModal.value = false
} catch (e) { dnsError.value = e instanceof Error ? e.message : 'DNS configuration failed.' } finally { dnsApplying.value = false }
}
function loadInterfaces() {
return interfacesRes.refresh()
async function loadInterfaces() {
const initialLoad = !interfacesHaveLoaded.value
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) {
@@ -757,18 +731,9 @@ function formatBytes(bytes: number): string {
}
// Tor Services
const torServicesRes = useCachedResource<{ services: TorServiceInfo[]; tor_running: boolean }>({
key: 'server.tor-services',
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 torServices = ref<TorServiceInfo[]>([])
const torServicesLoading = ref(false)
const torDaemonRunning = ref(false)
const torRestarting = ref(false)
const torRotating = ref<string | false>(false)
const torDeleting = ref<string | false>(false)
@@ -785,8 +750,11 @@ const availableAppsForTor = computed(() => {
.sort((a, b) => a.title.localeCompare(b.title))
})
function loadTorServices() {
return torServicesRes.refresh()
async function loadTorServices() {
const hadServices = torServices.value.length > 0
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) {
@@ -830,18 +798,14 @@ async function createService(name: string, port: number | null) {
onMounted(() => { checkTorStatus(); loadNetworkData(); loadInterfaces(); loadDiskStatus(); loadTorServices(); loadVpnPeers(); loadFipsSummary() })
// Poll VPN status every 15s so IP updates after pairing (write-through to
// the cached aggregate without refetching the other three RPCs)
// Poll VPN status every 15s so IP updates after pairing
const vpnPollInterval = setInterval(async () => {
try {
const vpnRes = await rpcClient.vpnStatus()
networkRes.optimistic(cur => ({
...(cur ?? defaultNetworkData()),
vpnConnected: vpnRes.connected,
vpnProvider: vpnRes.provider ?? '',
vpnIp: (vpnRes.ip_address ?? '').replace(/\/\d+$/, ''),
wgIp: vpnRes.wg_ip ?? '',
}))
networkData.value.vpnConnected = vpnRes.connected
networkData.value.vpnProvider = vpnRes.provider ?? ''
networkData.value.vpnIp = (vpnRes.ip_address ?? '').replace(/\/\d+$/, '')
networkData.value.wgIp = vpnRes.wg_ip ?? ''
} catch { /* ignore */ }
}, 15000)
onUnmounted(() => clearInterval(vpnPollInterval))
@@ -865,11 +829,8 @@ async function restartServices() {
async function checkTorStatus() {
checkingTor.value = true; torStatusLabel.value = 'checking'
try {
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 }
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' }
catch { torStatusLabel.value = 'stopped' } finally { checkingTor.value = false }
}
const logsToast = ref('')
@@ -1,6 +1,5 @@
import { flushPromises, mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { createPinia } from 'pinia'
import Credentials from '../Credentials.vue'
import { rpcClient } from '@/api/rpc-client'
@@ -43,8 +42,6 @@ describe('Credentials', () => {
const wrapper = mount(Credentials, {
global: {
// The cached-resource layer pulls the Pinia resources store in setup.
plugins: [createPinia()],
mocks: {
$router: { push: vi.fn() },
},
@@ -1,6 +1,5 @@
import { flushPromises, mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { createPinia } from 'pinia'
import PeerFiles from '../PeerFiles.vue'
import { rpcClient } from '@/api/rpc-client'
@@ -56,8 +55,6 @@ describe('PeerFiles', () => {
const wrapper = mount(PeerFiles, {
props: { peerId: 'peer.onion' },
global: {
// The shared peer-browse cache lives in the Pinia resources store.
plugins: [createPinia()],
stubs: {
Teleport: true,
},
@@ -1,6 +1,5 @@
import { flushPromises, mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { createPinia } from 'pinia'
import Server from '../Server.vue'
import { rpcClient } from '@/api/rpc-client'
@@ -30,8 +29,6 @@ function deferred<T>() {
function mountServer(options: { renderTorServices?: boolean } = {}) {
return mount(Server, {
global: {
// The cached-resource layer pulls the Pinia resources store in setup.
plugins: [createPinia()],
stubs: {
QuickActionsCard: true,
TorServicesCard: options.renderTorServices ? false : true,
+131 -23
View File
@@ -2,7 +2,6 @@
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { rpcClient } from '@/api/rpc-client'
import { useResourcesStore } from '@/stores/resources'
import BackButton from '@/components/BackButton.vue'
const router = useRouter()
@@ -74,15 +73,8 @@ interface ScannedNetwork {
encryption: string
}
const status = computed(() => statusEntry().data)
// 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 status = ref<RouterStatus | null>(null)
const loading = ref(true)
const error = ref('')
const host = ref('')
const sshUser = ref('root')
@@ -95,6 +87,15 @@ const detecting = ref(false)
const detectError = ref('')
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 provisionError = ref('')
const provisionSuccess = ref(false)
@@ -123,25 +124,25 @@ const dhcpLimit = ref(150)
const masqEnabled = ref(true)
async function load(params?: Record<string, string>) {
loading.value = true
error.value = ''
await resources.refresh<RouterStatus>('server.openwrt-status', () =>
rpcClient.call<RouterStatus>({
try {
status.value = await rpcClient.call<RouterStatus>({
method: 'openwrt.get-status',
params: params ?? {},
timeout: 30000,
dedup: true,
maxRetries: 1,
}))
const err = statusEntry().error
if (err) {
if (err.includes('No router configured')) {
showConnectForm.value = true
} else {
error.value = err
}
} else {
})
showConnectForm.value = false
if (params) connectedParams.value = params
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
if (msg.includes('No router configured')) {
showConnectForm.value = true
} else {
error.value = msg
}
} finally {
loading.value = false
}
}
@@ -156,6 +157,39 @@ 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[] }
async function detectRouter() {
@@ -211,6 +245,33 @@ function disconnectRouter() {
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() {
provisioning.value = true
provisionError.value = ''
@@ -445,6 +506,45 @@ onMounted(() => load())
>
{{ connecting ? 'Connecting…' : 'Connect' }}
</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>
<p v-if="error" class="mt-3 text-xs text-red-400">{{ error }}</p>
</div>
@@ -507,7 +607,15 @@ onMounted(() => load())
<button class="text-xs text-white/40 hover:text-white/70 transition-colors" @click="disconnectRouter">
Switch router
</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>
<p v-if="forgetError" class="mt-2 text-xs text-red-400">{{ forgetError }}</p>
</div>
<!-- WAN / Uplink -->
+75 -34
View File
@@ -93,9 +93,8 @@ let web5AnimationDone = false
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import { useCachedResource } from '@/composables/useCachedResource'
import { safeClipboardWrite } from './utils'
import type { ProfitsData, HwWalletDevice } from './types'
import type { ProfitsData, WalletTransaction, HwWalletDevice } from './types'
import Web5QuickActions from './Web5QuickActions.vue'
// import Web5Wallet from './Web5Wallet.vue' // hidden for now
@@ -137,13 +136,7 @@ function showToast(text: string) {
}
// --- Networking Profits ---
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 profitsBreakdown = ref<ProfitsData | null>(null)
const networkingProfitsDisplay = computed(() => {
if (!profitsBreakdown.value) return '...'
const sats = profitsBreakdown.value.total_sats
@@ -153,6 +146,15 @@ const networkingProfitsDisplay = computed(() => {
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 ---
const storedDid = ref<string | null>(null)
try {
@@ -288,35 +290,64 @@ async function copyDidDocument() {
}
// --- Wallet / LND Balances ---
// 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 walletConnected = ref(false)
const connectingWallet = ref(false)
// Ecash/transaction/balance display lives in the hidden wallet card when it
// returns, add cached resources for wallet.ecash-balance / lnd.gettransactions
// here rather than reviving the old eager loaders.
const lndOnchainBalance = ref(0)
const lndChannelBalance = ref(0)
const walletError = ref('')
const ecashBalance = ref(0)
// Transactions wallet card hidden, but loadTransactions still called for QuickActions walletConnected state
const walletTransactions = ref<WalletTransaction[]>([])
// Hardware wallets
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() {
if (walletConnected.value) {
walletManuallyDisconnected.value = true
walletConnected.value = false
} else {
connectingWallet.value = true
walletManuallyDisconnected.value = false
await lndInfoRes.refresh()
await loadLndBalances()
connectingWallet.value = false
}
}
@@ -330,8 +361,13 @@ async function detectHardwareWallets() {
}
}
// Auto-refresh wallet data every 30s while mounted (B5 will move this to
// WS-push invalidation; the store dedups overlapping refreshes).
// function reloadBalances() { // wallet hidden
// loadLndBalances()
// loadEcashBalance()
// loadTransactions()
// }
// Auto-refresh wallet data every 30s
let walletRefreshInterval: ReturnType<typeof setInterval> | null = null
onMounted(() => {
@@ -356,15 +392,20 @@ onMounted(() => {
// credentialsRef.value?.loadCredentials() // hidden for now
// sharedContentRef.value?.loadContentItems() // hidden for now
// Wallet/profits resources fetch themselves on first use (and skip the
// fetch entirely when the cached value is still fresh).
// Load local state data
loadEcashBalance()
loadNetworkingProfits()
loadLndBalances()
loadTransactions()
detectHardwareWallets()
// Shared content loaded by the component itself via expose
// The SharedContent component manages its own loadContentItems
walletRefreshInterval = setInterval(() => {
void lndInfoRes.refresh()
loadLndBalances()
loadTransactions()
loadEcashBalance()
}, 30000)
})
+278
View File
@@ -0,0 +1,278 @@
#!/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
@@ -0,0 +1,85 @@
#!/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