Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0651e8c615 | ||
|
|
7b69200244 | ||
|
|
2da8e2c698 | ||
|
|
cebbd10127 | ||
|
|
5d05529d11 | ||
|
|
72e480e9f7 | ||
|
|
74d50719ac | ||
|
|
eb74bc3fe4 | ||
|
|
b13751e6d1 |
@@ -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() => {
|
||||
|
||||
@@ -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))?;
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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, ¤t_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": "",
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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, ®istry_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)]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 (F1–F6). 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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"'\''"))
|
||||
}
|
||||
|
||||
Generated
+3
-22
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
Executable
+278
@@ -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}"
|
||||
Executable
+85
@@ -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
|
||||
Reference in New Issue
Block a user