Compare commits
34 Commits
bf24bbc15a
...
83b77796fc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83b77796fc | ||
|
|
a569104620 | ||
|
|
7e84434ff6 | ||
|
|
981a86cc26 | ||
|
|
b943ca5db2 | ||
|
|
cb3d567b7d | ||
|
|
45ac9be965 | ||
|
|
ab6fcef6f3 | ||
|
|
c7cd068e1a | ||
|
|
82cfc8ccba | ||
|
|
3a9d1db763 | ||
|
|
67609eea91 | ||
|
|
9c025b4cea | ||
|
|
ef2991a117 | ||
|
|
9a518db7b8 | ||
|
|
aa9e0f02b7 | ||
|
|
edd03e542d | ||
|
|
774ca28847 | ||
|
|
b602a9cea5 | ||
|
|
4576964be4 | ||
|
|
c481afc7d9 | ||
|
|
921363542c | ||
|
|
82659e9f4e | ||
|
|
47c16971a7 | ||
|
|
b08e4c4268 | ||
|
|
1278caa249 | ||
|
|
8a62ae008c | ||
|
|
9da66da776 | ||
|
|
34b1fdc1a3 | ||
|
|
2943fd0c5e | ||
|
|
486f1a061c | ||
|
|
dd0fac0e15 | ||
|
|
83dbd25c50 | ||
|
|
386d4bfc3f |
28
CHANGELOG.md
@ -1,5 +1,33 @@
|
||||
# Changelog
|
||||
|
||||
## v1.7.98-alpha (2026-06-16)
|
||||
|
||||
- Apps that crash now recover on their own. Multi-part apps like Immich and IndeedHub could have one of their pieces stop and stay stopped until the whole node was rebooted; the node now checks every couple of minutes and restarts any crashed piece automatically (while still leaving apps you deliberately stopped alone).
|
||||
- The on-screen kiosk display can no longer slow the whole node down. On machines without a graphics chip the kiosk browser could spin a CPU core at full tilt, starving everything else (including the wallet, which then timed out); it's now capped and uses lighter rendering on those machines.
|
||||
- If an update download fails, you're taken back to the Download button to retry, instead of being stranded on an Install button for an update that didn't actually finish downloading.
|
||||
- Your node's identity is clearer and always visible: Settings now shows your Node DID on every node (it previously only appeared if your browser had cached it) plus your node's npub, both with copy buttons. There's also a terminal tool to cryptographically prove all your node's keys come from your one seed phrase.
|
||||
- The "all nodes over Tor" group chat sends quickly now — the "sending" spinner clears as soon as the reachable nodes have the message, instead of hanging on a slow or offline node.
|
||||
- Message notifications now have a close button and open the relevant chat when tapped.
|
||||
- The encrypted mesh transport (FIPS) turns itself on automatically after setup — no button to press — and connects to peers more reliably (it retries and keeps connections warm), so node-to-node features use the fast path more often instead of falling back to Tor.
|
||||
- Your chat history with other nodes is saved reliably and now encrypted on disk, so it survives restarts and updates and can't be read from a stolen drive (only clearing chat removes it).
|
||||
- Peer media shows a "connecting" loader before a video or audio file plays, and audio errors are accurate instead of blaming File Browser.
|
||||
- The Fedimint app now displays with its proper styling, and the Connected Nodes screen stays compact — it shows a few nodes and scrolls, you can tap a node to jump to it in Federation, or tap Message to open its chat.
|
||||
- App updates can now arrive on their own without waiting for a full system release, so individual apps can be improved and shipped faster.
|
||||
|
||||
## v1.7.97-alpha (2026-06-16)
|
||||
|
||||
- The Bitcoin sync status on the home screen no longer disappears for a moment when it refreshes. If the node was briefly busy, the panel used to vanish and pop back; it now stays put and simply shows "Updating…" until the next reading arrives, while a genuinely stopped node still correctly shows as not running.
|
||||
- Bitcoin sync progress on the home screen now updates more promptly, so the percentage and block height keep pace with the node instead of lagging behind.
|
||||
- The Lightning wallet "connect your wallet" screen loads its details and QR code again across all nodes, instead of failing to fetch them.
|
||||
- Your list of trusted nodes is now clean: the same node no longer appears several times under different names, and removed nodes stay removed. In chat, a node that previously showed up as two separate contacts now appears just once.
|
||||
- Browsing another node's cloud is smoother: music and video files from a peer now preview and play properly (including seeking partway through), and the connection now shows a small badge telling you whether it's using the fast encrypted mesh or the slower Tor network.
|
||||
- Opening "My Folders" in the cloud now shows a clear, friendly message when the file app isn't running, instead of a confusing error.
|
||||
- The Electrum server app opens on its own once it's ready, instead of sometimes leaving a loading spinner stuck on top of the screen.
|
||||
- The Fedimint app now displays with its proper styling and icons, instead of appearing unstyled with a missing image.
|
||||
- The Mempool app now connects to your Bitcoin node whether the node is Bitcoin Core or Bitcoin Knots, instead of only working with one of them.
|
||||
- Nodes start up cleanly after a reboot. On some boots the node's main service was trying to start before its data drive had finished mounting, so it failed and retried about twenty times over roughly five minutes — showing a wall of "Failed to start" messages — before finally coming up. It now waits for the data drive to be ready first, so it starts on the first try.
|
||||
- The background images throughout the interface now load faster — they've been made significantly smaller with no loss of quality.
|
||||
|
||||
## v1.7.96-alpha (2026-06-15)
|
||||
|
||||
- The screen attached to your node now shows the normal Archipelago interface and your dashboard after you sign in, instead of a separate, stripped-down grid of app icons that could appear in its place. That extra screen has been removed so the attached display matches what you see everywhere else.
|
||||
|
||||
2
core/Cargo.lock
generated
@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.7.96-alpha"
|
||||
version = "1.7.97-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.7.96-alpha"
|
||||
version = "1.7.98-alpha"
|
||||
edition = "2021"
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
authors = ["Archipelago Team"]
|
||||
|
||||
@ -225,8 +225,7 @@ impl ApiHandler {
|
||||
return bad("invalid onion or content id");
|
||||
}
|
||||
|
||||
let fips_npub =
|
||||
crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||
let peer_path = format!("/content/{}", content_id);
|
||||
let mut req = crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &peer_path)
|
||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||
|
||||
@ -55,6 +55,7 @@ impl RpcHandler {
|
||||
"package.restart" => self.handle_package_restart(params).await,
|
||||
"package.uninstall" => self.clone().spawn_package_uninstall(params).await,
|
||||
"package.update" => self.clone().spawn_package_update(params).await,
|
||||
"package.check-updates" => self.handle_package_check_updates(params).await,
|
||||
"package.credentials" => self.handle_package_credentials(params).await,
|
||||
"app.filebrowser-token" => self.handle_filebrowser_token().await,
|
||||
|
||||
|
||||
@ -1184,6 +1184,12 @@ impl RpcHandler {
|
||||
entry.pinned = p;
|
||||
}
|
||||
let saved = entry.clone();
|
||||
let snapshot = contacts.clone();
|
||||
drop(contacts);
|
||||
// Persist (encrypted, atomic) so the customisation survives restarts.
|
||||
if let Err(e) = crate::mesh::save_mesh_contacts(&self.config.data_dir, &snapshot).await {
|
||||
tracing::warn!("failed to persist mesh contacts: {e}");
|
||||
}
|
||||
Ok(serde_json::json!({
|
||||
"saved": true,
|
||||
"pubkey": pubkey,
|
||||
@ -1215,6 +1221,11 @@ impl RpcHandler {
|
||||
let mut contacts = state.contacts.write().await;
|
||||
let entry = contacts.entry(pubkey.clone()).or_default();
|
||||
entry.blocked = blocked;
|
||||
let snapshot = contacts.clone();
|
||||
drop(contacts);
|
||||
if let Err(e) = crate::mesh::save_mesh_contacts(&self.config.data_dir, &snapshot).await {
|
||||
tracing::warn!("failed to persist mesh contacts: {e}");
|
||||
}
|
||||
Ok(serde_json::json!({ "pubkey": pubkey, "blocked": blocked }))
|
||||
}
|
||||
|
||||
|
||||
@ -32,8 +32,11 @@ impl RpcHandler {
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
||||
validate_app_id(package_id)?;
|
||||
|
||||
// Verify an update is actually available
|
||||
let pinned = image_versions::pinned_image_for_app(package_id)
|
||||
// Verify an update is actually available. Prefer the remote app catalog
|
||||
// (decoupled from the binary OTA), falling back to the image-versions.sh
|
||||
// pin when the catalog is absent or doesn't cover this app.
|
||||
let pinned = crate::container::app_catalog::catalog_primary_image(package_id)
|
||||
.or_else(|| image_versions::pinned_image_for_app(package_id))
|
||||
.ok_or_else(|| anyhow::anyhow!("No pinned image found for {}", package_id))?;
|
||||
|
||||
// Note: the `already updating` guard lives in `spawn_package_update`
|
||||
@ -149,6 +152,28 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/// Manual "check for updates": refresh the remote app catalog now. The
|
||||
/// package scanner recomputes each app's `available-update` from the fresh
|
||||
/// catalog on its next cycle and pushes it to the UI. Best-effort — a fetch
|
||||
/// failure leaves the cached catalog in place and reports `refreshed: false`.
|
||||
pub(in crate::api::rpc) async fn handle_package_check_updates(
|
||||
&self,
|
||||
_params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
match crate::container::app_catalog::refresh_catalog(&self.config.data_dir).await {
|
||||
Ok(count) => Ok(serde_json::json!({
|
||||
"status": "ok",
|
||||
"refreshed": true,
|
||||
"catalog_apps": count,
|
||||
})),
|
||||
Err(e) => Ok(serde_json::json!({
|
||||
"status": "ok",
|
||||
"refreshed": false,
|
||||
"error": e.to_string(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/// Core update execution: stop → pull → remove → recreate → verify.
|
||||
async fn execute_update(
|
||||
&self,
|
||||
@ -385,13 +410,24 @@ impl RpcHandler {
|
||||
package_id: &str,
|
||||
pinned_primary: &str,
|
||||
) -> Vec<(String, String)> {
|
||||
let stack_images = image_versions::pinned_images_for_stack(package_id);
|
||||
let mut stack_images = image_versions::pinned_images_for_stack(package_id);
|
||||
if stack_images.is_empty() {
|
||||
// Single container app
|
||||
vec![(package_id.to_string(), pinned_primary.to_string())]
|
||||
} else {
|
||||
stack_images
|
||||
// Single container app — pinned_primary already prefers the catalog.
|
||||
return vec![(package_id.to_string(), pinned_primary.to_string())];
|
||||
}
|
||||
// Stack app: override per-container images with the catalog where it
|
||||
// provides them; components the catalog omits keep the image-versions.sh
|
||||
// pin. This lets a single component (e.g. the IndeeHub frontend) be
|
||||
// bumped without touching the rest of the stack.
|
||||
let catalog_images = crate::container::app_catalog::catalog_stack_images(package_id);
|
||||
if !catalog_images.is_empty() {
|
||||
for (name, image) in stack_images.iter_mut() {
|
||||
if let Some(catalog_image) = catalog_images.get(name) {
|
||||
*image = catalog_image.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
stack_images
|
||||
}
|
||||
|
||||
/// Rollback: restart old containers if they still exist.
|
||||
|
||||
@ -30,14 +30,22 @@ const DOCTOR_SH_PATH: &str = "/home/archipelago/archy/scripts/container-doctor.s
|
||||
const DOCTOR_SERVICE_PATH: &str = "/etc/systemd/system/archipelago-doctor.service";
|
||||
const DOCTOR_TIMER_PATH: &str = "/etc/systemd/system/archipelago-doctor.timer";
|
||||
|
||||
// Kiosk hardening (#36): keep the deployed unit + launcher in sync with the
|
||||
// repo so the CPU/memory cap and the GPU-vs-headless flag selection reach
|
||||
// already-installed nodes via OTA, not just fresh ISOs.
|
||||
const KIOSK_SERVICE: &str = include_str!("../../../image-recipe/configs/archipelago-kiosk.service");
|
||||
const KIOSK_LAUNCHER: &str =
|
||||
include_str!("../../../image-recipe/configs/archipelago-kiosk-launcher.sh");
|
||||
const KIOSK_SERVICE_PATH: &str = "/etc/systemd/system/archipelago-kiosk.service";
|
||||
const KIOSK_LAUNCHER_PATH: &str = "/usr/local/bin/archipelago-kiosk-launcher";
|
||||
|
||||
const NGINX_CONF_PATH: &str = "/etc/nginx/sites-available/archipelago";
|
||||
const NGINX_ENABLED_CONF_PATH: &str = "/etc/nginx/sites-enabled/archipelago";
|
||||
/// Per-app proxy snippet included by the HTTPS (:443) server block. Carries its
|
||||
/// own `/app/fedimint/` location, so it needs the same B13 asset-rewrite heal as
|
||||
/// the main conf — browsers reach fedimint over HTTPS via this snippet. Absent on
|
||||
/// HTTP-only nodes, in which case the bootstrap loop skips it.
|
||||
const NGINX_HTTPS_SNIPPET_PATH: &str =
|
||||
"/etc/nginx/snippets/archipelago-https-app-proxies.conf";
|
||||
const NGINX_HTTPS_SNIPPET_PATH: &str = "/etc/nginx/snippets/archipelago-https-app-proxies.conf";
|
||||
const RUNTIME_ASSETS_DIR: &str = "/opt/archipelago/web-ui/archipelago-runtime";
|
||||
|
||||
/// Inserted into every server block of the nginx config that lacks the
|
||||
@ -517,6 +525,92 @@ async fn write_root_if_needed(path: &str, content: &str) -> Result<bool> {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
const ARCHIPELAGO_SERVICE_PATH: &str = "/etc/systemd/system/archipelago.service";
|
||||
const MOUNT_REQUIRE_LINE: &str = "RequiresMountsFor=/var/lib/archipelago";
|
||||
|
||||
/// B17 self-heal: ensure the installed archipelago.service waits for the data
|
||||
/// volume to mount before it starts. On production nodes `/var/lib/archipelago`
|
||||
/// (the app data dir AND podman's graphroot) is a separate device-mapper volume;
|
||||
/// without a mount dependency the service can start before `var-lib-archipelago.mount`,
|
||||
/// write to the bare mountpoint on rootfs, fail every podman call, exit, and be
|
||||
/// restarted every 5s until the volume mounts (~5 min of "[FAILED] Failed to start"
|
||||
/// on cold boots). Fresh ISOs already ship the directive; this heals already-deployed
|
||||
/// nodes. The change is boot-ordering only — it takes effect on the NEXT reboot, so we
|
||||
/// never restart the running service here. Idempotent; no-op if the unit is absent
|
||||
/// (dev runs) or already patched. Harmless when the data dir is on rootfs (systemd maps
|
||||
/// the requirement to the always-mounted root).
|
||||
pub async fn ensure_archipelago_mount_ordering() {
|
||||
let current = match fs::read_to_string(ARCHIPELAGO_SERVICE_PATH).await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::debug!(
|
||||
"mount-ordering self-heal: {} not readable ({}) — skipping",
|
||||
ARCHIPELAGO_SERVICE_PATH,
|
||||
e
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
if current.contains(MOUNT_REQUIRE_LINE) {
|
||||
return; // already healed
|
||||
}
|
||||
// Insert the directive into the [Unit] section, immediately before [Service].
|
||||
let Some(idx) = current.find("\n[Service]") else {
|
||||
tracing::warn!(
|
||||
"mount-ordering self-heal: no [Service] section in {} — skipping",
|
||||
ARCHIPELAGO_SERVICE_PATH
|
||||
);
|
||||
return;
|
||||
};
|
||||
let mut patched = String::with_capacity(current.len() + MOUNT_REQUIRE_LINE.len() + 96);
|
||||
patched.push_str(¤t[..idx]);
|
||||
patched.push_str("\n# B17: start only after the data volume (+ podman graphroot) is mounted\n");
|
||||
patched.push_str(MOUNT_REQUIRE_LINE);
|
||||
patched.push_str(¤t[idx..]);
|
||||
match write_root_if_needed(ARCHIPELAGO_SERVICE_PATH, &patched).await {
|
||||
Ok(true) => {
|
||||
info!(
|
||||
"B17: added '{}' to archipelago.service (effective next reboot)",
|
||||
MOUNT_REQUIRE_LINE
|
||||
);
|
||||
if let Err(e) = host_sudo(&["systemctl", "daemon-reload"]).await {
|
||||
tracing::warn!("B17 self-heal: daemon-reload failed: {:#}", e);
|
||||
}
|
||||
}
|
||||
Ok(false) => {}
|
||||
Err(e) => tracing::warn!("B17 mount-ordering self-heal failed: {:#}", e),
|
||||
}
|
||||
}
|
||||
|
||||
/// #36 self-heal: keep the kiosk unit + launcher current on already-deployed
|
||||
/// nodes so the CPU/memory cap (a runaway chromium was saturating the node and
|
||||
/// starving the backend) and the GPU-vs-headless flag selection arrive via OTA.
|
||||
/// No-op on nodes without the kiosk installed; only restarts the kiosk if it's
|
||||
/// actually running (so it never re-enables an operator-disabled kiosk).
|
||||
pub async fn ensure_kiosk_hardened() {
|
||||
if fs::metadata(KIOSK_SERVICE_PATH).await.is_err() {
|
||||
return; // kiosk not installed on this node
|
||||
}
|
||||
let svc_changed = write_root_if_needed(KIOSK_SERVICE_PATH, KIOSK_SERVICE)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
let launcher_changed = write_root_if_needed(KIOSK_LAUNCHER_PATH, KIOSK_LAUNCHER)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
if launcher_changed {
|
||||
let _ = host_sudo(&["chmod", "+x", KIOSK_LAUNCHER_PATH]).await;
|
||||
}
|
||||
if svc_changed || launcher_changed {
|
||||
if let Err(e) = host_sudo(&["systemctl", "daemon-reload"]).await {
|
||||
warn!("kiosk hardening: daemon-reload failed: {:#}", e);
|
||||
}
|
||||
// try-restart only restarts a currently-active unit — leaves a stopped/
|
||||
// disabled kiosk alone.
|
||||
let _ = host_sudo(&["systemctl", "try-restart", "archipelago-kiosk.service"]).await;
|
||||
info!("kiosk: applied resource cap + GPU-flag hardening (#36)");
|
||||
}
|
||||
}
|
||||
|
||||
/// Patch the nginx site config to add missing backend proxy blocks. Older ISO
|
||||
/// configs shipped individual per-endpoint `location` blocks, so missing
|
||||
/// endpoints silently fell through to the SPA `index.html` and the frontend
|
||||
@ -615,10 +709,7 @@ async fn patch_nginx_conf(path: &str) -> Result<bool> {
|
||||
// so insert the reroot set after the unique :8175 proxy_pass. Guarded on
|
||||
// the marker so it can never double-apply after Style A already healed.
|
||||
if !patched.contains("'href=\"/' 'href=\"/app/fedimint/'") {
|
||||
patched = patched.replace(
|
||||
NGINX_FEDIMINT_SNIPPET_ANCHOR,
|
||||
NGINX_FEDIMINT_SNIPPET_INSERT,
|
||||
);
|
||||
patched = patched.replace(NGINX_FEDIMINT_SNIPPET_ANCHOR, NGINX_FEDIMINT_SNIPPET_INSERT);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
343
core/archipelago/src/container/app_catalog.rs
Normal file
@ -0,0 +1,343 @@
|
||||
//! Remote app version catalog — DECOUPLES per-app updates from the binary OTA.
|
||||
//!
|
||||
//! Background: `image_versions.rs` reads the pinned image tags from
|
||||
//! `image-versions.sh`, which is deployed *with the archipelago binary*. That
|
||||
//! coupled every app update to a full node release. This module adds a remote
|
||||
//! catalog (`app-catalog.json`) fetched over HTTP from the same origin as the
|
||||
//! OTA manifest, refreshed periodically and on demand. Bumping an app's version
|
||||
//! is then a JSON edit + push — no binary release.
|
||||
//!
|
||||
//! Resolution order (origin-always-wins, matching the DHT design's posture):
|
||||
//! 1. Remote catalog (this module) — the live source of "available update".
|
||||
//! 2. `image-versions.sh` pin — offline/baseline fallback when the catalog is
|
||||
//! missing or doesn't cover the app.
|
||||
//!
|
||||
//! ## Forward-compatibility with the DHT distribution plan
|
||||
//! (`docs/dht-distribution-design.md`)
|
||||
//! This catalog IS the "discovery / authenticity" layer of that plan. The schema
|
||||
//! is deliberately extensible so the later phases bolt on WITHOUT a breaking
|
||||
//! change:
|
||||
//! - `signature` / `signed_by` (top level) — Phase 0 seed-derived release-root
|
||||
//! signature over the canonical JSON. Absent today; verified when present.
|
||||
//! - per-image `digest` / `size` — BLAKE3/SHA-256 content address + length, so
|
||||
//! the iroh swarm can fetch images by hash with the registry as origin.
|
||||
//! Unknown fields are ignored (no `deny_unknown_fields`), so adding fields on the
|
||||
//! publisher side never breaks older nodes.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
use std::time::SystemTime;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
/// Filename for both the published catalog and the on-node cache.
|
||||
pub const APP_CATALOG_FILE: &str = "app-catalog.json";
|
||||
|
||||
/// Cache of the parsed catalog, invalidated when the cache file mtime changes.
|
||||
static CACHE: Mutex<Option<CacheEntry>> = Mutex::new(None);
|
||||
|
||||
struct CacheEntry {
|
||||
mtime: SystemTime,
|
||||
catalog: AppCatalog,
|
||||
}
|
||||
|
||||
/// Top-level catalog document.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct AppCatalog {
|
||||
/// Schema version. 1 = current. Bump only on incompatible changes.
|
||||
#[serde(default)]
|
||||
pub schema: u32,
|
||||
/// Publish date (RFC 3339 or YYYY-MM-DD). Informational.
|
||||
#[serde(default)]
|
||||
pub updated: String,
|
||||
/// app_id -> entry.
|
||||
#[serde(default)]
|
||||
pub apps: HashMap<String, AppCatalogEntry>,
|
||||
/// DHT-plan forward-compat: detached signature over the canonical JSON,
|
||||
/// produced by the seed-derived release-root key. Absent today.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub signature: Option<String>,
|
||||
/// DHT-plan forward-compat: publisher identity (did:key / npub).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub signed_by: Option<String>,
|
||||
}
|
||||
|
||||
/// Per-app catalog entry.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct AppCatalogEntry {
|
||||
/// User-facing version string (drives the "Update available" badge text).
|
||||
pub version: String,
|
||||
/// Primary single-container image reference (`registry/repo:tag`). For stack
|
||||
/// apps this is the primary container's image (the one whose version the
|
||||
/// badge tracks — e.g. the IndeeHub frontend).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub image: Option<String>,
|
||||
/// Stack apps only: container_name -> image reference. Components omitted here
|
||||
/// fall back to the `image-versions.sh` pin during an update.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub images: Option<HashMap<String, String>>,
|
||||
/// DHT-plan forward-compat: content address of the primary image (unused now).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub digest: Option<String>,
|
||||
/// DHT-plan forward-compat: size in bytes of the primary image (unused now).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub size: Option<u64>,
|
||||
/// Optional human-readable changelog lines for this version.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub changelog: Vec<String>,
|
||||
}
|
||||
|
||||
/// Read-side cache file search order. Mirrors `image_versions.rs`: the running
|
||||
/// daemon's data dir first (via env for dev), then the canonical runtime path.
|
||||
fn cache_paths() -> Vec<PathBuf> {
|
||||
let mut paths = Vec::new();
|
||||
if let Ok(dir) = std::env::var("ARCHIPELAGO_DATA_DIR") {
|
||||
paths.push(Path::new(&dir).join(APP_CATALOG_FILE));
|
||||
}
|
||||
paths.push(Path::new("/var/lib/archipelago").join(APP_CATALOG_FILE));
|
||||
paths
|
||||
}
|
||||
|
||||
fn find_cache_file() -> Option<(PathBuf, SystemTime)> {
|
||||
for p in cache_paths() {
|
||||
if let Ok(meta) = p.metadata() {
|
||||
if let Ok(mtime) = meta.modified() {
|
||||
return Some((p, mtime));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Load and cache the on-node catalog. Returns an empty catalog when absent —
|
||||
/// callers then fall back to `image-versions.sh`.
|
||||
fn load_catalog() -> AppCatalog {
|
||||
let (path, mtime) = match find_cache_file() {
|
||||
Some(v) => v,
|
||||
None => return AppCatalog::default(),
|
||||
};
|
||||
|
||||
{
|
||||
let cache = CACHE.lock().unwrap();
|
||||
if let Some(ref entry) = *cache {
|
||||
if entry.mtime == mtime {
|
||||
return entry.catalog.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let content = match std::fs::read_to_string(&path) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
debug!("app-catalog: failed to read {}: {}", path.display(), e);
|
||||
return AppCatalog::default();
|
||||
}
|
||||
};
|
||||
let catalog: AppCatalog = match serde_json::from_str(&content) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
warn!("app-catalog: invalid JSON at {}: {}", path.display(), e);
|
||||
return AppCatalog::default();
|
||||
}
|
||||
};
|
||||
|
||||
{
|
||||
let mut cache = CACHE.lock().unwrap();
|
||||
*cache = Some(CacheEntry {
|
||||
mtime,
|
||||
catalog: catalog.clone(),
|
||||
});
|
||||
}
|
||||
catalog
|
||||
}
|
||||
|
||||
fn entry_for(app_id: &str) -> Option<AppCatalogEntry> {
|
||||
load_catalog().apps.get(app_id).cloned()
|
||||
}
|
||||
|
||||
/// Primary image for an app per the remote catalog, if covered.
|
||||
pub fn catalog_primary_image(app_id: &str) -> Option<String> {
|
||||
entry_for(app_id).and_then(|e| e.image)
|
||||
}
|
||||
|
||||
/// Per-container stack image overrides from the catalog (container_name -> image).
|
||||
pub fn catalog_stack_images(app_id: &str) -> HashMap<String, String> {
|
||||
entry_for(app_id).and_then(|e| e.images).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Image override for the orchestrator's install/upgrade path. Returns the
|
||||
/// catalog's primary image for `app_id` ONLY when it refers to the same
|
||||
/// repository as the manifest's current image — a guard so a catalog typo can
|
||||
/// never redirect an app to an unrelated image. `None` means "use the manifest
|
||||
/// image as-is" (catalog absent, app uncovered, or repo mismatch).
|
||||
pub fn catalog_image_override(app_id: &str, manifest_image: &str) -> Option<String> {
|
||||
let candidate = catalog_primary_image(app_id)?;
|
||||
let same_repo = crate::container::image_versions::image_without_registry_or_tag(&candidate)
|
||||
== crate::container::image_versions::image_without_registry_or_tag(manifest_image);
|
||||
if same_repo {
|
||||
Some(candidate)
|
||||
} else {
|
||||
warn!(
|
||||
"app-catalog: ignoring image for {} — repo mismatch (catalog={}, manifest={})",
|
||||
app_id, candidate, manifest_image
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Decoupled "available update" check for ALL apps.
|
||||
///
|
||||
/// Prefers the remote catalog; when the catalog covers the app, its verdict is
|
||||
/// authoritative (so we never advertise a stale `image-versions.sh` pin over a
|
||||
/// newer catalog, nor vice-versa). Falls back to the deployed pin only when the
|
||||
/// catalog is missing or doesn't cover the app.
|
||||
pub fn available_update_for_app(app_id: &str, running_image: &str) -> Option<String> {
|
||||
if let Some(catalog_image) = catalog_primary_image(app_id) {
|
||||
// Catalog covers this app with a concrete image -> authoritative.
|
||||
return crate::container::image_versions::available_update_for_images(
|
||||
&catalog_image,
|
||||
running_image,
|
||||
);
|
||||
}
|
||||
// Not covered by the catalog -> baseline pin from image-versions.sh.
|
||||
crate::container::image_versions::available_update_for_app(app_id, running_image)
|
||||
}
|
||||
|
||||
/// Derive candidate catalog URLs from the OTA mirror list by swapping the
|
||||
/// manifest filename for the catalog filename. Falls back to the default
|
||||
/// manifest origin when no mirrors are configured.
|
||||
fn catalog_urls_from_mirrors(mirrors: &[crate::update::UpdateMirror]) -> Vec<String> {
|
||||
let mut urls: Vec<String> = mirrors
|
||||
.iter()
|
||||
.filter_map(|m| {
|
||||
// mirror.url ends with ".../releases/manifest.json"
|
||||
if m.url.ends_with("manifest.json") {
|
||||
Some(m.url.replace("manifest.json", APP_CATALOG_FILE))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
urls.dedup();
|
||||
urls
|
||||
}
|
||||
|
||||
/// Fetch the catalog from the first reachable mirror and atomically write it to
|
||||
/// `<data_dir>/app-catalog.json`. Returns the number of apps in the catalog on
|
||||
/// success. Best-effort: a fetch failure leaves the existing cache untouched
|
||||
/// (origin-always-wins; updates simply aren't refreshed this cycle).
|
||||
pub async fn refresh_catalog(data_dir: &Path) -> anyhow::Result<usize> {
|
||||
let mirrors = crate::update::load_mirrors(data_dir)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let urls = catalog_urls_from_mirrors(&mirrors);
|
||||
if urls.is_empty() {
|
||||
debug!("app-catalog: no mirror-derived URLs to fetch from");
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(20))
|
||||
.build()?;
|
||||
|
||||
let mut last_err: Option<anyhow::Error> = None;
|
||||
for url in &urls {
|
||||
match fetch_one(&client, url).await {
|
||||
Ok(catalog) => {
|
||||
let count = catalog.apps.len();
|
||||
write_cache(data_dir, &catalog)?;
|
||||
// Invalidate the in-process cache so the next read re-parses.
|
||||
*CACHE.lock().unwrap() = None;
|
||||
info!("app-catalog: refreshed from {} ({} apps)", url, count);
|
||||
return Ok(count);
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("app-catalog: fetch {} failed: {}", url, e);
|
||||
last_err = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("no catalog mirrors reachable")))
|
||||
}
|
||||
|
||||
async fn fetch_one(client: &reqwest::Client, url: &str) -> anyhow::Result<AppCatalog> {
|
||||
let resp = client.get(url).send().await?;
|
||||
if !resp.status().is_success() {
|
||||
anyhow::bail!("HTTP {}", resp.status());
|
||||
}
|
||||
let body = resp.text().await?;
|
||||
let catalog: AppCatalog = serde_json::from_str(&body)?;
|
||||
// NOTE (DHT Phase 0): when `catalog.signature` is present, verify it against
|
||||
// the seed-derived release-root pubkey here before accepting. Until signing
|
||||
// ships we accept unsigned catalogs (same trust level as today's manifest).
|
||||
Ok(catalog)
|
||||
}
|
||||
|
||||
fn write_cache(data_dir: &Path, catalog: &AppCatalog) -> anyhow::Result<()> {
|
||||
let dest = data_dir.join(APP_CATALOG_FILE);
|
||||
let tmp = data_dir.join(format!("{}.tmp", APP_CATALOG_FILE));
|
||||
let json = serde_json::to_string_pretty(catalog)?;
|
||||
std::fs::write(&tmp, json)?;
|
||||
std::fs::rename(&tmp, &dest)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_and_ignores_unknown_fields() {
|
||||
let json = r#"{
|
||||
"schema": 1,
|
||||
"updated": "2026-06-16",
|
||||
"future_field": "ignored",
|
||||
"signature": "sig123",
|
||||
"signed_by": "did:key:zABC",
|
||||
"apps": {
|
||||
"indeedhub": {
|
||||
"version": "1.0.1",
|
||||
"image": "146.59.87.168:3000/lfg2025/indeedhub:1.0.1",
|
||||
"digest": "blake3:deadbeef",
|
||||
"size": 12345,
|
||||
"another_future_field": true
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
let cat: AppCatalog = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(cat.schema, 1);
|
||||
assert_eq!(cat.signature.as_deref(), Some("sig123"));
|
||||
let e = cat.apps.get("indeedhub").unwrap();
|
||||
assert_eq!(e.version, "1.0.1");
|
||||
assert_eq!(
|
||||
e.image.as_deref(),
|
||||
Some("146.59.87.168:3000/lfg2025/indeedhub:1.0.1")
|
||||
);
|
||||
assert_eq!(e.digest.as_deref(), Some("blake3:deadbeef"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_catalog_when_absent_is_default() {
|
||||
let cat = AppCatalog::default();
|
||||
assert!(cat.apps.is_empty());
|
||||
assert!(cat.signature.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_url_derived_from_mirror() {
|
||||
let mirrors = vec![crate::update::UpdateMirror {
|
||||
url: "http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/manifest.json"
|
||||
.to_string(),
|
||||
label: "Server 1".to_string(),
|
||||
}];
|
||||
let urls = catalog_urls_from_mirrors(&mirrors);
|
||||
assert_eq!(
|
||||
urls,
|
||||
vec![
|
||||
"http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/app-catalog.json"
|
||||
.to_string()
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -172,8 +172,10 @@ impl DockerPackageScanner {
|
||||
// Extract actual version from container image tag
|
||||
let running_version = image_versions::extract_version_from_image(&container.image);
|
||||
|
||||
// Decoupled from the binary OTA: prefer the remote app catalog,
|
||||
// falling back to the image-versions.sh pin when uncovered/offline.
|
||||
let available_update =
|
||||
image_versions::available_update_for_app(&app_id, &container.image);
|
||||
crate::container::app_catalog::available_update_for_app(&app_id, &container.image);
|
||||
|
||||
let package = PackageDataEntry {
|
||||
state: package_state.clone(),
|
||||
|
||||
@ -213,7 +213,7 @@ pub fn available_update_for_app(app_id: &str, running_image: &str) -> Option<Str
|
||||
available_update_for_images(&pinned, running_image)
|
||||
}
|
||||
|
||||
fn available_update_for_images(pinned: &str, running_image: &str) -> Option<String> {
|
||||
pub fn available_update_for_images(pinned: &str, running_image: &str) -> Option<String> {
|
||||
let pinned_version = extract_version_from_image(&pinned);
|
||||
if is_floating_tag(&pinned_version) {
|
||||
return None;
|
||||
@ -255,7 +255,7 @@ fn is_floating_tag(tag: &str) -> bool {
|
||||
matches!(tag, "latest" | "stable" | "release" | "main")
|
||||
}
|
||||
|
||||
fn image_without_registry_or_tag(image: &str) -> &str {
|
||||
pub fn image_without_registry_or_tag(image: &str) -> &str {
|
||||
let without_tag = strip_tag(image);
|
||||
match without_tag.split_once('/') {
|
||||
Some((first, rest))
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
pub mod app_catalog;
|
||||
pub mod bitcoin_ui;
|
||||
pub mod boot_reconciler;
|
||||
pub mod companion;
|
||||
|
||||
@ -1385,7 +1385,29 @@ impl ProdContainerOrchestrator {
|
||||
let mut resolved_manifest = lm.manifest.clone();
|
||||
self.resolve_dynamic_env(&mut resolved_manifest)?;
|
||||
|
||||
let resolved = lm.manifest.app.container.resolve().ok_or_else(|| {
|
||||
// Decouple the app image from the shipped manifest: prefer the remote
|
||||
// app catalog when it covers this app with a same-repo image. This makes
|
||||
// both the pull below and create_container() below use the catalog tag,
|
||||
// so an app update no longer requires a binary/runtime release. Falls
|
||||
// back to the manifest image when the catalog is absent/uncovered.
|
||||
if let Some(current) = resolved_manifest.app.container.image.clone() {
|
||||
if let Some(catalog_image) = crate::container::app_catalog::catalog_image_override(
|
||||
&resolved_manifest.app.id,
|
||||
¤t,
|
||||
) {
|
||||
if catalog_image != current {
|
||||
tracing::info!(
|
||||
app_id = %resolved_manifest.app.id,
|
||||
from = %current,
|
||||
to = %catalog_image,
|
||||
"app-catalog: overriding manifest image"
|
||||
);
|
||||
resolved_manifest.app.container.image = Some(catalog_image);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let resolved = resolved_manifest.app.container.resolve().ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"manifest for {} has invalid container source (neither image nor build)",
|
||||
lm.manifest.app.id
|
||||
|
||||
@ -371,7 +371,11 @@ mod tests {
|
||||
Some("npub1merged"),
|
||||
"merges fips_npub from the dropped duplicate"
|
||||
);
|
||||
assert_eq!(kept.name.as_deref(), Some("Sapien"), "merges name from the dup");
|
||||
assert_eq!(
|
||||
kept.name.as_deref(),
|
||||
Some("Sapien"),
|
||||
"merges name from the dup"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@ -93,17 +93,61 @@ pub async fn peer_base_url(npub: &str) -> Result<String> {
|
||||
Ok(format!("http://[{}]:{}", ip, PEER_PORT))
|
||||
}
|
||||
|
||||
/// Build an HTTP client tuned for FIPS peer-to-peer dialing. No proxy,
|
||||
/// short timeout — fall back to Tor on failure.
|
||||
/// Build an HTTP client tuned for FIPS peer-to-peer dialing. No proxy.
|
||||
/// `connect_timeout` is generous enough to let NAT hole-punching complete on
|
||||
/// the first dial (FIPS is UDP hole-punched; the path often isn't established
|
||||
/// until the first packets flow), so a reachable-but-cold peer isn't abandoned
|
||||
/// to Tor prematurely. Reliability over latency — FIPS is the preferred path.
|
||||
pub fn client() -> reqwest::Client {
|
||||
reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(20))
|
||||
.connect_timeout(Duration::from_secs(5))
|
||||
.connect_timeout(Duration::from_secs(8))
|
||||
.user_agent("archipelago-fips/1")
|
||||
.build()
|
||||
.expect("static reqwest client config")
|
||||
}
|
||||
|
||||
/// Send a FIPS request with ONE retry on a connect/timeout error.
|
||||
///
|
||||
/// The first dial to a peer typically triggers NAT hole-punching and can time
|
||||
/// out before the overlay path is established; a quick retry then lands on the
|
||||
/// now-warm path. Without this, a single cold-path failure drops the call to
|
||||
/// Tor even though the peer is FIPS-reachable — the main reason FIPS "isn't
|
||||
/// robust". Only connect/timeout errors are retried (a real HTTP response,
|
||||
/// including 4xx/5xx, is returned as-is for the caller to interpret).
|
||||
async fn send_with_retry(rb: reqwest::RequestBuilder) -> Result<reqwest::Response, reqwest::Error> {
|
||||
let retry = rb.try_clone();
|
||||
match rb.send().await {
|
||||
Ok(resp) => Ok(resp),
|
||||
Err(e) if (e.is_connect() || e.is_timeout()) && retry.is_some() => {
|
||||
// Brief pause so the hole-punch packets from the first attempt can
|
||||
// traverse before we re-dial onto the warmed path.
|
||||
tokio::time::sleep(Duration::from_millis(600)).await;
|
||||
retry.expect("retry builder present").send().await
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Proactively warm the hole-punched FIPS path to a peer: resolve its overlay
|
||||
/// address and open a short connection to its peer listener. Hole-punched
|
||||
/// paths and NAT mappings go cold after ~30-60s of no traffic, after which the
|
||||
/// next real dial pays the full re-punch cost and often falls back to Tor.
|
||||
/// Keeping the path warm is what makes FIPS the transport that actually gets
|
||||
/// used. Best-effort: any error (peer offline, UDP blocked) is ignored — the
|
||||
/// connection attempt itself is what re-punches and refreshes the path.
|
||||
pub async fn warm_path(npub: &str) {
|
||||
if !is_service_active().await {
|
||||
return;
|
||||
}
|
||||
let Ok(base) = peer_base_url(npub).await else {
|
||||
return;
|
||||
};
|
||||
let c = client();
|
||||
// The response status is irrelevant; establishing the connection warms it.
|
||||
let _ = tokio::time::timeout(Duration::from_secs(8), c.get(&base).send()).await;
|
||||
}
|
||||
|
||||
// ── DNS wire-format helpers ─────────────────────────────────────────────
|
||||
|
||||
fn encode_query(id: u16, npub: &str) -> Result<Vec<u8>> {
|
||||
@ -374,10 +418,14 @@ impl<'a> PeerRequest<'a> {
|
||||
for (k, v) in &self.headers {
|
||||
rb = rb.header(*k, v);
|
||||
}
|
||||
match rb.send().await {
|
||||
match send_with_retry(rb).await {
|
||||
Ok(r) => Ok(Some(r)),
|
||||
Err(e) => {
|
||||
tracing::debug!("FIPS POST {} failed: {}, falling back to Tor", url, e);
|
||||
tracing::debug!(
|
||||
"FIPS POST {} failed after retry: {}, falling back to Tor",
|
||||
url,
|
||||
e
|
||||
);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
@ -403,10 +451,14 @@ impl<'a> PeerRequest<'a> {
|
||||
for (k, v) in &self.headers {
|
||||
rb = rb.header(*k, v);
|
||||
}
|
||||
match rb.send().await {
|
||||
match send_with_retry(rb).await {
|
||||
Ok(r) => Ok(Some(r)),
|
||||
Err(e) => {
|
||||
tracing::debug!("FIPS GET {} failed: {}, falling back to Tor", url, e);
|
||||
tracing::debug!(
|
||||
"FIPS GET {} failed after retry: {}, falling back to Tor",
|
||||
url,
|
||||
e
|
||||
);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
@ -33,6 +33,63 @@ pub mod service;
|
||||
pub mod update;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Auto-activate FIPS with no user interaction. Once seed onboarding has
|
||||
/// materialised the fips key, install the daemon config + start the service if
|
||||
/// it isn't already up. Idempotent and best-effort: FIPS is the preferred
|
||||
/// transport and should come up on its own — the UI "Activate" button is now a
|
||||
/// manual fallback, not a requirement. No-op pre-onboarding (no key yet) or
|
||||
/// when the service is already active.
|
||||
pub async fn ensure_activated(data_dir: &std::path::Path) {
|
||||
let identity_dir = identity_dir_from(data_dir);
|
||||
if !identity_dir.join("fips_key").exists() {
|
||||
return; // pre-onboarding: nothing to activate yet
|
||||
}
|
||||
if dial::is_service_active().await {
|
||||
return; // already up
|
||||
}
|
||||
tracing::info!("FIPS inactive — auto-activating (no user interaction needed)");
|
||||
if let Err(e) = config::install(&identity_dir).await {
|
||||
tracing::warn!("FIPS auto-activate: config install failed: {:#}", e);
|
||||
return;
|
||||
}
|
||||
if let Err(e) = service::activate(SERVICE_UNIT).await {
|
||||
tracing::warn!("FIPS auto-activate: service activate failed: {:#}", e);
|
||||
return;
|
||||
}
|
||||
tracing::info!("FIPS auto-activated");
|
||||
}
|
||||
|
||||
/// Spawn the FIPS supervisor: every 45s it (1) auto-activates FIPS if onboarding
|
||||
/// is done but the service is down — so it comes up with zero user interaction,
|
||||
/// and (2) keeps hole-punched paths to known federation peers warm, so on-demand
|
||||
/// dials land on FIPS instead of falling back to Tor. Warms peers concurrently
|
||||
/// so one slow/offline peer doesn't delay the rest.
|
||||
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(45));
|
||||
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 {
|
||||
continue;
|
||||
}
|
||||
let nodes = crate::federation::load_nodes(&data_dir)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let mut handles = Vec::new();
|
||||
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;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Systemd unit name supervised by archipelago.
|
||||
|
||||
@ -64,6 +64,7 @@ mod server;
|
||||
mod session;
|
||||
mod settings;
|
||||
mod state;
|
||||
mod storage_crypto;
|
||||
mod streaming;
|
||||
mod totp;
|
||||
mod transport;
|
||||
@ -271,6 +272,15 @@ async fn main() -> Result<()> {
|
||||
// delays server readiness; best-effort, warnings only.
|
||||
tokio::spawn(bootstrap::ensure_doctor_installed());
|
||||
|
||||
// B17: heal already-deployed nodes whose archipelago.service lacks a mount
|
||||
// dependency on the data volume, so cold boots stop flapping. Boot-ordering
|
||||
// only — effective next reboot; never restarts the running service.
|
||||
tokio::spawn(bootstrap::ensure_archipelago_mount_ordering());
|
||||
|
||||
// #36: keep the kiosk unit + launcher hardened (CPU/mem cap + GPU-vs-headless
|
||||
// flags) on already-deployed nodes via OTA; no-op if the kiosk isn't installed.
|
||||
tokio::spawn(bootstrap::ensure_kiosk_hardened());
|
||||
|
||||
// Spawn periodic container snapshot (for crash recovery)
|
||||
crash_recovery::spawn_snapshot_task(config.data_dir.clone());
|
||||
|
||||
@ -291,6 +301,31 @@ async fn main() -> Result<()> {
|
||||
});
|
||||
}
|
||||
|
||||
// Periodically restart crashed multi-container stack members (immich,
|
||||
// indeedhub, …) at RUNTIME, not just at boot. The health monitor skips them
|
||||
// as "orphans" because the sub-container app_ids (e.g. immich_server) aren't
|
||||
// in package_data, so without this a crashed immich_server / indeedhub-api
|
||||
// never comes back until the next reboot (#16/#17). Reuses the boot
|
||||
// recovery, which cheaply skips already-running containers and respects the
|
||||
// user-stopped list, so this only acts on genuinely-down stack members.
|
||||
{
|
||||
let data_dir = config.data_dir.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut tick = tokio::time::interval(Duration::from_secs(120));
|
||||
tick.tick().await; // consume the immediate tick; boot recovery covers t0
|
||||
loop {
|
||||
tick.tick().await;
|
||||
let report = crash_recovery::start_stopped_stack_containers(&data_dir).await;
|
||||
if report.recovered > 0 {
|
||||
info!(
|
||||
"🔄 Stack supervisor: restarted {} crashed stack member(s) (failed: {:?})",
|
||||
report.recovered, report.failed
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Spawn disk space monitor (warns at 85%, auto-cleans at 90%)
|
||||
disk_monitor::spawn_disk_monitor(config.data_dir.clone());
|
||||
|
||||
@ -306,6 +341,11 @@ async fn main() -> Result<()> {
|
||||
electrs_status::spawn_status_cache();
|
||||
bitcoin_status::spawn_status_cache();
|
||||
|
||||
// FIPS supervisor: auto-activate FIPS after onboarding (no Activate button
|
||||
// needed) and keep hole-punched paths to federation peers warm so peer dials
|
||||
// land on FIPS (the preferred transport) instead of falling back to Tor.
|
||||
fips::spawn_fips_supervisor(config.data_dir.clone());
|
||||
|
||||
let startup_ms = startup_start.elapsed().as_millis();
|
||||
info!(
|
||||
"Server listening on http://{} (startup: {}ms)",
|
||||
|
||||
@ -37,6 +37,7 @@ use tracing::{error, info, warn};
|
||||
|
||||
const MESH_CONFIG_FILE: &str = "mesh-config.json";
|
||||
const MESH_IGNORED_RADIO_FILE: &str = "mesh-ignored-radio-contacts.json";
|
||||
const MESH_CONTACTS_FILE: &str = "mesh-contacts.json";
|
||||
|
||||
/// Derive a stable synthetic `contact_id` for a federation peer from its
|
||||
/// archipelago ed25519 pubkey. Mesh LoRa contacts use meshcore firmware's
|
||||
@ -210,6 +211,66 @@ pub async fn save_ignored_radio_contacts(data_dir: &Path, pubkeys: &[String]) ->
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load persisted mesh contact customisations (alias / notes / pinned / blocked),
|
||||
/// decrypting at rest with the node key and migrating any legacy plaintext file.
|
||||
/// Returns an empty map on any error so a read failure never loses live state.
|
||||
pub async fn load_mesh_contacts(
|
||||
data_dir: &Path,
|
||||
) -> std::collections::HashMap<String, listener::ContactEntry> {
|
||||
let path = data_dir.join(MESH_CONTACTS_FILE);
|
||||
let Ok(raw) = fs::read(&path).await else {
|
||||
return std::collections::HashMap::new();
|
||||
};
|
||||
let bytes = if crate::storage_crypto::is_plaintext_json(&raw) {
|
||||
raw
|
||||
} else {
|
||||
match crate::storage_crypto::derive_key(
|
||||
data_dir,
|
||||
crate::storage_crypto::DOMAIN_MESH_CONTACTS,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(k) => match crate::storage_crypto::open(&raw, &k) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
warn!("mesh contacts: decrypt failed ({e}); keeping in-memory state");
|
||||
return std::collections::HashMap::new();
|
||||
}
|
||||
},
|
||||
Err(_) => return std::collections::HashMap::new(),
|
||||
}
|
||||
};
|
||||
serde_json::from_slice(&bytes).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Persist mesh contact customisations, encrypted at rest with the node key and
|
||||
/// written atomically (temp + rename) so a crash mid-write can't corrupt them.
|
||||
pub async fn save_mesh_contacts(
|
||||
data_dir: &Path,
|
||||
contacts: &std::collections::HashMap<String, listener::ContactEntry>,
|
||||
) -> Result<()> {
|
||||
fs::create_dir_all(data_dir).await.ok();
|
||||
let content = serde_json::to_vec(contacts).context("Failed to serialize mesh contacts")?;
|
||||
let bytes = match crate::storage_crypto::derive_key(
|
||||
data_dir,
|
||||
crate::storage_crypto::DOMAIN_MESH_CONTACTS,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(k) => crate::storage_crypto::seal(&content, &k).unwrap_or(content),
|
||||
Err(_) => content, // no key yet (pre-onboarding) → plaintext rather than no-write
|
||||
};
|
||||
let path = data_dir.join(MESH_CONTACTS_FILE);
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
fs::write(&tmp, &bytes)
|
||||
.await
|
||||
.context("Failed to write mesh contacts tmp")?;
|
||||
fs::rename(&tmp, &path)
|
||||
.await
|
||||
.context("Failed to rename mesh contacts")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Detect serial devices that could be mesh radios.
|
||||
/// Checks both Meshcore (via probe) and legacy Meshtastic paths.
|
||||
pub async fn detect_devices() -> Vec<String> {
|
||||
@ -304,6 +365,18 @@ impl MeshService {
|
||||
}
|
||||
}
|
||||
|
||||
// Restore persisted contact customisations (alias/notes/pinned/blocked),
|
||||
// decrypted with the node key, so they survive restarts.
|
||||
{
|
||||
let saved = load_mesh_contacts(data_dir).await;
|
||||
if !saved.is_empty() {
|
||||
let mut contacts = state.contacts.write().await;
|
||||
for (pk, entry) in saved {
|
||||
contacts.insert(pk, entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
state,
|
||||
config,
|
||||
|
||||
@ -43,18 +43,68 @@ fn data_path() -> &'static Mutex<Option<PathBuf>> {
|
||||
PATH.get_or_init(|| Mutex::new(None))
|
||||
}
|
||||
|
||||
/// At-rest encryption key for messages.json, derived from the node identity in
|
||||
/// `init()`. `None` only if the node key is unreadable (pre-onboarding) — in
|
||||
/// which case we persist plaintext rather than lose messages.
|
||||
fn enc_key() -> &'static Mutex<Option<[u8; 32]>> {
|
||||
static KEY: OnceLock<Mutex<Option<[u8; 32]>>> = OnceLock::new();
|
||||
KEY.get_or_init(|| Mutex::new(None))
|
||||
}
|
||||
|
||||
/// Initialize message store — load from disk. Call once at startup.
|
||||
pub async fn init(data_dir: &Path) {
|
||||
let path = data_dir.join("messages.json");
|
||||
*data_path().lock().unwrap_or_else(|e| e.into_inner()) = Some(path.clone());
|
||||
|
||||
if let Ok(content) = tokio::fs::read_to_string(&path).await {
|
||||
if let Ok(loaded) = serde_json::from_str::<MessageStore>(&content) {
|
||||
// Derive + cache the at-rest encryption key (bound to this node's identity).
|
||||
match crate::storage_crypto::derive_key(data_dir, crate::storage_crypto::DOMAIN_MESSAGES).await
|
||||
{
|
||||
Ok(k) => *enc_key().lock().unwrap_or_else(|e| e.into_inner()) = Some(k),
|
||||
Err(e) => tracing::warn!(
|
||||
"message store: encryption key unavailable ({e}); will persist plaintext"
|
||||
),
|
||||
}
|
||||
|
||||
let Ok(raw) = tokio::fs::read(&path).await else {
|
||||
return; // no file yet (new node)
|
||||
};
|
||||
// Decrypt the on-disk blob, transparently migrating a legacy plaintext file.
|
||||
let mut was_plaintext = false;
|
||||
let bytes = if crate::storage_crypto::is_plaintext_json(&raw) {
|
||||
was_plaintext = true;
|
||||
Some(raw)
|
||||
} else {
|
||||
let key = *enc_key().lock().unwrap_or_else(|e| e.into_inner());
|
||||
match key {
|
||||
Some(k) => match crate::storage_crypto::open(&raw, &k) {
|
||||
Ok(p) => Some(p),
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
"message store: decrypt failed ({e}); NOT overwriting on-disk data"
|
||||
);
|
||||
None
|
||||
}
|
||||
},
|
||||
None => None,
|
||||
}
|
||||
};
|
||||
if let Some(bytes) = bytes {
|
||||
if let Ok(loaded) = serde_json::from_slice::<MessageStore>(&bytes) {
|
||||
let mut guard = store().lock().unwrap_or_else(|e| e.into_inner());
|
||||
*guard = loaded;
|
||||
tracing::info!("Loaded {} messages from disk", guard.messages.len());
|
||||
}
|
||||
}
|
||||
// Eagerly re-write a legacy plaintext file as encrypted on first boot.
|
||||
if was_plaintext
|
||||
&& enc_key()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.is_some()
|
||||
{
|
||||
persist();
|
||||
tracing::info!("message store: migrated plaintext messages.json to encrypted at rest");
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist current messages to disk.
|
||||
@ -63,13 +113,28 @@ pub async fn init(data_dir: &Path) {
|
||||
fn persist() {
|
||||
let guard = store().lock().unwrap_or_else(|e| e.into_inner());
|
||||
let path_guard = data_path().lock().unwrap_or_else(|e| e.into_inner());
|
||||
let key = *enc_key().lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(ref path) = *path_guard {
|
||||
if let Ok(content) = serde_json::to_string(&*guard) {
|
||||
if let Ok(content) = serde_json::to_vec(&*guard) {
|
||||
let path = path.clone();
|
||||
drop(path_guard);
|
||||
drop(guard);
|
||||
tokio::task::spawn(async move {
|
||||
let _ = tokio::fs::write(&path, content).await;
|
||||
// Encrypt at rest when the node key is available; fall back to
|
||||
// plaintext rather than drop the write if it somehow isn't.
|
||||
let bytes = match key {
|
||||
Some(k) => crate::storage_crypto::seal(&content, &k).unwrap_or(content),
|
||||
None => content,
|
||||
};
|
||||
// Atomic write: stage to a temp file then rename, so a crash or
|
||||
// reboot mid-write can never truncate/corrupt the real history
|
||||
// (rename is atomic on the same filesystem).
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
if tokio::fs::write(&tmp, &bytes).await.is_ok() {
|
||||
let _ = tokio::fs::rename(&tmp, &path).await;
|
||||
} else {
|
||||
let _ = tokio::fs::remove_file(&tmp).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -543,4 +543,21 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_node_key_known_answer_vs_python_verifier() {
|
||||
// Cross-checks scripts/verify-seed-derivation.py: same mnemonic must
|
||||
// produce the same node_key bytes in Rust and in the Python verifier.
|
||||
let (_, seed) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap();
|
||||
let key = derive_node_ed25519(&seed).unwrap();
|
||||
assert_eq!(
|
||||
hex::encode(key.to_bytes()),
|
||||
"3b4f4a1450450260ae360adb9c33ea5eb86356fa14454ca0067dd4b51ea8be87"
|
||||
);
|
||||
let nostr = derive_node_nostr_key(&seed).unwrap();
|
||||
assert_eq!(
|
||||
hex::encode(nostr.secret_key().to_secret_bytes()),
|
||||
"3a94fb32efab2a5025401d53fd7d82b41323a5c06ad14ce528ebe3a813d88831"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
108
core/archipelago/src/storage_crypto.rs
Normal file
@ -0,0 +1,108 @@
|
||||
//! At-rest encryption for local state stores (chat messages, mesh contacts).
|
||||
//!
|
||||
//! Best-practice envelope, matching `credentials::store`:
|
||||
//! - **Key**: SHA-256(domain-separator ‖ node identity key). The node key is
|
||||
//! seed-derived and never leaves the device, so each store is bound to this
|
||||
//! node's identity — a stolen disk image is unreadable without it, and the
|
||||
//! per-domain separator means one store's key can't open another.
|
||||
//! - **Cipher**: ChaCha20-Poly1305 AEAD with a fresh random 96-bit nonce per
|
||||
//! write (`nonce ‖ ciphertext` on disk). The Poly1305 tag makes it
|
||||
//! tamper-evident — any on-disk modification fails to open.
|
||||
//! - **Migration**: legacy plaintext JSON is detected and read transparently,
|
||||
//! then re-written encrypted on the next save. No data is stranded.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use std::path::Path;
|
||||
|
||||
/// Domain separators — one per store so keys never overlap.
|
||||
pub const DOMAIN_MESSAGES: &[u8] = b"archipelago-message-store-v1";
|
||||
pub const DOMAIN_MESH_CONTACTS: &[u8] = b"archipelago-mesh-contacts-v1";
|
||||
|
||||
/// Derive a 32-byte key bound to this node's identity for a given store domain.
|
||||
pub async fn derive_key(data_dir: &Path, domain: &[u8]) -> Result<[u8; 32]> {
|
||||
let node_key_path = data_dir.join("identity").join("node_key");
|
||||
let key_bytes = tokio::fs::read(&node_key_path)
|
||||
.await
|
||||
.context("reading node key for at-rest encryption")?;
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(domain);
|
||||
hasher.update(&key_bytes);
|
||||
let mut key = [0u8; 32];
|
||||
key.copy_from_slice(&hasher.finalize());
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
/// Encrypt `plaintext`, returning `nonce ‖ ciphertext`.
|
||||
pub fn seal(plaintext: &[u8], key: &[u8; 32]) -> Result<Vec<u8>> {
|
||||
use chacha20poly1305::aead::{Aead, KeyInit};
|
||||
let nonce_bytes: [u8; 12] = rand::random();
|
||||
let cipher = chacha20poly1305::ChaCha20Poly1305::new_from_slice(key)
|
||||
.map_err(|e| anyhow::anyhow!("cipher init: {e}"))?;
|
||||
let ct = cipher
|
||||
.encrypt(
|
||||
chacha20poly1305::aead::generic_array::GenericArray::from_slice(&nonce_bytes),
|
||||
plaintext,
|
||||
)
|
||||
.map_err(|e| anyhow::anyhow!("encryption failed: {e}"))?;
|
||||
let mut out = Vec::with_capacity(12 + ct.len());
|
||||
out.extend_from_slice(&nonce_bytes);
|
||||
out.extend_from_slice(&ct);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Decrypt `nonce ‖ ciphertext`.
|
||||
pub fn open(data: &[u8], key: &[u8; 32]) -> Result<Vec<u8>> {
|
||||
use chacha20poly1305::aead::{Aead, KeyInit};
|
||||
if data.len() < 12 {
|
||||
anyhow::bail!("ciphertext too short");
|
||||
}
|
||||
let (nonce, ct) = data.split_at(12);
|
||||
let cipher = chacha20poly1305::ChaCha20Poly1305::new_from_slice(key)
|
||||
.map_err(|e| anyhow::anyhow!("cipher init: {e}"))?;
|
||||
cipher
|
||||
.decrypt(
|
||||
chacha20poly1305::aead::generic_array::GenericArray::from_slice(nonce),
|
||||
ct,
|
||||
)
|
||||
.map_err(|_| anyhow::anyhow!("decryption failed — key mismatch or corruption"))
|
||||
}
|
||||
|
||||
/// Heuristic: does this look like legacy plaintext JSON (starts with `{`/`[`)?
|
||||
/// Encrypted blobs start with a random nonce byte, so a `{`/`[` first byte is a
|
||||
/// reliable migration signal.
|
||||
pub fn is_plaintext_json(raw: &[u8]) -> bool {
|
||||
matches!(raw.first(), Some(b'{') | Some(b'['))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn seal_open_round_trips() {
|
||||
let key = [7u8; 32];
|
||||
let msg = br#"{"messages":[{"m":"hi"}]}"#;
|
||||
let sealed = seal(msg, &key).unwrap();
|
||||
// Encrypted output must NOT be readable plaintext.
|
||||
assert!(!is_plaintext_json(&sealed));
|
||||
assert_ne!(&sealed[12..], &msg[..]);
|
||||
assert_eq!(open(&sealed, &key).unwrap(), msg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_fails_on_wrong_key_or_tamper() {
|
||||
let sealed = seal(b"secret", &[1u8; 32]).unwrap();
|
||||
assert!(open(&sealed, &[2u8; 32]).is_err());
|
||||
let mut tampered = sealed.clone();
|
||||
*tampered.last_mut().unwrap() ^= 0x01;
|
||||
assert!(open(&tampered, &[1u8; 32]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_plaintext_vs_ciphertext() {
|
||||
assert!(is_plaintext_json(b"{\"a\":1}"));
|
||||
assert!(is_plaintext_json(b"[]"));
|
||||
assert!(!is_plaintext_json(&seal(b"x", &[3u8; 32]).unwrap()));
|
||||
}
|
||||
}
|
||||
@ -538,12 +538,19 @@ pub async fn load_state(data_dir: &Path) -> Result<UpdateState> {
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
/// Marker written only after EVERY component has downloaded and verified.
|
||||
/// Distinguishes a complete, install-ready staging from the partial files a
|
||||
/// resumable-but-failed download leaves behind.
|
||||
const STAGED_COMPLETE_MARKER: &str = ".download-complete";
|
||||
|
||||
async fn has_staged_update(data_dir: &Path) -> bool {
|
||||
let staging_dir = data_dir.join("update-staging");
|
||||
let Ok(mut entries) = fs::read_dir(&staging_dir).await else {
|
||||
return false;
|
||||
};
|
||||
matches!(entries.next_entry().await, Ok(Some(_)))
|
||||
// A *complete* staged update carries the marker. A partial/failed download
|
||||
// leaves component files (kept for resume) but no marker, so it reads as
|
||||
// "not staged" — the state self-heal then clears update_in_progress and the
|
||||
// UI returns to Download instead of stranding the user on Install.
|
||||
fs::metadata(data_dir.join("update-staging").join(STAGED_COMPLETE_MARKER))
|
||||
.await
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
pub async fn save_state(data_dir: &Path, state: &UpdateState) -> Result<()> {
|
||||
@ -801,7 +808,10 @@ pub async fn download_update(data_dir: &Path) -> Result<DownloadProgress> {
|
||||
);
|
||||
}
|
||||
|
||||
// Mark update as downloaded
|
||||
// Mark update as downloaded. Write the completion marker FIRST so a crash
|
||||
// between the two can't leave update_in_progress=true without the marker
|
||||
// (which the self-heal would then clear, harmlessly forcing a re-download).
|
||||
let _ = fs::write(staging_dir.join(STAGED_COMPLETE_MARKER), b"1").await;
|
||||
let mut state = load_state(data_dir).await?;
|
||||
state.update_in_progress = true;
|
||||
save_state(data_dir, &state).await?;
|
||||
@ -1507,9 +1517,26 @@ pub async fn run_update_scheduler(data_dir: std::path::PathBuf) {
|
||||
// Check every hour; act based on schedule setting
|
||||
let mut tick = interval(Duration::from_secs(3600));
|
||||
|
||||
// Refresh the app catalog once at startup so per-app "update available"
|
||||
// badges appear without waiting for the first hourly tick.
|
||||
if let Err(e) = crate::container::app_catalog::refresh_catalog(&data_dir).await {
|
||||
debug!(
|
||||
"Update scheduler: initial app-catalog refresh failed: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
|
||||
loop {
|
||||
tick.tick().await;
|
||||
|
||||
// App-catalog refresh is INDEPENDENT of the OTA schedule below: it only
|
||||
// populates per-app update availability (the "Update" button still has
|
||||
// to be clicked — nothing auto-applies). Best-effort; on failure the
|
||||
// previously cached catalog stays in place (origin-always-wins).
|
||||
if let Err(e) = crate::container::app_catalog::refresh_catalog(&data_dir).await {
|
||||
debug!("Update scheduler: app-catalog refresh failed: {}", e);
|
||||
}
|
||||
|
||||
let state = match load_state(&data_dir).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
@ -1855,6 +1882,12 @@ mod tests {
|
||||
tokio::fs::write(staging.join("archipelago"), b"staged")
|
||||
.await
|
||||
.unwrap();
|
||||
// A *complete* staged update carries the marker; without it the state
|
||||
// self-heal correctly treats this as a partial download and clears
|
||||
// update_in_progress (see has_staged_update / #26).
|
||||
tokio::fs::write(staging.join(STAGED_COMPLETE_MARKER), b"1")
|
||||
.await
|
||||
.unwrap();
|
||||
let state = UpdateState {
|
||||
current_version: "1.0.0".to_string(),
|
||||
last_check: Some("2025-06-15T12:00:00Z".to_string()),
|
||||
|
||||
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 987 KiB |
|
Before Width: | Height: | Size: 976 KiB After Width: | Height: | Size: 869 KiB |
|
Before Width: | Height: | Size: 976 KiB After Width: | Height: | Size: 869 KiB |
@ -5,10 +5,24 @@ server {
|
||||
proxy_intercept_errors on;
|
||||
error_page 500 502 503 504 = @wait_page;
|
||||
|
||||
# Serve our own wait-page/icon assets locally first, but fall back to the
|
||||
# real fedimint guardian (:8177) for ITS bundled /assets/*.css|js. Without
|
||||
# the fallback, the guardian UI's stylesheets resolve to this local root,
|
||||
# 404, and the app renders unstyled (B13 fixed the local icon; this fixes
|
||||
# the guardian UI's own CSS).
|
||||
location /assets/ {
|
||||
root /usr/share/nginx/html;
|
||||
add_header Cache-Control "public, max-age=3600" always;
|
||||
try_files $uri =404;
|
||||
try_files $uri @guardian_assets;
|
||||
}
|
||||
|
||||
location @guardian_assets {
|
||||
proxy_pass http://127.0.0.1:8177;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location / {
|
||||
|
||||
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 987 KiB |
|
Before Width: | Height: | Size: 570 KiB After Width: | Height: | Size: 510 KiB |
@ -156,6 +156,50 @@ underscores. Supported interface types are `ui`, `api`, and `metrics`; only
|
||||
`type: ui` is treated as a launchable app surface. Supported protocols are
|
||||
`http` and `https`, and `path` must start with `/`.
|
||||
|
||||
### Nostr Signer Bridge (NIP-07)
|
||||
|
||||
Apps embedded in the Archipelago iframe can use the node's Nostr identity to sign
|
||||
events without managing their own keys. Archipelago injects a **NIP-07 provider**
|
||||
(`window.nostr` with `getPublicKey()` / `signEvent()` / `nip04` / `nip44`) that bridges
|
||||
to the host. Your app code uses standard NIP-07 — no Archipelago-specific API.
|
||||
|
||||
**How injection works.** After install, the host copies `nostr-provider.js` into the
|
||||
app container and patches the app's web server so every page loads it and the app is
|
||||
iframe-embeddable. This is **best-effort** and depends on your server config exposing
|
||||
the right hooks. For an **nginx-served SPA** (the supported reference shape, e.g.
|
||||
IndeeHub) your `nginx.conf` must satisfy this contract:
|
||||
|
||||
1. **Be iframe-embeddable.** Do not send a hard `X-Frame-Options: DENY`. The host
|
||||
strips a `SAMEORIGIN`/`DENY` `X-Frame-Options` header line if present; restrictive
|
||||
CSP `frame-ancestors` will still block embedding.
|
||||
2. **Keep an exact-match `location = /sw.js {` block.** The provider's no-cache
|
||||
`location = /nostr-provider.js` block is inserted immediately before it.
|
||||
3. **Keep an SPA fallback line `try_files $uri $uri/ /index.html;`.** A
|
||||
`sub_filter` that injects `<script src="/nostr-provider.js"></script>` before
|
||||
`</head>` is inserted right after it. (nginx must have `ngx_http_sub_module` —
|
||||
stock `nginx:alpine` does.)
|
||||
4. **If you proxy an API that does NIP-98 URL verification**, expose
|
||||
`proxy_set_header X-Forwarded-Prefix /api;`; the host rewrites it to honor the
|
||||
outer reverse proxy's prefix.
|
||||
|
||||
The patch is **idempotent** (it checks for an existing `nostr-provider` reference
|
||||
before editing) and re-runs on reinstall. If you rename or remove any of the anchor
|
||||
strings above, injection silently no-ops and `window.nostr` will be undefined in your
|
||||
app — so guard those lines in your config (see the contract comment block at the top of
|
||||
IndeeHub's `nginx.conf` for a template).
|
||||
|
||||
> Non-nginx servers (Next.js `node server.js`, etc.) are not auto-patched today. Either
|
||||
> serve via nginx, or ship `nostr-provider.js` yourself and reference it in your HTML;
|
||||
> the canonical script lives at `/opt/archipelago/web-ui/nostr-provider.js` on the node.
|
||||
|
||||
Declare iframe intent in the manifest so the launcher embeds (vs. opens a new tab):
|
||||
|
||||
```yaml
|
||||
metadata:
|
||||
launch:
|
||||
open_in_new_tab: false # default; set true only if the app cannot be iframed
|
||||
```
|
||||
|
||||
## Security Requirements
|
||||
|
||||
These are enforced by the marketplace/catalog pipeline and the node. Non-compliant apps are flagged.
|
||||
|
||||
@ -79,6 +79,16 @@ xset s noblank 2>/dev/null || true
|
||||
pkill -u archipelago -f 'chromium.*localhost' 2>/dev/null || true
|
||||
sleep 1
|
||||
|
||||
# GPU vs headless (#36). On a real kiosk display with a GPU, GPU rasterization is
|
||||
# fast. On a GPU-less / headless server (no /dev/dri), --enable-gpu-rasterization
|
||||
# forces GPU paths that fall back to software compositing and SPIN a full core at
|
||||
# ~92% CPU, saturating the node. Detect the GPU and pick safe flags accordingly.
|
||||
if [ -e /dev/dri/card0 ] || [ -e /dev/dri/renderD128 ]; then
|
||||
GPU_FLAGS="--enable-gpu-rasterization --num-raster-threads=2"
|
||||
else
|
||||
GPU_FLAGS="--disable-gpu --num-raster-threads=1"
|
||||
fi
|
||||
|
||||
while true; do
|
||||
sudo -u archipelago env DISPLAY=:0 HOME=/home/archipelago chromium --kiosk \
|
||||
--app=http://localhost/kiosk?safe_area_x=${KIOSK_SAFE_AREA_X_PX:-0}\&safe_area_y=${KIOSK_SAFE_AREA_Y_PX:-0} \
|
||||
@ -92,8 +102,7 @@ while true; do
|
||||
--disable-save-password-bubble \
|
||||
--disable-suggestions-service \
|
||||
--disable-component-update \
|
||||
--enable-gpu-rasterization \
|
||||
--num-raster-threads=2 \
|
||||
$GPU_FLAGS \
|
||||
--renderer-process-limit=2 \
|
||||
--window-size=1920,1080 \
|
||||
--window-position=0,0 \
|
||||
|
||||
@ -20,5 +20,15 @@ TimeoutStartSec=360
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
# Resource guardrail (#36). On GPU-less / headless hardware chromium could spin
|
||||
# software compositing at ~92% of a core, saturating the node and starving the
|
||||
# backend (it caused the .198 receive timeout + deploy storms). Cap CPU + memory
|
||||
# so a runaway kiosk can never take the whole machine down; Delegate so the cap
|
||||
# also binds the chromium/Xorg children in this unit's cgroup.
|
||||
Delegate=yes
|
||||
CPUQuota=75%
|
||||
MemoryMax=1500M
|
||||
MemoryHigh=1200M
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@ -2,6 +2,14 @@
|
||||
Description=Archipelago Backend
|
||||
After=network-online.target archipelago-setup-tor.service
|
||||
Wants=network-online.target
|
||||
# The data dir AND podman's graphroot (containers/storage) both live on the
|
||||
# separate /var/lib/archipelago volume. Without this, on a cold boot the service
|
||||
# (and its ExecStartPre) can start BEFORE var-lib-archipelago.mount, write to the
|
||||
# bare mountpoint on rootfs, fail every podman call, exit, and get restarted every
|
||||
# 5s until the volume mounts (~5 min of "[FAILED] Failed to start" on boot — B17).
|
||||
# RequiresMountsFor adds both Requires= and After= on the mount unit so we never
|
||||
# start until the data volume is mounted.
|
||||
RequiresMountsFor=/var/lib/archipelago
|
||||
|
||||
[Service]
|
||||
Type=notify
|
||||
|
||||
4
neode-ui/package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"version": "1.7.96-alpha",
|
||||
"version": "1.7.98-alpha",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "neode-ui",
|
||||
"version": "1.7.96-alpha",
|
||||
"version": "1.7.98-alpha",
|
||||
"dependencies": {
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@vue-leaflet/vue-leaflet": "^0.10.1",
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"private": true,
|
||||
"version": "1.7.96-alpha",
|
||||
"version": "1.7.98-alpha",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "./start-dev.sh",
|
||||
|
||||
|
Before Width: | Height: | Size: 476 KiB After Width: | Height: | Size: 435 KiB |
|
Before Width: | Height: | Size: 894 KiB After Width: | Height: | Size: 824 KiB |
|
Before Width: | Height: | Size: 1014 KiB After Width: | Height: | Size: 965 KiB |
|
Before Width: | Height: | Size: 1019 KiB After Width: | Height: | Size: 954 KiB |
|
Before Width: | Height: | Size: 1016 KiB After Width: | Height: | Size: 943 KiB |
|
Before Width: | Height: | Size: 1019 KiB After Width: | Height: | Size: 954 KiB |
|
Before Width: | Height: | Size: 976 KiB After Width: | Height: | Size: 869 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 987 KiB |
|
Before Width: | Height: | Size: 901 KiB After Width: | Height: | Size: 854 KiB |
|
Before Width: | Height: | Size: 999 KiB After Width: | Height: | Size: 956 KiB |
|
Before Width: | Height: | Size: 999 KiB After Width: | Height: | Size: 952 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 987 KiB |
|
Before Width: | Height: | Size: 5.5 MiB After Width: | Height: | Size: 778 KiB |
|
Before Width: | Height: | Size: 1014 KiB After Width: | Height: | Size: 965 KiB |
|
Before Width: | Height: | Size: 976 KiB After Width: | Height: | Size: 869 KiB |
|
Before Width: | Height: | Size: 996 KiB After Width: | Height: | Size: 919 KiB |
|
Before Width: | Height: | Size: 774 KiB After Width: | Height: | Size: 726 KiB |
|
Before Width: | Height: | Size: 494 KiB After Width: | Height: | Size: 438 KiB |
@ -59,6 +59,15 @@
|
||||
<p class="mt-0.5 text-sm text-white/70 line-clamp-2">{{ toastMessage.text }}</p>
|
||||
<p class="mt-1 text-xs text-orange-400">Click to view</p>
|
||||
</div>
|
||||
<button
|
||||
@click.stop="messageToast.closeToast"
|
||||
aria-label="Dismiss notification"
|
||||
class="-mt-1 -mr-1 shrink-0 rounded-full p-1 text-white/40 transition-colors hover:bg-white/10 hover:text-white/80"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
@ -136,7 +145,7 @@ watch(() => appStore.isAuthenticated, (authenticated) => {
|
||||
}
|
||||
} else {
|
||||
messageToast.stopPolling()
|
||||
toastMessage.value = { show: false, text: '' }
|
||||
toastMessage.value = { show: false, text: '', fromPubkey: '' }
|
||||
screensaverStore.clearInactivityTimer()
|
||||
screensaverStore.deactivate()
|
||||
stopRemoteRelay()
|
||||
|
||||
@ -21,7 +21,9 @@ function jsonResponse(body: unknown, status = 200): Response {
|
||||
json: () => Promise.resolve(body),
|
||||
text: () => Promise.resolve(typeof body === 'string' ? body : JSON.stringify(body)),
|
||||
blob: () => Promise.resolve(new Blob([JSON.stringify(body)])),
|
||||
headers: new Headers(),
|
||||
// A real File Browser JSON response carries this; listDirectory now guards
|
||||
// on it (B4) to detect the SPA-fallback HTML / 502 cases.
|
||||
headers: new Headers({ 'content-type': 'application/json' }),
|
||||
redirected: false,
|
||||
type: 'basic' as ResponseType,
|
||||
url: '',
|
||||
@ -119,7 +121,21 @@ describe('FileBrowserClient', () => {
|
||||
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse(null, 404))
|
||||
|
||||
await expect(fileBrowserClient.listDirectory('/missing')).rejects.toThrow('Failed to list directory: 404')
|
||||
await expect(fileBrowserClient.listDirectory('/missing')).rejects.toThrow('File Browser is not available (HTTP 404)')
|
||||
})
|
||||
|
||||
it('throws a friendly error when File Browser is absent and nginx serves the SPA (B4)', async () => {
|
||||
setAuthenticated()
|
||||
|
||||
// 200 but text/html (SPA index.html fallback) — res.json() would throw the
|
||||
// opaque "Unexpected token '<'"; the guard must surface a friendly message.
|
||||
const htmlResponse = {
|
||||
...jsonResponse('<!doctype html><html></html>'),
|
||||
headers: new Headers({ 'content-type': 'text/html' }),
|
||||
} as Response
|
||||
mockFetch.mockResolvedValueOnce(htmlResponse)
|
||||
|
||||
await expect(fileBrowserClient.listDirectory('/')).rejects.toThrow('File Browser is not available')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -586,6 +586,21 @@ class RPCClient {
|
||||
})
|
||||
}
|
||||
|
||||
async checkPackageUpdates(): Promise<{
|
||||
status: string
|
||||
refreshed: boolean
|
||||
catalog_apps?: number
|
||||
error?: string
|
||||
}> {
|
||||
// Refreshes the remote app catalog now (decoupled from the binary OTA).
|
||||
// Per-app `available-update` badges repopulate on the next package scan
|
||||
// and arrive via the usual WebSocket push.
|
||||
return this.call({
|
||||
method: 'package.check-updates',
|
||||
timeout: 25000,
|
||||
})
|
||||
}
|
||||
|
||||
async getMarketplace(url: string): Promise<Record<string, unknown>> {
|
||||
return this.call({
|
||||
method: 'marketplace.get',
|
||||
|
||||
@ -25,7 +25,11 @@
|
||||
class="flex-shrink-0 w-9 h-9 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center transition-colors"
|
||||
@click="togglePlay"
|
||||
>
|
||||
<svg v-if="!audioPlayer.playing.value" class="w-5 h-5 text-white ml-0.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<svg v-if="audioPlayer.loading.value" class="w-5 h-5 animate-spin text-white" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.4 0 0 5.4 0 12h4z" />
|
||||
</svg>
|
||||
<svg v-else-if="!audioPlayer.playing.value" class="w-5 h-5 text-white ml-0.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7L8 5z" />
|
||||
</svg>
|
||||
<svg v-else class="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||
|
||||
@ -27,7 +27,7 @@ describe('useMessageToast', () => {
|
||||
toast.receivedMessages.value = []
|
||||
toast.lastMessageCount.value = 0
|
||||
toast.loadingMessages.value = false
|
||||
toast.toastMessage.value = { show: false, text: '' }
|
||||
toast.toastMessage.value = { show: false, text: '', fromPubkey: '' }
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@ -145,7 +145,7 @@ describe('useMessageToast', () => {
|
||||
|
||||
it('dismissToastAndOpenMessages clears toast and navigates', () => {
|
||||
const toast = useMessageToast()
|
||||
toast.toastMessage.value = { show: true, text: 'New message' }
|
||||
toast.toastMessage.value = { show: true, text: 'New message', fromPubkey: '' }
|
||||
toast.dismissToastAndOpenMessages()
|
||||
|
||||
expect(toast.toastMessage.value.show).toBe(false)
|
||||
|
||||
@ -4,6 +4,7 @@ const audio = ref<HTMLAudioElement | null>(null)
|
||||
const currentSrc = ref<string | null>(null)
|
||||
const currentName = ref('')
|
||||
const playing = ref(false)
|
||||
const loading = ref(false)
|
||||
const currentTime = ref(0)
|
||||
const duration = ref(0)
|
||||
const error = ref<string | null>(null)
|
||||
@ -21,8 +22,22 @@ function init() {
|
||||
duration.value = audio.value?.duration ?? 0
|
||||
error.value = null
|
||||
})
|
||||
// Buffering / connecting over mesh|Tor → show a loader until it can play.
|
||||
audio.value.addEventListener('loadstart', () => {
|
||||
loading.value = true
|
||||
})
|
||||
audio.value.addEventListener('waiting', () => {
|
||||
loading.value = true
|
||||
})
|
||||
audio.value.addEventListener('canplay', () => {
|
||||
loading.value = false
|
||||
})
|
||||
audio.value.addEventListener('playing', () => {
|
||||
loading.value = false
|
||||
})
|
||||
audio.value.addEventListener('ended', () => {
|
||||
playing.value = false
|
||||
loading.value = false
|
||||
})
|
||||
audio.value.addEventListener('pause', () => {
|
||||
playing.value = false
|
||||
@ -33,7 +48,8 @@ function init() {
|
||||
})
|
||||
audio.value.addEventListener('error', () => {
|
||||
playing.value = false
|
||||
error.value = 'Could not play audio. File Browser may not be running.'
|
||||
loading.value = false
|
||||
error.value = 'Could not play this audio file. The peer may be offline, or the file may be unavailable.'
|
||||
})
|
||||
}
|
||||
|
||||
@ -47,6 +63,7 @@ function play(src: string, name: string) {
|
||||
}
|
||||
|
||||
if (currentSrc.value !== src) {
|
||||
loading.value = true
|
||||
audio.value!.src = src
|
||||
currentSrc.value = src
|
||||
currentName.value = name
|
||||
@ -87,6 +104,7 @@ export function useAudioPlayer() {
|
||||
seek,
|
||||
stop,
|
||||
playing,
|
||||
loading,
|
||||
currentName,
|
||||
currentTime,
|
||||
duration,
|
||||
|
||||
@ -14,7 +14,7 @@ const MESSAGE_POLL_INTERVAL = 30000 // 30s
|
||||
const receivedMessages = ref<ReceivedMessage[]>([])
|
||||
const lastMessageCount = ref(0)
|
||||
const loadingMessages = ref(false)
|
||||
const toastMessage = ref<{ show: boolean; text: string }>({ show: false, text: '' })
|
||||
const toastMessage = ref<{ show: boolean; text: string; fromPubkey: string }>({ show: false, text: '', fromPubkey: '' })
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
export function useMessageToast() {
|
||||
@ -37,6 +37,9 @@ export function useMessageToast() {
|
||||
toastMessage.value = {
|
||||
show: true,
|
||||
text: (newCount === 1 ? latest?.message : null) ?? `${newCount} new messages`,
|
||||
// Only deep-link to a specific chat when it's a single new message
|
||||
// from one sender; otherwise open the mesh list.
|
||||
fromPubkey: newCount === 1 ? (latest?.from_pubkey ?? '') : '',
|
||||
}
|
||||
lastMessageCount.value = msgs.length
|
||||
} else {
|
||||
@ -83,9 +86,16 @@ export function useMessageToast() {
|
||||
}
|
||||
|
||||
function dismissToastAndOpenMessages() {
|
||||
toastMessage.value = { show: false, text: '' }
|
||||
const peer = toastMessage.value.fromPubkey
|
||||
toastMessage.value = { show: false, text: '', fromPubkey: '' }
|
||||
markAsRead()
|
||||
router.push('/dashboard/mesh')
|
||||
// Open the specific conversation when we know the sender; else the mesh list.
|
||||
router.push(peer ? { path: '/dashboard/mesh', query: { peer } } : '/dashboard/mesh')
|
||||
}
|
||||
|
||||
// Dismiss the toast without navigating (the close icon).
|
||||
function closeToast() {
|
||||
toastMessage.value = { show: false, text: '', fromPubkey: '' }
|
||||
}
|
||||
|
||||
return {
|
||||
@ -99,5 +109,6 @@ export function useMessageToast() {
|
||||
stopPolling,
|
||||
markAsRead,
|
||||
dismissToastAndOpenMessages,
|
||||
closeToast,
|
||||
}
|
||||
}
|
||||
|
||||
@ -179,7 +179,7 @@
|
||||
"aiDataAccess": "AI Data Access",
|
||||
"serverName": "Hostname",
|
||||
"sessionStatus": "Session Status",
|
||||
"yourDid": "Your DID",
|
||||
"yourDid": "Node DID",
|
||||
"onionAddress": "Node .onion Address",
|
||||
"loggedIn": "Currently logged in",
|
||||
"didHelper": "Decentralized identifier for passwordless auth",
|
||||
|
||||
95
neode-ui/src/stores/__tests__/homeStatus.test.ts
Normal file
@ -0,0 +1,95 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
|
||||
// Mock the rpc-client module
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
call: vi.fn(),
|
||||
vpnStatus: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { useHomeStatusStore } from '../homeStatus'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { PackageState, type PackageDataEntry } from '@/types/api'
|
||||
|
||||
const mockedRpc = vi.mocked(rpcClient)
|
||||
|
||||
function pkg(state: string): Record<string, PackageDataEntry> {
|
||||
return { 'bitcoin-knots': { state } as unknown as PackageDataEntry }
|
||||
}
|
||||
|
||||
describe('homeStatus — B16 bitcoin sync status retain (no vanish, no stale-as-live)', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('records a successful poll as available + not stale', async () => {
|
||||
const store = useHomeStatusStore()
|
||||
mockedRpc.call.mockResolvedValueOnce({ block_height: 800000, sync_progress: 1 })
|
||||
await store.refreshBitcoin({})
|
||||
expect(store.stats.bitcoinAvailable).toBe(true)
|
||||
expect(store.stats.bitcoinSyncPercent).toBe(100)
|
||||
expect(store.bitcoinStale).toBe(false)
|
||||
expect(store.bitcoinLoadState).toBe('ready')
|
||||
})
|
||||
|
||||
it('keeps the tile visible (available) but marks stale when getinfo fails while the container is Running', async () => {
|
||||
const store = useHomeStatusStore()
|
||||
// First a good poll so we have real sync numbers.
|
||||
mockedRpc.call.mockResolvedValueOnce({ block_height: 800000, sync_progress: 0.5 })
|
||||
await store.refreshBitcoin(pkg(PackageState.Running))
|
||||
expect(store.stats.bitcoinAvailable).toBe(true)
|
||||
|
||||
// Now a transient RPC failure (e.g. RPC busy during heavy IBD) — container still Running.
|
||||
mockedRpc.call.mockRejectedValueOnce(new Error('timeout'))
|
||||
await store.refreshBitcoin(pkg(PackageState.Running))
|
||||
expect(store.stats.bitcoinAvailable).toBe(true) // does NOT vanish
|
||||
expect(store.bitcoinStale).toBe(true) // shown as "Updating…", not live
|
||||
expect(store.stats.bitcoinSyncPercent).toBe(50) // last-known retained
|
||||
})
|
||||
|
||||
it('flips to NOT available (and not stale) when getinfo fails and the container is Stopped — no stale-as-live', async () => {
|
||||
const store = useHomeStatusStore()
|
||||
mockedRpc.call.mockResolvedValueOnce({ block_height: 800000, sync_progress: 1 })
|
||||
await store.refreshBitcoin(pkg(PackageState.Running))
|
||||
expect(store.stats.bitcoinAvailable).toBe(true)
|
||||
|
||||
mockedRpc.call.mockRejectedValueOnce(new Error('refused'))
|
||||
await store.refreshBitcoin(pkg(PackageState.Stopped))
|
||||
expect(store.stats.bitcoinAvailable).toBe(false) // genuinely down → reflect it
|
||||
expect(store.bitcoinStale).toBe(false) // not "Updating…": it's authoritatively stopped
|
||||
})
|
||||
|
||||
it('retains the last-known available value (marked stale) when package data is momentarily absent', async () => {
|
||||
const store = useHomeStatusStore()
|
||||
mockedRpc.call.mockResolvedValueOnce({ block_height: 800000, sync_progress: 1 })
|
||||
await store.refreshBitcoin(pkg(PackageState.Running))
|
||||
expect(store.stats.bitcoinAvailable).toBe(true)
|
||||
|
||||
// getinfo fails AND the packages map has no authoritative bitcoin entry (route change / scan).
|
||||
mockedRpc.call.mockRejectedValueOnce(new Error('timeout'))
|
||||
await store.refreshBitcoin({})
|
||||
expect(store.stats.bitcoinAvailable).toBe(true) // retained, does NOT flash "Not running"
|
||||
expect(store.bitcoinStale).toBe(true)
|
||||
expect(store.bitcoinLoadState).toBe('ready')
|
||||
})
|
||||
|
||||
it('stays unknown (null) without fabricating availability when the first ever poll fails with no package data', async () => {
|
||||
const store = useHomeStatusStore()
|
||||
mockedRpc.call.mockRejectedValueOnce(new Error('timeout'))
|
||||
await store.refreshBitcoin({})
|
||||
expect(store.stats.bitcoinAvailable).toBeNull() // nothing known yet — don't invent a tile
|
||||
expect(store.bitcoinLoadState).toBe('error')
|
||||
})
|
||||
|
||||
it('marks bitcoin available + stale when the first poll times out but the container is Running (syncing node)', async () => {
|
||||
const store = useHomeStatusStore()
|
||||
// No prior success; getinfo times out during heavy initial sync, but container is up.
|
||||
mockedRpc.call.mockRejectedValueOnce(new Error('timeout'))
|
||||
await store.refreshBitcoin(pkg(PackageState.Running))
|
||||
expect(store.stats.bitcoinAvailable).toBe(true) // tile appears instead of staying hidden
|
||||
expect(store.bitcoinStale).toBe(true) // labeled "Updating…" since we have no live numbers yet
|
||||
})
|
||||
})
|
||||
@ -43,6 +43,10 @@ export const useHomeStatusStore = defineStore('homeStatus', () => {
|
||||
const stats = reactive<SystemStatsSnapshot>(emptyStats())
|
||||
const systemLoadState = ref<LoadState>('idle')
|
||||
const bitcoinLoadState = ref<LoadState>('idle')
|
||||
// True when we're showing a retained (last-known) bitcoin value because the
|
||||
// latest poll failed transiently — the UI renders an "Updating…" badge so the
|
||||
// figure is never presented as live, and the tile never vanishes mid-sync.
|
||||
const bitcoinStale = ref(false)
|
||||
const vpnLoadState = ref<LoadState>('idle')
|
||||
const fipsLoadState = ref<LoadState>('idle')
|
||||
const lastSystemRefreshAt = ref<number | null>(null)
|
||||
@ -109,26 +113,34 @@ export const useHomeStatusStore = defineStore('homeStatus', () => {
|
||||
stats.bitcoinSyncPercent = (btc.sync_progress ?? 0) * 100
|
||||
stats.bitcoinBlockHeight = btc.block_height ?? 0
|
||||
stats.bitcoinAvailable = true
|
||||
bitcoinStale.value = false
|
||||
bitcoinLoadState.value = 'ready'
|
||||
lastBitcoinRefreshAt.value = Date.now()
|
||||
} catch {
|
||||
const btcPkg = packages['bitcoin-knots'] || packages['bitcoin-core'] || packages.bitcoin
|
||||
if (btcPkg?.state === PackageState.Running) {
|
||||
// Container is up but the RPC call failed (busy during heavy IBD, etc.).
|
||||
// Keep the tile visible with the last-known figures, marked as updating.
|
||||
stats.bitcoinAvailable = true
|
||||
bitcoinStale.value = true
|
||||
bitcoinLoadState.value = 'ready'
|
||||
lastBitcoinRefreshAt.value = Date.now()
|
||||
return
|
||||
}
|
||||
|
||||
if (btcPkg && (btcPkg.state === PackageState.Stopped || btcPkg.state === PackageState.Exited)) {
|
||||
// Authoritatively down — reflect it (do NOT keep showing stale data as live).
|
||||
stats.bitcoinAvailable = false
|
||||
bitcoinStale.value = false
|
||||
bitcoinLoadState.value = 'ready'
|
||||
lastBitcoinRefreshAt.value = Date.now()
|
||||
return
|
||||
}
|
||||
|
||||
// No authoritative package data yet. Keep the previous known value
|
||||
// rather than flashing "Not running" during route changes/scans.
|
||||
// rather than flashing "Not running" during route changes/scans; if we
|
||||
// had a value, surface it as "updating" instead of presenting it as live.
|
||||
if (stats.bitcoinAvailable !== null) bitcoinStale.value = true
|
||||
bitcoinLoadState.value = stats.bitcoinAvailable === null ? 'error' : 'ready'
|
||||
}
|
||||
}
|
||||
@ -186,6 +198,7 @@ export const useHomeStatusStore = defineStore('homeStatus', () => {
|
||||
stats,
|
||||
systemLoadState,
|
||||
bitcoinLoadState,
|
||||
bitcoinStale,
|
||||
vpnLoadState,
|
||||
fipsLoadState,
|
||||
systemStatsLoaded,
|
||||
|
||||
@ -231,7 +231,7 @@
|
||||
<!-- Quick Start Goals -->
|
||||
<div
|
||||
v-if="showQuickStart"
|
||||
class="home-card transition-opacity duration-300"
|
||||
class="home-card lg:col-span-2 transition-opacity duration-300"
|
||||
:class="{ 'home-card-animate': animateCards, 'opacity-0 pointer-events-none': showWelcomeBlock && !animateCards }"
|
||||
style="--card-stagger: 5"
|
||||
>
|
||||
@ -506,6 +506,7 @@ const systemStatsLoaded = computed(() => homeStatus.systemStatsLoaded)
|
||||
const systemStats = computed(() => ({
|
||||
...homeStatus.stats,
|
||||
bitcoinAvailable: homeStatus.stats.bitcoinAvailable === true,
|
||||
bitcoinStale: homeStatus.bitcoinStale,
|
||||
}))
|
||||
const systemUptimeDisplay = computed(() => { if (homeStatus.stats.uptimeSecs === 0) return t('home.systemMonitoring'); const days = Math.floor(homeStatus.stats.uptimeSecs / 86400); const hours = Math.floor((homeStatus.stats.uptimeSecs % 86400) / 3600); if (days > 0) return `Uptime: ${days}d ${hours}h`; const mins = Math.floor((homeStatus.stats.uptimeSecs % 3600) / 60); return `Uptime: ${hours}h ${mins}m` })
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useMeshStore } from '@/stores/mesh'
|
||||
import { useTransportStore } from '@/stores/transport'
|
||||
import type { MeshMessage, MeshPeer, SessionStatus } from '@/stores/mesh'
|
||||
@ -12,6 +13,7 @@ import '@/views/mesh/mesh-styles.css'
|
||||
|
||||
const mesh = useMeshStore()
|
||||
const transport = useTransportStore()
|
||||
const route = useRoute()
|
||||
|
||||
// Responsive layout breakpoints
|
||||
const isWideDesktop = ref(window.innerWidth >= 1536)
|
||||
@ -181,16 +183,24 @@ async function sendArchMessage() {
|
||||
selfOnion = tor.tor_address
|
||||
} catch { /* non-fatal */ }
|
||||
const msg = messageText.value.trim()
|
||||
let sent = 0
|
||||
for (const node of nodes.nodes) {
|
||||
const nodeOnion = node.onion || node.did
|
||||
// Skip sending to ourselves (would create duplicate received message)
|
||||
if (selfOnion && (nodeOnion === selfOnion || nodeOnion === selfOnion.replace('.onion', '') || selfOnion === nodeOnion + '.onion')) continue
|
||||
try {
|
||||
await rpcClient.sendMessageToPeer(nodeOnion, msg)
|
||||
sent++
|
||||
} catch { /* some peers may be offline */ }
|
||||
}
|
||||
const targets = nodes.nodes
|
||||
.map((node) => node.onion || node.did)
|
||||
// Skip sending to ourselves (would create a duplicate received message).
|
||||
.filter(
|
||||
(nodeOnion) =>
|
||||
!(selfOnion &&
|
||||
(nodeOnion === selfOnion ||
|
||||
nodeOnion === selfOnion.replace('.onion', '') ||
|
||||
selfOnion === nodeOnion + '.onion'))
|
||||
)
|
||||
// Send to all peers CONCURRENTLY so the spinner clears after the slowest
|
||||
// single delivery (one Tor round-trip) rather than the sum of all of them —
|
||||
// previously a slow or offline node kept the "sending" spinner up long after
|
||||
// the online peers had already received the message.
|
||||
const results = await Promise.allSettled(
|
||||
targets.map((nodeOnion) => rpcClient.sendMessageToPeer(nodeOnion, msg))
|
||||
)
|
||||
const sent = results.filter((r) => r.status === 'fulfilled').length
|
||||
try {
|
||||
await rpcClient.call({ method: 'node-store-sent', params: { message: msg } })
|
||||
} catch { /* non-fatal */ }
|
||||
@ -301,6 +311,15 @@ onMounted(async () => {
|
||||
loadPendingFromSession()
|
||||
await Promise.all([mesh.refreshAll(), transport.fetchStatus(), refreshFederationNodes(), refreshSelfOnion(), refreshSelfDid(), refreshContacts()])
|
||||
refreshOutboxCount()
|
||||
// Deep-link from a message toast: open the sender's conversation if we can
|
||||
// match it; otherwise just land on the mesh page (graceful fallback).
|
||||
const targetPeer = typeof route.query.peer === 'string' ? route.query.peer : ''
|
||||
if (targetPeer) {
|
||||
const match = mesh.peers.find(
|
||||
(p) => p.pubkey_hex === targetPeer || p.did === targetPeer
|
||||
)
|
||||
if (match) openChat(match)
|
||||
}
|
||||
// Start background polling for Archipelago (Tor) messages so unread count works
|
||||
loadArchMessages()
|
||||
if (!archPollInterval) {
|
||||
|
||||
@ -90,7 +90,7 @@
|
||||
<div
|
||||
v-for="item in catalogItems"
|
||||
:key="item.id"
|
||||
class="glass-card overflow-hidden"
|
||||
class="glass-card overflow-hidden flex flex-col h-full"
|
||||
>
|
||||
<!-- Media preview (images / videos / audio) -->
|
||||
<div
|
||||
@ -150,8 +150,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card body -->
|
||||
<div class="p-4 flex items-center gap-4">
|
||||
<!-- Card body — pinned to the bottom so the filename + action buttons
|
||||
line up across cards of differing preview heights. -->
|
||||
<div class="p-4 flex items-center gap-4 mt-auto">
|
||||
<div v-if="!isMediaMime(item.mime_type)" class="flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center" :class="fileIconBg(item.mime_type)">
|
||||
<svg class="w-5 h-5" :class="fileIconColor(item.mime_type)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="fileIconPath(item.mime_type)" />
|
||||
@ -234,12 +235,30 @@
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Video element -->
|
||||
<video
|
||||
:src="videoPlayerUrl"
|
||||
class="w-full rounded-xl"
|
||||
controls
|
||||
autoplay
|
||||
/>
|
||||
<div class="relative">
|
||||
<video
|
||||
:src="videoPlayerUrl"
|
||||
class="w-full rounded-xl bg-black"
|
||||
controls
|
||||
autoplay
|
||||
@playing="videoLoading = false"
|
||||
@canplay="videoLoading = false"
|
||||
@error="videoLoading = false; videoError = true"
|
||||
/>
|
||||
<!-- Loader while the stream connects over mesh/Tor -->
|
||||
<div v-if="videoLoading && !videoError" class="absolute inset-0 flex flex-col items-center justify-center gap-3 rounded-xl bg-black/60 pointer-events-none">
|
||||
<svg class="w-8 h-8 animate-spin text-white/80" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.4 0 0 5.4 0 12h4z" />
|
||||
</svg>
|
||||
<span class="text-sm text-white/70">Connecting to peer…</span>
|
||||
</div>
|
||||
<!-- Error state -->
|
||||
<div v-if="videoError" class="absolute inset-0 flex flex-col items-center justify-center gap-2 rounded-xl bg-black/70 text-center px-4">
|
||||
<p class="text-sm text-white/80">Couldn't play this video</p>
|
||||
<p class="text-xs text-white/50">The peer may be offline, or this preview can't be played. Try downloading it instead.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Info bar -->
|
||||
<div class="mt-3 flex items-center justify-between">
|
||||
<div>
|
||||
@ -320,6 +339,10 @@ const audioPlayer = useAudioPlayer()
|
||||
const videoPlayerItem = ref<CatalogItem | null>(null)
|
||||
const videoPlayerUrl = ref<string | null>(null)
|
||||
const videoPlayerPaid = ref(false)
|
||||
// Streaming a peer's file connects over mesh/Tor before the first frame, so
|
||||
// show a loader until the element can actually play (or errors).
|
||||
const videoLoading = ref(false)
|
||||
const videoError = ref(false)
|
||||
|
||||
const peerDisplayName = computed(() => {
|
||||
if (currentPeer.value?.name) return currentPeer.value.name
|
||||
@ -604,8 +627,19 @@ function closeVideoPlayer() {
|
||||
videoPlayerItem.value = null
|
||||
videoPlayerUrl.value = null
|
||||
videoPlayerPaid.value = false
|
||||
videoLoading.value = false
|
||||
videoError.value = false
|
||||
}
|
||||
|
||||
// Show the loader the moment a video opens; the element's playing/canplay/error
|
||||
// events clear it.
|
||||
watch(videoPlayerUrl, (url) => {
|
||||
if (url) {
|
||||
videoLoading.value = true
|
||||
videoError.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function triggerDownload(base64Data: string, item: CatalogItem) {
|
||||
const blob = new Blob(
|
||||
[Uint8Array.from(atob(base64Data), c => c.charCodeAt(0))],
|
||||
|
||||
@ -871,6 +871,13 @@ async function downloadUpdate() {
|
||||
await loadStatus()
|
||||
showStatus(t('systemUpdate.upToDateMessage'))
|
||||
} else {
|
||||
// A failed download is NOT a staged update — return the UI to the
|
||||
// Download button so the user can retry, instead of stranding them on
|
||||
// Install. Re-sync from the backend (its self-heal clears a stale
|
||||
// update_in_progress once the partial staging is cleaned up).
|
||||
downloaded.value = false
|
||||
updateInProgress.value = false
|
||||
await loadStatus()
|
||||
showStatus(`${t('systemUpdate.downloadFailed')} ${msg}`, true)
|
||||
}
|
||||
if (import.meta.env.DEV) console.warn('Download failed', e)
|
||||
|
||||
@ -60,8 +60,8 @@
|
||||
<div v-if="stats.bitcoinAvailable" class="p-4 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<p class="text-xs text-orange-400/80">Bitcoin</p>
|
||||
<p class="text-sm font-medium" :class="stats.bitcoinSyncPercent >= 99.9 ? 'text-green-400' : 'text-orange-400'">
|
||||
{{ stats.bitcoinSyncPercent >= 99.9 ? 'Synced' : stats.bitcoinSyncPercent.toFixed(1) + '%' }}
|
||||
<p class="text-sm font-medium" :class="stats.bitcoinStale ? 'text-white/50' : (stats.bitcoinSyncPercent >= 99.9 ? 'text-green-400' : 'text-orange-400')">
|
||||
{{ stats.bitcoinStale ? 'Updating…' : (stats.bitcoinSyncPercent >= 99.9 ? 'Synced' : stats.bitcoinSyncPercent.toFixed(1) + '%') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="w-full h-2 bg-white/10 rounded-full overflow-hidden">
|
||||
@ -96,6 +96,7 @@ defineProps<{
|
||||
bitcoinSyncPercent: number
|
||||
bitcoinBlockHeight: number
|
||||
bitcoinAvailable: boolean
|
||||
bitcoinStale?: boolean
|
||||
}
|
||||
uptimeDisplay: string
|
||||
}>()
|
||||
|
||||
@ -50,13 +50,19 @@ useBodyScrollLock(showReleaseNotes)
|
||||
const serverTorAddressFromStore = computed(() => store.serverInfo?.['tor-address'] || null)
|
||||
const torAddressFromRpc = ref<string | null>(null)
|
||||
const serverTorAddress = computed(() => serverTorAddressFromStore.value || torAddressFromRpc.value)
|
||||
// Fallback DID fetched from the backend when localStorage doesn't have one
|
||||
// (e.g. a browser/node where onboarding never stored `neode_did`).
|
||||
const didFromRpc = ref<string | null>(null)
|
||||
const userDid = computed(() => {
|
||||
try {
|
||||
return localStorage.getItem('neode_did') || null
|
||||
return localStorage.getItem('neode_did') || didFromRpc.value
|
||||
} catch {
|
||||
return null
|
||||
return didFromRpc.value
|
||||
}
|
||||
})
|
||||
// The node's seed-derived Nostr public key (npub), fetched from the backend.
|
||||
const userNpub = ref<string | null>(null)
|
||||
const copiedNpub = ref(false)
|
||||
|
||||
const copiedOnion = ref(false)
|
||||
const copiedDid = ref(false)
|
||||
@ -100,6 +106,17 @@ async function copyDid() {
|
||||
setTimeout(() => { copiedDid.value = false }, 2000)
|
||||
}
|
||||
|
||||
async function copyNpub() {
|
||||
if (!userNpub.value) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(userNpub.value)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
copiedNpub.value = true
|
||||
setTimeout(() => { copiedNpub.value = false }, 2000)
|
||||
}
|
||||
|
||||
// Load Tor address on mount if not in store
|
||||
async function init() {
|
||||
if (!serverTorAddressFromStore.value) {
|
||||
@ -110,6 +127,29 @@ async function init() {
|
||||
if (import.meta.env.DEV) console.warn('Tor address may not be available yet', e)
|
||||
}
|
||||
}
|
||||
// DID: fall back to the node.did RPC when localStorage doesn't have one, so
|
||||
// the Identity card shows the DID on every node (not just ones where the
|
||||
// browser cached it during onboarding).
|
||||
let storedDid: string | null = null
|
||||
try { storedDid = localStorage.getItem('neode_did') } catch { /* unavailable */ }
|
||||
if (!storedDid) {
|
||||
try {
|
||||
const res = await rpcClient.call<{ did?: string }>({ method: 'node.did' })
|
||||
if (res?.did) {
|
||||
didFromRpc.value = res.did
|
||||
try { localStorage.setItem('neode_did', res.did) } catch { /* unavailable */ }
|
||||
}
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.warn('node.did unavailable', e)
|
||||
}
|
||||
}
|
||||
// The node's seed-derived Nostr public key (npub) for the Identity card.
|
||||
try {
|
||||
const res = await rpcClient.call<{ nostr_npub?: string }>({ method: 'node.nostr-pubkey' })
|
||||
if (res?.nostr_npub) userNpub.value = res.nostr_npub
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.warn('node.nostr-pubkey unavailable', e)
|
||||
}
|
||||
}
|
||||
init()
|
||||
</script>
|
||||
@ -188,6 +228,46 @@ init()
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
|
||||
<!-- v1.7.98-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.98-alpha</span>
|
||||
<span class="text-xs text-white/40">June 16, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Apps that crash now recover on their own. Multi-part apps like Immich and IndeedHub could have one of their pieces stop and stay stopped until the whole node was rebooted; the node now checks every couple of minutes and restarts any crashed piece automatically (while still leaving apps you deliberately stopped alone).</p>
|
||||
<p>The on-screen kiosk display can no longer slow the whole node down. On machines without a graphics chip the kiosk browser could spin a CPU core at full tilt, starving everything else (including the wallet, which then timed out); it's now capped and uses lighter rendering on those machines.</p>
|
||||
<p>If an update download fails, you're taken back to the Download button to retry, instead of being stranded on an Install button for an update that didn't actually finish downloading.</p>
|
||||
<p>Your node's identity is clearer and always visible: Settings now shows your Node DID on every node (it previously only appeared if your browser had cached it) plus your node's npub, both with copy buttons. There's also a terminal tool to cryptographically prove all your node's keys come from your one seed phrase.</p>
|
||||
<p>The "all nodes over Tor" group chat sends quickly now — the "sending" spinner clears as soon as the reachable nodes have the message, instead of hanging on a slow or offline node.</p>
|
||||
<p>Message notifications now have a close button and open the relevant chat when tapped.</p>
|
||||
<p>The encrypted mesh transport (FIPS) turns itself on automatically after setup — no button to press — and connects to peers more reliably (it retries and keeps connections warm), so node-to-node features use the fast path more often instead of falling back to Tor.</p>
|
||||
<p>Your chat history with other nodes is saved reliably and now encrypted on disk, so it survives restarts and updates and can't be read from a stolen drive (only clearing chat removes it).</p>
|
||||
<p>Peer media shows a "connecting" loader before a video or audio file plays, and audio errors are accurate instead of blaming File Browser.</p>
|
||||
<p>The Fedimint app now displays with its proper styling, and the Connected Nodes screen stays compact — it shows a few nodes and scrolls, you can tap a node to jump to it in Federation, or tap Message to open its chat.</p>
|
||||
<p>App updates can now arrive on their own without waiting for a full system release, so individual apps can be improved and shipped faster.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.97-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.97-alpha</span>
|
||||
<span class="text-xs text-white/40">June 16, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>The Bitcoin sync status on the home screen no longer disappears for a moment when it refreshes. If the node was briefly busy, the panel used to vanish and pop back; it now stays put and simply shows "Updating…" until the next reading arrives, while a genuinely stopped node still correctly shows as not running.</p>
|
||||
<p>Bitcoin sync progress on the home screen now updates more promptly, so the percentage and block height keep pace with the node instead of lagging behind.</p>
|
||||
<p>The Lightning wallet "connect your wallet" screen loads its details and QR code again across all nodes, instead of failing to fetch them.</p>
|
||||
<p>Your list of trusted nodes is now clean: the same node no longer appears several times under different names, and removed nodes stay removed. In chat, a node that previously showed up as two separate contacts now appears just once.</p>
|
||||
<p>Browsing another node's cloud is smoother: music and video files from a peer now preview and play properly (including seeking partway through), and the connection now shows a small badge telling you whether it's using the fast encrypted mesh or the slower Tor network.</p>
|
||||
<p>Opening "My Folders" in the cloud now shows a clear, friendly message when the file app isn't running, instead of a confusing error.</p>
|
||||
<p>The Electrum server app opens on its own once it's ready, instead of sometimes leaving a loading spinner stuck on top of the screen.</p>
|
||||
<p>The Fedimint app now displays with its proper styling and icons, instead of appearing unstyled with a missing image.</p>
|
||||
<p>The Mempool app now connects to your Bitcoin node whether the node is Bitcoin Core or Bitcoin Knots, instead of only working with one of them.</p>
|
||||
<p>Nodes start up cleanly after a reboot. On some boots the node's main service was trying to start before its data drive had finished mounting, so it failed and retried about twenty times over roughly five minutes — showing a wall of "Failed to start" messages — before finally coming up. It now waits for the data drive to be ready first, so it starts on the first try.</p>
|
||||
<p>The background images throughout the interface now load faster — they've been made significantly smaller with no loss of quality.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.96-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
@ -1496,8 +1576,8 @@ init()
|
||||
<p class="text-base font-medium text-white/90">{{ t('settings.loggedIn') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Identity Card: DID + Tor Address -->
|
||||
<div v-if="userDid || serverTorAddress" class="bg-black/20 rounded-xl px-5 py-4 border border-white/10 md:col-span-2 space-y-4">
|
||||
<!-- Identity Card: DID + npub + Tor Address -->
|
||||
<div v-if="userDid || userNpub || serverTorAddress" class="bg-black/20 rounded-xl px-5 py-4 border border-white/10 md:col-span-2 space-y-4">
|
||||
<div v-if="userDid">
|
||||
<div class="flex items-center justify-between gap-2 mb-2">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
@ -1520,7 +1600,29 @@ init()
|
||||
<p class="text-sm font-mono text-white/90 break-all" :title="userDid">{{ userDid }}</p>
|
||||
<p class="text-xs text-white/50 mt-1">{{ t('settings.didHelper') }}</p>
|
||||
</div>
|
||||
<div v-if="serverTorAddress" :class="userDid ? 'pt-4 border-t border-white/10' : ''">
|
||||
<div v-if="userNpub" :class="userDid ? 'pt-4 border-t border-white/10' : ''">
|
||||
<div class="flex items-center justify-between gap-2 mb-2">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<svg class="w-5 h-5 text-purple-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
<p class="text-xs font-semibold text-white/60 uppercase tracking-wide">Node npub</p>
|
||||
</div>
|
||||
<button
|
||||
@click="copyNpub"
|
||||
class="shrink-0 px-3 py-1.5 rounded-lg glass-button glass-button-sm text-xs font-medium text-white/90 hover:text-white transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
<svg v-if="!copiedNpub" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span v-else class="text-green-400 text-xs">{{ t('common.copied') }}</span>
|
||||
<span v-if="!copiedNpub">{{ t('common.copy') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-sm font-mono text-white/90 break-all" :title="userNpub">{{ userNpub }}</p>
|
||||
<p class="text-xs text-white/50 mt-1">Your node's Nostr public key, derived from its seed.</p>
|
||||
</div>
|
||||
<div v-if="serverTorAddress" :class="(userDid || userNpub) ? 'pt-4 border-t border-white/10' : ''">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<svg class="w-5 h-5 text-amber-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||
|
||||
@ -78,7 +78,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Trusted tab -->
|
||||
<div v-show="nodesContainerTab === 'trusted'" class="space-y-2 flex-1 overflow-y-auto">
|
||||
<div v-show="nodesContainerTab === 'trusted'" class="space-y-2 max-h-72 overflow-y-auto">
|
||||
<div v-if="loadingPeers && peers.length === 0" class="p-4 text-center text-white/60 text-sm">
|
||||
{{ t('common.loading') }}
|
||||
</div>
|
||||
@ -91,7 +91,8 @@
|
||||
<div
|
||||
v-for="p in peers"
|
||||
:key="p.pubkey"
|
||||
class="flex items-center justify-between p-3 bg-white/5 rounded-lg"
|
||||
@click="router.push({ path: '/dashboard/server/federation', query: { node: p.did || p.pubkey || p.onion } })"
|
||||
class="flex items-center justify-between p-3 bg-white/5 rounded-lg cursor-pointer hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<div class="w-2 h-2 rounded-full shrink-0" :class="peerReachable[p.onion] ? 'bg-green-400' : 'bg-amber-400'"></div>
|
||||
@ -101,7 +102,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="router.push('/dashboard/mesh')"
|
||||
@click.stop="router.push({ path: '/dashboard/mesh', query: { peer: p.pubkey || p.did || p.onion } })"
|
||||
class="px-2 py-1 text-xs rounded bg-orange-500/20 text-orange-400 hover:bg-orange-500/30 transition-colors shrink-0"
|
||||
>
|
||||
{{ t('web5.message') }}
|
||||
@ -110,7 +111,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Observers tab -->
|
||||
<div v-show="nodesContainerTab === 'observers'" class="space-y-2 flex-1 overflow-y-auto">
|
||||
<div v-show="nodesContainerTab === 'observers'" class="space-y-2 max-h-72 overflow-y-auto">
|
||||
<div v-if="loadingPeers && observers.length === 0" class="p-4 text-center text-white/60 text-sm">
|
||||
{{ t('common.loading') }}
|
||||
</div>
|
||||
@ -123,7 +124,8 @@
|
||||
<div
|
||||
v-for="p in observers"
|
||||
:key="p.pubkey"
|
||||
class="flex items-center justify-between p-3 bg-white/5 rounded-lg"
|
||||
@click="router.push({ path: '/dashboard/server/federation', query: { node: p.did || p.pubkey || p.onion } })"
|
||||
class="flex items-center justify-between p-3 bg-white/5 rounded-lg cursor-pointer hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<div class="w-2 h-2 rounded-full shrink-0" :class="peerReachable[p.onion] ? 'bg-green-400' : 'bg-amber-400'"></div>
|
||||
@ -139,7 +141,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Messages tab -->
|
||||
<div v-show="nodesContainerTab === 'messages'" class="space-y-2 flex-1 overflow-y-auto">
|
||||
<div v-show="nodesContainerTab === 'messages'" class="space-y-2 max-h-72 overflow-y-auto">
|
||||
<div v-if="loadingMessages && receivedMessages.length === 0" class="p-4 text-center text-white/60 text-sm">
|
||||
{{ t('common.loading') }}
|
||||
</div>
|
||||
@ -163,7 +165,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Requests tab -->
|
||||
<div v-show="nodesContainerTab === 'requests'" class="space-y-2 flex-1 overflow-y-auto">
|
||||
<div v-show="nodesContainerTab === 'requests'" class="space-y-2 max-h-72 overflow-y-auto">
|
||||
<div v-if="loadingRequests && connectionRequests.length === 0" class="p-4 text-center text-white/60 text-sm">
|
||||
{{ t('common.loading') }}
|
||||
</div>
|
||||
@ -406,6 +408,7 @@ function federationNodeToPeer(node: FederationNode): Peer {
|
||||
return {
|
||||
onion: node.onion,
|
||||
pubkey: node.pubkey,
|
||||
did: node.did,
|
||||
name: node.name || `Federation: ${node.did?.slice(0, 16) || 'node'}`,
|
||||
}
|
||||
}
|
||||
|
||||
@ -159,4 +159,4 @@ export interface DwnMessageEntry {
|
||||
|
||||
export type VisibilityLevel = 'hidden' | 'discoverable' | 'public'
|
||||
|
||||
export type Peer = { onion: string; pubkey: string; name?: string }
|
||||
export type Peer = { onion: string; pubkey: string; name?: string; did?: string }
|
||||
|
||||
@ -1,28 +1,34 @@
|
||||
{
|
||||
"version": "1.7.96-alpha",
|
||||
"release_date": "2026-06-15",
|
||||
"version": "1.7.98-alpha",
|
||||
"release_date": "2026-06-16",
|
||||
"changelog": [
|
||||
"The screen attached to your node now shows the normal Archipelago interface and your dashboard after you sign in, instead of a separate, stripped-down grid of app icons that could appear in its place. That extra screen has been removed so the attached display matches what you see everywhere else.",
|
||||
"On a brand-new node, the attached screen now walks through the same welcome and setup steps you'd see on a phone or laptop, and shows the normal sign-in screen once the node is set up \u2014 so the on-device display always matches the rest of the interface.",
|
||||
"When adding a FIPS network anchor, you can now choose whether it connects over TCP (for a public anchor reached across the internet) or UDP (for one on your local network), instead of it always assuming the local-network option.",
|
||||
"Behind the scenes, a new automated two-node test now exercises real node-to-node features \u2014 browsing another node's shared files and handling a removed node \u2014 against live nodes before each release, so node-to-node problems are caught earlier."
|
||||
"Apps that crash now recover on their own. Multi-part apps like Immich and IndeedHub could have one of their pieces stop and stay stopped until the whole node was rebooted; the node now checks every couple of minutes and restarts any crashed piece automatically (while still leaving apps you deliberately stopped alone).",
|
||||
"The on-screen kiosk display can no longer slow the whole node down. On machines without a graphics chip the kiosk browser could spin a CPU core at full tilt, starving everything else (including the wallet, which then timed out); it's now capped and uses lighter rendering on those machines.",
|
||||
"If an update download fails, you're taken back to the Download button to retry, instead of being stranded on an Install button for an update that didn't actually finish downloading.",
|
||||
"Your node's identity is clearer and always visible: Settings now shows your Node DID on every node (it previously only appeared if your browser had cached it) plus your node's npub, both with copy buttons. There's also a terminal tool to cryptographically prove all your node's keys come from your one seed phrase.",
|
||||
"The \"all nodes over Tor\" group chat sends quickly now \u2014 the \"sending\" spinner clears as soon as the reachable nodes have the message, instead of hanging on a slow or offline node.",
|
||||
"Message notifications now have a close button and open the relevant chat when tapped.",
|
||||
"The encrypted mesh transport (FIPS) turns itself on automatically after setup \u2014 no button to press \u2014 and connects to peers more reliably (it retries and keeps connections warm), so node-to-node features use the fast path more often instead of falling back to Tor.",
|
||||
"Your chat history with other nodes is saved reliably and now encrypted on disk, so it survives restarts and updates and can't be read from a stolen drive (only clearing chat removes it).",
|
||||
"Peer media shows a \"connecting\" loader before a video or audio file plays, and audio errors are accurate instead of blaming File Browser.",
|
||||
"The Fedimint app now displays with its proper styling, and the Connected Nodes screen stays compact \u2014 it shows a few nodes and scrolls, you can tap a node to jump to it in Federation, or tap Message to open its chat."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"name": "archipelago",
|
||||
"current_version": "1.7.96-alpha",
|
||||
"new_version": "1.7.96-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.96-alpha/archipelago",
|
||||
"sha256": "147672b4ebecd6042d4606a4fa2fdd60a5de8b9f518a7c0263ee2b6f49aed113",
|
||||
"size_bytes": 44358704
|
||||
"current_version": "1.7.98-alpha",
|
||||
"new_version": "1.7.98-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.98-alpha/archipelago",
|
||||
"sha256": "f8bac49964f9ce7d6a268876fa54c91f9792803c50c8ede1ca42a52d979f98d4",
|
||||
"size_bytes": 44707368
|
||||
},
|
||||
{
|
||||
"name": "archipelago-frontend-1.7.96-alpha.tar.gz",
|
||||
"current_version": "1.7.96-alpha",
|
||||
"new_version": "1.7.96-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.96-alpha/archipelago-frontend-1.7.96-alpha.tar.gz",
|
||||
"sha256": "c91c73abd078dd85f2515b553d60348ab8fe169f7ec00e9d4a6b4f131a96c2fa",
|
||||
"size_bytes": 184071997
|
||||
"name": "archipelago-frontend-1.7.98-alpha.tar.gz",
|
||||
"current_version": "1.7.98-alpha",
|
||||
"new_version": "1.7.98-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.98-alpha/archipelago-frontend-1.7.98-alpha.tar.gz",
|
||||
"sha256": "4952b12505852f46cd80e58ed73f14ecf50f47058b9e561296f467c512fca01d",
|
||||
"size_bytes": 177644716
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -1,28 +1,34 @@
|
||||
{
|
||||
"version": "1.7.96-alpha",
|
||||
"release_date": "2026-06-15",
|
||||
"version": "1.7.98-alpha",
|
||||
"release_date": "2026-06-16",
|
||||
"changelog": [
|
||||
"The screen attached to your node now shows the normal Archipelago interface and your dashboard after you sign in, instead of a separate, stripped-down grid of app icons that could appear in its place. That extra screen has been removed so the attached display matches what you see everywhere else.",
|
||||
"On a brand-new node, the attached screen now walks through the same welcome and setup steps you'd see on a phone or laptop, and shows the normal sign-in screen once the node is set up \u2014 so the on-device display always matches the rest of the interface.",
|
||||
"When adding a FIPS network anchor, you can now choose whether it connects over TCP (for a public anchor reached across the internet) or UDP (for one on your local network), instead of it always assuming the local-network option.",
|
||||
"Behind the scenes, a new automated two-node test now exercises real node-to-node features \u2014 browsing another node's shared files and handling a removed node \u2014 against live nodes before each release, so node-to-node problems are caught earlier."
|
||||
"Apps that crash now recover on their own. Multi-part apps like Immich and IndeedHub could have one of their pieces stop and stay stopped until the whole node was rebooted; the node now checks every couple of minutes and restarts any crashed piece automatically (while still leaving apps you deliberately stopped alone).",
|
||||
"The on-screen kiosk display can no longer slow the whole node down. On machines without a graphics chip the kiosk browser could spin a CPU core at full tilt, starving everything else (including the wallet, which then timed out); it's now capped and uses lighter rendering on those machines.",
|
||||
"If an update download fails, you're taken back to the Download button to retry, instead of being stranded on an Install button for an update that didn't actually finish downloading.",
|
||||
"Your node's identity is clearer and always visible: Settings now shows your Node DID on every node (it previously only appeared if your browser had cached it) plus your node's npub, both with copy buttons. There's also a terminal tool to cryptographically prove all your node's keys come from your one seed phrase.",
|
||||
"The \"all nodes over Tor\" group chat sends quickly now \u2014 the \"sending\" spinner clears as soon as the reachable nodes have the message, instead of hanging on a slow or offline node.",
|
||||
"Message notifications now have a close button and open the relevant chat when tapped.",
|
||||
"The encrypted mesh transport (FIPS) turns itself on automatically after setup \u2014 no button to press \u2014 and connects to peers more reliably (it retries and keeps connections warm), so node-to-node features use the fast path more often instead of falling back to Tor.",
|
||||
"Your chat history with other nodes is saved reliably and now encrypted on disk, so it survives restarts and updates and can't be read from a stolen drive (only clearing chat removes it).",
|
||||
"Peer media shows a \"connecting\" loader before a video or audio file plays, and audio errors are accurate instead of blaming File Browser.",
|
||||
"The Fedimint app now displays with its proper styling, and the Connected Nodes screen stays compact \u2014 it shows a few nodes and scrolls, you can tap a node to jump to it in Federation, or tap Message to open its chat."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"name": "archipelago",
|
||||
"current_version": "1.7.96-alpha",
|
||||
"new_version": "1.7.96-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.96-alpha/archipelago",
|
||||
"sha256": "147672b4ebecd6042d4606a4fa2fdd60a5de8b9f518a7c0263ee2b6f49aed113",
|
||||
"size_bytes": 44358704
|
||||
"current_version": "1.7.98-alpha",
|
||||
"new_version": "1.7.98-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.98-alpha/archipelago",
|
||||
"sha256": "f8bac49964f9ce7d6a268876fa54c91f9792803c50c8ede1ca42a52d979f98d4",
|
||||
"size_bytes": 44707368
|
||||
},
|
||||
{
|
||||
"name": "archipelago-frontend-1.7.96-alpha.tar.gz",
|
||||
"current_version": "1.7.96-alpha",
|
||||
"new_version": "1.7.96-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.96-alpha/archipelago-frontend-1.7.96-alpha.tar.gz",
|
||||
"sha256": "c91c73abd078dd85f2515b553d60348ab8fe169f7ec00e9d4a6b4f131a96c2fa",
|
||||
"size_bytes": 184071997
|
||||
"name": "archipelago-frontend-1.7.98-alpha.tar.gz",
|
||||
"current_version": "1.7.98-alpha",
|
||||
"new_version": "1.7.98-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.98-alpha/archipelago-frontend-1.7.98-alpha.tar.gz",
|
||||
"sha256": "4952b12505852f46cd80e58ed73f14ecf50f47058b9e561296f467c512fca01d",
|
||||
"size_bytes": 177644716
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
134
scripts/generate-app-catalog.sh
Executable file
@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env bash
|
||||
# Generate releases/app-catalog.json — the REMOTE per-app version catalog that
|
||||
# decouples app updates from the binary OTA (see
|
||||
# core/.../container/app_catalog.rs and docs/dht-distribution-design.md).
|
||||
#
|
||||
# Nodes fetch this file over HTTP from the OVH origin (same host as the OTA
|
||||
# manifest), compare each app's catalog version against the running container
|
||||
# tag, and light up the per-app "Update" button — no node release required.
|
||||
#
|
||||
# The app_id -> image-variable mapping below MIRRORS
|
||||
# core/archipelago/src/container/image_versions.rs (image_var_for_app +
|
||||
# containers_for_stack). image_versions.rs is the canonical mapping; keep this in
|
||||
# sync when you add an app there.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/generate-app-catalog.sh [output-path]
|
||||
# # then publish: push releases/app-catalog.json to the OVH gitea (raw URL).
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
OUT="${1:-$ROOT/releases/app-catalog.json}"
|
||||
|
||||
# Export every *_IMAGE var (and ARCHY_REGISTRY) so python can read them.
|
||||
set -a
|
||||
# shellcheck disable=SC1091
|
||||
source "$ROOT/scripts/image-versions.sh"
|
||||
set +a
|
||||
|
||||
UPDATED="$(date -u +%Y-%m-%d)" OUT="$OUT" python3 - <<'PY'
|
||||
import json, os
|
||||
|
||||
def img(var):
|
||||
v = os.environ.get(var)
|
||||
return v if v else None
|
||||
|
||||
def tag(image):
|
||||
# version = tag after the LAST colon that follows the last slash
|
||||
if not image:
|
||||
return None
|
||||
tail = image.rsplit('/', 1)[-1]
|
||||
return tail.rsplit(':', 1)[1] if ':' in tail else 'latest'
|
||||
|
||||
# Single-container apps: app_id -> primary image variable.
|
||||
SINGLE = {
|
||||
"bitcoin-knots": "BITCOIN_KNOTS_IMAGE",
|
||||
"lnd": "LND_IMAGE",
|
||||
"electrumx": "ELECTRUMX_IMAGE",
|
||||
"bitcoin-ui": "BITCOIN_UI_IMAGE",
|
||||
"lnd-ui": "LND_UI_IMAGE",
|
||||
"electrs-ui": "ELECTRS_UI_IMAGE",
|
||||
"homeassistant": "HOMEASSISTANT_IMAGE",
|
||||
"grafana": "GRAFANA_IMAGE",
|
||||
"uptime-kuma": "UPTIME_KUMA_IMAGE",
|
||||
"jellyfin": "JELLYFIN_IMAGE",
|
||||
"photoprism": "PHOTOPRISM_IMAGE",
|
||||
"ollama": "OLLAMA_IMAGE",
|
||||
"vaultwarden": "VAULTWARDEN_IMAGE",
|
||||
"nextcloud": "NEXTCLOUD_IMAGE",
|
||||
"searxng": "SEARXNG_IMAGE",
|
||||
"cryptpad": "CRYPTPAD_IMAGE",
|
||||
"filebrowser": "FILEBROWSER_IMAGE",
|
||||
"nginx-proxy-manager": "NPM_IMAGE",
|
||||
"portainer": "PORTAINER_IMAGE",
|
||||
"tailscale": "TAILSCALE_IMAGE",
|
||||
"fedimint": "FEDIMINT_IMAGE",
|
||||
"fedimint-gateway": "FEDIMINT_GATEWAY_IMAGE",
|
||||
"nostr-rs-relay": "NOSTR_RS_RELAY_IMAGE",
|
||||
"nostr-vpn": "NOSTR_VPN_IMAGE",
|
||||
"fips": "FIPS_IMAGE",
|
||||
"routstr": "ROUTSTR_IMAGE",
|
||||
"adguardhome": "ADGUARDHOME_IMAGE",
|
||||
}
|
||||
|
||||
# Stack apps: app_id -> {container_name: image variable}. The FIRST entry is the
|
||||
# primary (its version drives the badge); it is also emitted as `image`.
|
||||
STACK = {
|
||||
"indeedhub": {
|
||||
"indeedhub": "INDEEDHUB_IMAGE",
|
||||
"indeedhub-api": "INDEEDHUB_API_IMAGE",
|
||||
"indeedhub-ffmpeg": "INDEEDHUB_FFMPEG_IMAGE",
|
||||
},
|
||||
"immich": {
|
||||
"immich_server": "IMMICH_SERVER_IMAGE",
|
||||
"immich_postgres": "IMMICH_POSTGRES_IMAGE",
|
||||
"immich_redis": "REDIS_IMAGE",
|
||||
},
|
||||
"penpot": {
|
||||
"penpot-frontend": "PENPOT_FRONTEND_IMAGE",
|
||||
"penpot-backend": "PENPOT_BACKEND_IMAGE",
|
||||
"penpot-exporter": "PENPOT_EXPORTER_IMAGE",
|
||||
"penpot-postgres": "PENPOT_POSTGRES_IMAGE",
|
||||
"penpot-valkey": "PENPOT_VALKEY_IMAGE",
|
||||
},
|
||||
"mempool": {
|
||||
"archy-mempool-web": "MEMPOOL_WEB_IMAGE",
|
||||
"mempool-api": "MEMPOOL_BACKEND_IMAGE",
|
||||
"archy-mempool-db": "MARIADB_IMAGE",
|
||||
},
|
||||
"btcpay": {
|
||||
"btcpay-server": "BTCPAY_IMAGE",
|
||||
"archy-nbxplorer": "NBXPLORER_IMAGE",
|
||||
"archy-btcpay-db": "BTCPAY_POSTGRES_IMAGE",
|
||||
},
|
||||
}
|
||||
|
||||
apps = {}
|
||||
for app_id, var in SINGLE.items():
|
||||
image = img(var)
|
||||
if image:
|
||||
apps[app_id] = {"version": tag(image), "image": image}
|
||||
|
||||
for app_id, comps in STACK.items():
|
||||
images = {name: img(var) for name, var in comps.items() if img(var)}
|
||||
if not images:
|
||||
continue
|
||||
primary_name = next(iter(comps)) # first listed = primary
|
||||
primary_image = img(comps[primary_name])
|
||||
entry = {"version": tag(primary_image)}
|
||||
if primary_image:
|
||||
entry["image"] = primary_image
|
||||
entry["images"] = images
|
||||
apps[app_id] = entry
|
||||
|
||||
catalog = {
|
||||
"schema": 1,
|
||||
"updated": os.environ["UPDATED"],
|
||||
"apps": dict(sorted(apps.items())),
|
||||
}
|
||||
|
||||
with open(os.environ["OUT"], "w") as f:
|
||||
json.dump(catalog, f, indent=2)
|
||||
f.write("\n")
|
||||
print(f"Wrote {os.environ['OUT']} with {len(apps)} apps")
|
||||
PY
|
||||
129
scripts/verify-seed-derivation.py
Normal file
@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Cryptographically verify that a node's on-disk keys are deterministically
|
||||
derived from its onboarding seed, exactly as documented in core/archipelago/
|
||||
src/seed.rs:
|
||||
|
||||
BIP-39 mnemonic (24 words)
|
||||
-> PBKDF2-HMAC-SHA512(2048, salt="mnemonic") = 64-byte seed
|
||||
-> HKDF-SHA256(salt=None, IKM=seed, info=<domain>) = each 32-byte key
|
||||
"archipelago/node/ed25519/v1" -> node_key (=> Node DID)
|
||||
"archipelago/nostr-node/secp256k1/v1" -> nostr_secret (=> npub)
|
||||
"archipelago/fips/secp256k1/v1" -> fips_key (FIPS transport)
|
||||
|
||||
It compares each freshly-derived key against the bytes actually on disk under
|
||||
/var/lib/archipelago/identity/. A MATCH proves the on-disk key was derived from
|
||||
the seed (and nothing else). Also prints the resulting did:key for cross-check
|
||||
against Settings -> Node DID.
|
||||
|
||||
Usage (run on the node):
|
||||
sudo python3 verify-seed-derivation.py
|
||||
# paste the 24-word mnemonic when prompted (input is hidden, never logged)
|
||||
|
||||
Pure standard library — no third-party crypto packages required.
|
||||
"""
|
||||
import sys, os, hmac, hashlib, getpass, unicodedata
|
||||
|
||||
IDENT = "/var/lib/archipelago/identity"
|
||||
|
||||
DOMAINS = {
|
||||
"node_key (=> Node DID)": (b"archipelago/node/ed25519/v1", f"{IDENT}/node_key", "raw"),
|
||||
"nostr_secret (=> node npub)": (b"archipelago/nostr-node/secp256k1/v1", f"{IDENT}/nostr_secret", "nsec"),
|
||||
"fips_key (FIPS transport)": (b"archipelago/fips/secp256k1/v1", f"{IDENT}/fips_key", "nsec"),
|
||||
}
|
||||
|
||||
|
||||
def hkdf_sha256(ikm: bytes, info: bytes, length: int = 32) -> bytes:
|
||||
"""RFC 5869 HKDF-SHA256 with salt=None (== HashLen zero bytes)."""
|
||||
salt = b"\x00" * hashlib.sha256().digest_size
|
||||
prk = hmac.new(salt, ikm, hashlib.sha256).digest()
|
||||
okm, t, i = b"", b"", 1
|
||||
while len(okm) < length:
|
||||
t = hmac.new(prk, t + info + bytes([i]), hashlib.sha256).digest()
|
||||
okm += t
|
||||
i += 1
|
||||
return okm[:length]
|
||||
|
||||
|
||||
# --- minimal bech32 decode (BIP-173) to recover the 32-byte secret from nsec ---
|
||||
_B32 = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
||||
|
||||
|
||||
def _bech32_decode_data(s: str) -> bytes:
|
||||
s = s.strip().lower()
|
||||
pos = s.rfind("1")
|
||||
data = [_B32.index(c) for c in s[pos + 1:]]
|
||||
data = data[:-6] # drop 6-char checksum
|
||||
# convert 5-bit groups -> 8-bit bytes
|
||||
acc = bits = 0
|
||||
out = bytearray()
|
||||
for v in data:
|
||||
acc = (acc << 5) | v
|
||||
bits += 5
|
||||
if bits >= 8:
|
||||
bits -= 8
|
||||
out.append((acc >> bits) & 0xFF)
|
||||
return bytes(out)
|
||||
|
||||
|
||||
# --- minimal base58btc + multicodec to render did:key from the ed25519 pubkey ---
|
||||
_B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
|
||||
|
||||
|
||||
def _b58(b: bytes) -> str:
|
||||
n = int.from_bytes(b, "big")
|
||||
s = ""
|
||||
while n:
|
||||
n, r = divmod(n, 58)
|
||||
s = _B58[r] + s
|
||||
return "1" * (len(b) - len(b.lstrip(b"\x00"))) + s
|
||||
|
||||
|
||||
def did_key_from_ed25519_pub(pub: bytes) -> str:
|
||||
return "did:key:z" + _b58(b"\xed\x01" + pub) # 0xed01 = ed25519-pub multicodec
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not os.path.isdir(IDENT):
|
||||
print(f"!! {IDENT} not found — run this on a node.")
|
||||
return 2
|
||||
mnemonic = getpass.getpass("Paste the node's 24-word mnemonic (hidden): ").strip()
|
||||
words = mnemonic.split()
|
||||
if len(words) != 24:
|
||||
print(f"!! expected 24 words, got {len(words)}")
|
||||
return 2
|
||||
# BIP-39: seed = PBKDF2-HMAC-SHA512(NFKD(mnemonic), "mnemonic"+passphrase, 2048, 64)
|
||||
norm = unicodedata.normalize("NFKD", " ".join(words)).encode("utf-8")
|
||||
seed = hashlib.pbkdf2_hmac("sha512", norm, b"mnemonic", 2048, 64)
|
||||
|
||||
all_ok = True
|
||||
for name, (info, path, fmt) in DOMAINS.items():
|
||||
derived = hkdf_sha256(seed, info, 32)
|
||||
try:
|
||||
raw = open(path, "rb").read()
|
||||
disk = raw if fmt == "raw" else _bech32_decode_data(raw.decode().strip())
|
||||
disk = disk[:32]
|
||||
except Exception as e:
|
||||
print(f"[{name}] could not read {path}: {e}")
|
||||
all_ok = False
|
||||
continue
|
||||
ok = disk == derived
|
||||
all_ok &= ok
|
||||
print(f"[{'MATCH ✅' if ok else 'MISMATCH ❌'}] {name}")
|
||||
print(f" derived(seed): {derived.hex()}")
|
||||
print(f" on-disk : {disk.hex()}")
|
||||
|
||||
# Render the Node DID from node_key.pub for a visual cross-check vs the UI.
|
||||
try:
|
||||
pub = open(f"{IDENT}/node_key.pub", "rb").read()[:32]
|
||||
print(f"\nNode DID (from node_key.pub): {did_key_from_ed25519_pub(pub)}")
|
||||
print(" ^ should equal Settings -> Node DID")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print("\n==> ALL KEYS SEED-DERIVED ✅" if all_ok else "\n==> SOME KEYS DID NOT MATCH ❌")
|
||||
return 0 if all_ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@ -1,3 +1,21 @@
|
||||
# ▶▶ SESSION SAVE / RESUME (2026-06-16) — v1.7.97-alpha CUT, mid-rollout
|
||||
|
||||
**v1.7.97-alpha is BUILT + TAGGED LOCALLY but NOT yet published to the fleet.**
|
||||
- Release commit `47c16971` ("chore: release v1.7.97-alpha") + tag `v1.7.97-alpha` exist on LOCAL main only. NOT pushed to gitea-vps2. Fleet still sees 1.7.96-alpha.
|
||||
- Contents (14 fixes + image-opt): B5,B1,B2,B4,B14,B21,B3,B15,B7,B13,B12,B16,**B17**, B6-pruned-gate + lossless background-image optimization (bg-mesh PNG→JPEG).
|
||||
- Release artifacts staged: `releases/v1.7.97-alpha/{archipelago, archipelago-frontend-1.7.97-alpha.tar.gz}` + `/tmp/archipelago-frontend-1.7.97-alpha.tar.gz` (177MB, flat layout verified, optimized images baked in, no APK).
|
||||
- **Deployed (sideload, NOT fleet OTA):** .116 = on 1.7.97-alpha, healthy, B17 self-heal CONFIRMED (unit now has RequiresMountsFor, 36 containers survived restart). .198 = deploying (sideload binary+frontend).
|
||||
- **Backup binaries for rollback:** `/usr/local/bin/archipelago.1.7.96-alpha.bak` on .116 and .198.
|
||||
|
||||
**REMAINING (this session, user wants to do WITH them):**
|
||||
1. Finish .198 sideload; then **UI-confirm fixes together on .116/.198** + close passing Gitea issues (#8,#9,#10,#11,#12,#14,#19(code-only),#20,#21,#22,#23,#24,#29). Issue map below.
|
||||
2. **Publish to fleet:** `scripts/publish-release-assets.sh 1.7.97-alpha gitea-vps2` + `git push gitea-vps2 main + tag` (AFTER joint confirm — user's call).
|
||||
3. **Cut a fresh ISO** (bakes B13 nginx + B17 unit + all frontend). ISO builds run on a server (deploy-to-target / .228). Then test the ISO together.
|
||||
|
||||
⚠️ LESSON: never run the release binary to "check --version" — it has no such flag and BOOTS A FULL NODE (adopts containers, grabs mesh radio). Use `strings <bin> | grep version`. (Did this on .116; the instance exited on the :5678 port conflict, no harm.)
|
||||
|
||||
---
|
||||
|
||||
# ▶▶ SESSION SAVE / RESUME (2026-06-15)
|
||||
|
||||
**State:** v1.7.96-alpha SHIPPED. v1.7.97-alpha NOT cut yet — 10 fixes committed on **vps2 main** (`git remote: gitea-vps2`), nothing on the fleet yet. Validate on .116/.198 + UI-confirm BEFORE cutting .97.
|
||||
@ -8,11 +26,10 @@ cd ~/Projects/archy && git fetch gitea-vps2 && git checkout main && git reset --
|
||||
```
|
||||
Then continue from "IN PROGRESS" below.
|
||||
|
||||
**Committed & ready for .97 (vps2 main):** B5 (LND CORS, verified .116/.198/.103), B1, B2, B4, B14, B21, B3 (incl. /api/peer-content nginx via bootstrap), B15, B7, **B13 (fedimint CSS self-heal — main conf + HTTPS snippet, verified .198 both paths app-icon 404→200)**, **B12 (mempool bitcoin-host detect across 3 render paths — unit-tested; live bitcoin-core validation pending)**. B6 pruned-gate already live. = 12 fixes.
|
||||
**Committed & ready for .97 (vps2 main):** B5 (LND CORS, verified .116/.198/.103), B1, B2, B4, B14, B21, B3 (incl. /api/peer-content nginx via bootstrap), B15, B7, **B13 (fedimint CSS self-heal — main conf + HTTPS snippet, verified .198 both paths app-icon 404→200)**, **B12 (mempool bitcoin-host detect across 3 render paths — unit-tested; live bitcoin-core validation pending)**, **B16 (bitcoin sync tile retain/Updating… — unit-tested 6/6, commit 83dbd25c)**. B6 pruned-gate already live. = 13 fixes. PLUS **image-optimization** (commit 386d4bfc — all bg images losslessly optimized, bg-mesh PNG→JPEG; user asked to include it in the .97 release).
|
||||
|
||||
**IN PROGRESS — pick up at B16.** B13 + B12 DONE (committed; see their entries below for full detail). REMAINING:
|
||||
1. **B16** (bitcoin status retain — needs a UI test).
|
||||
2. Then **B6** no-node-present half, **B12b** (sibling bitcoin-host hardcodes: LND/BTCPay/electrumx/fedimint + mempool dep declaration — reuse `{{BITCOIN_HOST}}`; needs validation, esp. LND/fedimint), **B14b** (FIPS reachability depth), **B22/B23** (peer download + group chat — need live repro), B9/B10/B11/B17/B18/B19, B8 (low), B20 (mesh-headers feature).
|
||||
**IN PROGRESS — B16 DONE (commit 83dbd25c). Pick up at B6 no-node-present half.** B13 + B12 + B16 DONE (committed; see entries below). REMAINING:
|
||||
1. **B6** no-node-present half, **B12b** (sibling bitcoin-host hardcodes: LND/BTCPay/electrumx/fedimint + mempool dep declaration — reuse `{{BITCOIN_HOST}}`; needs validation, esp. LND/fedimint), **B14b** (FIPS reachability depth), **B22/B23** (peer download + group chat — need live repro), B9/B10/B11/B17/B18/B19, B8 (low), B20 (mesh-headers feature).
|
||||
3. **Loose end:** 4 pre-existing prod_orchestrator test failures (generated-files/data_uid fixtures use disallowed tempdir volume sources) — see B12 NOTE; separate small fix.
|
||||
|
||||
Note: .198 is running a sideloaded B13-era .97-dev binary (md5 4c83803d). The B12 binary was built (`core/target/release/archipelago`) but NOT sideloaded (mempool isn't on .198; .198 is Knots so B12 is a no-op there). Reflashing/OTA replaces the dev binary.
|
||||
@ -132,11 +149,21 @@ NOTE: self-healed snippet is functionally correct but NOT byte-identical to the
|
||||
### B15 — Bitcoin UI sync progress lags — FIXED (Home.vue poll 30s→10s). UI-confirm.
|
||||
Bitcoin UI doesn't update its sync progress fast enough even though the console clearly already has the block-height data. Likely a polling-interval / reactive-update gap between the status source and the UI.
|
||||
|
||||
### B16 — Bitcoin sync status vanishes — DEFERRED (homeStatus.ts already partly retains last value; safe fix needs UI test to avoid showing stale-as-live; plan in findings)
|
||||
The bitcoin sync status in the Home > System container disappears when it should persist/cache and show an "updating" state. Related to B15 (Bitcoin UI sync lag). Likely the status component clears on empty/transitional poll instead of retaining last-known + showing updating.
|
||||
### B16 — Bitcoin sync status vanishes — FIXED + UNIT-TESTED (commit 83dbd25c). UI-confirm.
|
||||
The bitcoin sync status in the Home > System container disappears when it should persist/cache and show an "updating" state. Related to B15 (Bitcoin UI sync lag). Root cause: the tile is gated `v-if="stats.bitcoinAvailable===true"` (HomeSystemCard.vue:60); a transient `bitcoin.getinfo` failure (RPC busy during heavy IBD, or a route-change/scan where the packages map is momentarily empty) could blank it.
|
||||
FIX (commit 83dbd25c): added a `bitcoinStale` flag to homeStatus.ts —
|
||||
- getinfo fails while the bitcoin container is **Running**, OR package data is momentarily **absent** → retain last-known value + `bitcoinStale=true` (tile stays, renders **"Updating…"** instead of a frozen figure shown as live).
|
||||
- container authoritatively **Stopped/Exited** → `bitcoinAvailable=false`, `stale=false` (no stale-as-live — genuinely down is reflected).
|
||||
- first-ever poll times out but container Running (syncing node) → show the tile as updating rather than staying hidden.
|
||||
Wired `bitcoinStale` through Home.vue `systemStats` → HomeSystemCard prop; card shows "Updating…" (dimmed) when stale.
|
||||
**Harness:** `neode-ui/src/stores/__tests__/homeStatus.test.ts` (6 cases) — RED before fix (5/6 fail), GREEN after (6/6). `vue-tsc --noEmit` exit 0. Full vitest suite: only pre-existing AppIconGrid cross-test teardown flake (passes 7/7 standalone; not my change). UI-confirm on .116/.198 still recommended (hard to trigger transient failure on demand — unit test is the authoritative harness here).
|
||||
|
||||
### B17 — archipelago.service flaps on boot before starting — TODO
|
||||
On some boots, `[FAILED] Failed to start archipelago.service - Archipelago Backend` prints ~20 times over ~5 min before it finally starts properly. Likely a startup dependency/timing race (DB lock, port bind, crash-recovery, or a dependency not ready) causing systemd restart loop until a precondition is met. Check service Restart=/RestartSec, ExecStartPre gates, and what the early failures log. May tie to B16/crash-recovery.
|
||||
### B17 — archipelago.service flaps on boot before starting — FIXED + VERIFIED on .198 (commit 34b1fdc1)
|
||||
On some boots, `[FAILED] Failed to start archipelago.service` printed ~20× over ~5 min before starting. ROOT CAUSE (proven live on .198): on production nodes `/var/lib/archipelago` is a **separate `/dev/mapper/archipelago-data` ext4 volume** (systemd unit `var-lib-archipelago.mount`), and podman's **graphroot=`/var/lib/archipelago/containers/storage`** lives on it too. The unit ordered only `After=network-online.target` — NO mount dependency — so on cold boots the service (and its `ExecStartPre`) could start BEFORE the volume mounted, write to the bare mountpoint on rootfs, fail every podman call, exit, and be restarted every 5s (`Restart=on-failure RestartSec=5`) until the mount appeared. Smoking gun in .198's journal: `var-lib-archipelago.mount: Directory /var/lib/archipelago to mount over is not empty, mounting anyway` — the service had written there pre-mount. Dev laptop .116 has the data dir on rootfs → never flaps (explains "on some boots"). Diagnostic: every node showed `banners == "Server listening"` (process always succeeds once it runs) ⇒ failure is systemd-level, not a Rust crash.
|
||||
FIX (commit 34b1fdc1): `RequiresMountsFor=/var/lib/archipelago` (adds `Requires=` + `After=` on the mount unit).
|
||||
- `image-recipe/configs/archipelago.service`: ships the directive on fresh ISOs.
|
||||
- `bootstrap::ensure_archipelago_mount_ordering()`: self-heals already-deployed nodes' installed `/etc/systemd/system/archipelago.service` + `daemon-reload` (boot-ordering only — effective next reboot; never restarts the running service). Idempotent; harmless on rootfs installs.
|
||||
VERIFIED on .198: applied directive → `systemctl show -p After` includes `var-lib-archipelago.mount`, `systemd-analyze verify` clean → rebooted: mount@07:35:22, archipelago banner@07:35:35 (13s AFTER mount), `banners=1 listening=1 failed_to_start=0` (zero flap), directive persisted. `cargo check` EXIT 0. NOTE: self-heal CODE (auto-patch on deployed nodes) still to be exercised with the built binary on .228 (directive was applied manually on .198); residual rootfs shadow files under the mountpoint are benign.
|
||||
|
||||
### B18 — Apps stop right after install (or become unstartable) — TODO
|
||||
Many apps install but immediately stop, requiring a manual Start — or become unstartable entirely. Likely the install→start handoff / reconciler doesn't bring them up (or starts then they exit). Related to B9 (IndeedHub stopping), B10 (Immich). Possibly linked to the cgroup-SIGKILL-on-archipelago.service-restart issue (feedback_no_systemctl_deploy_until_quadlet) — but NOTE: on .116 (Quadlet) containers survived a service restart cleanly, so the reconciler may be fine there; reproduce on the affected nodes. Check post-install start sequencing + boot_reconciler + container restart policy + cgroup placement.
|
||||
@ -224,3 +251,10 @@ All backlog bugs now mirrored as Gitea issues: B1→#8, B2→#9, B3→#10, B4→
|
||||
- **Discovered B14b** (FIPS reaches only ~4/15 peers; rest genuinely Tor) and **B21** (pill) during the block.
|
||||
- ⚠️ LESSON: a backgrounded build "completed" notification does NOT mean success — grep the EXIT code before committing (a broken commit reached main once; repaired by 1c6dc153; no release cut from it → fleet unaffected).
|
||||
- **NEXT: B3 (peer media streaming — big), then B14b (FIPS reachability), then app-specific (B6,B7,B9–B13,B15–B19).** None deployed to fleet yet — all on vps2 main awaiting the .97 release after full .116/.198 + UI verification.
|
||||
|
||||
## New backlog issues filed 2026-06-16 (this session)
|
||||
- #32 Tor chat: message stuck on spinner though peers received it (task #8)
|
||||
- #33 Message toast: click-to-open chat + close icon (task #9)
|
||||
- #34 Local UI images never rebuild on source change — orchestrator gap (task #7); blocks OTA of bitcoin-ui relay + fedimint CSS to existing fleet
|
||||
- #35 Paid 10% video previews unplayable — truncated MP4 (task #6)
|
||||
NOTE: bitcoin RPC relay UI + fedimint guardian CSS now LIVE on .116 (image rebuilds); .198 deploy in progress. Bitcoin app launches host-net UI at <node>:8334 (not /app/bitcoin-ui/ proxy).
|
||||
|
||||