Compare commits
@@ -1,5 +1,40 @@
|
||||
# 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.
|
||||
- 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 — 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 — browsing another node's shared files and handling a removed node — against live nodes before each release, so node-to-node problems are caught earlier.
|
||||
|
||||
## v1.7.95-alpha (2026-06-15)
|
||||
|
||||
- Browsing another node's shared files now works over the fast encrypted mesh. Opening a peer's cloud could fail with a generic "Operation failed" message because the request for their file list wasn't permitted over the mesh and came back as "not found" — and it never retried over Tor. The mesh now serves the file list directly, and if a peer can't answer over the mesh the node automatically falls back to Tor instead of giving up.
|
||||
|
||||
@@ -8,6 +8,12 @@ app:
|
||||
image: git.tx1138.com/lfg2025/mempool-backend:v3.0.0
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
# CORE_RPC_HOST must follow the node's actual Bitcoin container — Knots or
|
||||
# Core — resolved at apply time from host facts (B12). Hardcoding either
|
||||
# breaks mempool's RPC connection on the other.
|
||||
derived_env:
|
||||
- key: CORE_RPC_HOST
|
||||
template: "{{BITCOIN_HOST}}"
|
||||
secret_env:
|
||||
- key: CORE_RPC_PASSWORD
|
||||
secret_file: bitcoin-rpc-password
|
||||
@@ -47,7 +53,6 @@ app:
|
||||
- ELECTRUM_HOST=electrumx
|
||||
- ELECTRUM_PORT=50001
|
||||
- ELECTRUM_TLS_ENABLED=false
|
||||
- CORE_RPC_HOST=bitcoin-knots
|
||||
- CORE_RPC_PORT=8332
|
||||
- CORE_RPC_USERNAME=archipelago
|
||||
- DATABASE_ENABLED=true
|
||||
|
||||
@@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.7.94-alpha"
|
||||
version = "1.7.97-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.7.95-alpha"
|
||||
version = "1.7.98-alpha"
|
||||
edition = "2021"
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
authors = ["Archipelago Team"]
|
||||
|
||||
@@ -202,6 +202,27 @@ impl ApiHandler {
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// A 401 that still carries CORS headers, for endpoints fetched
|
||||
/// cross-origin by same-node app UIs (e.g. the LND wallet UI on its own
|
||||
/// port). Without the ACAO header the browser surfaces an opaque CORS
|
||||
/// error instead of the 401, so the app can't tell it just needs auth.
|
||||
/// `origin` is the already-validated reflect value from `app_cors_origin`
|
||||
/// (empty string when the origin isn't allowed → no CORS header added).
|
||||
fn unauthorized_cors(origin: &str) -> Response<hyper::Body> {
|
||||
let body = serde_json::json!({ "error": "Unauthorized" });
|
||||
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
|
||||
let mut builder = Response::builder()
|
||||
.status(StatusCode::UNAUTHORIZED)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Vary", "Origin");
|
||||
if !origin.is_empty() {
|
||||
builder = builder
|
||||
.header("Access-Control-Allow-Origin", origin)
|
||||
.header("Access-Control-Allow-Credentials", "true");
|
||||
}
|
||||
builder.body(hyper::Body::from(body_bytes)).unwrap()
|
||||
}
|
||||
|
||||
/// Allowed CORS origins derived from the config host IP.
|
||||
fn allowed_origins(&self) -> Vec<String> {
|
||||
let mut origins = vec![
|
||||
@@ -501,12 +522,25 @@ impl ApiHandler {
|
||||
Self::handle_container_logs_http(self.rpc_handler.clone(), path, &origin).await
|
||||
}
|
||||
|
||||
// LND proxy — requires session
|
||||
(Method::GET, path) if path.starts_with("/proxy/lnd/") => {
|
||||
// Peer content streaming proxy — Range-streams a peer's media file
|
||||
// so <video>/<audio> can seek/play (B3). Same-origin, session-gated.
|
||||
(Method::GET, p) if p.starts_with("/api/peer-content/") => {
|
||||
if !self.is_authenticated(&headers).await {
|
||||
return Ok(Self::unauthorized());
|
||||
}
|
||||
self.handle_peer_content_stream(p, &headers).await
|
||||
}
|
||||
|
||||
// LND proxy — requires session. The LND wallet UI calls this
|
||||
// cross-origin from its own app port, so even the 401 must carry
|
||||
// CORS headers; otherwise the browser reports a bare CORS failure
|
||||
// ("No 'Access-Control-Allow-Origin' header") instead of a
|
||||
// readable 401 the UI can act on.
|
||||
(Method::GET, path) if path.starts_with("/proxy/lnd/") => {
|
||||
let origin = self.app_cors_origin(&headers);
|
||||
if !self.is_authenticated(&headers).await {
|
||||
return Ok(Self::unauthorized_cors(&origin));
|
||||
}
|
||||
Self::handle_lnd_proxy(self.rpc_handler.clone(), path, &origin).await
|
||||
}
|
||||
|
||||
|
||||
@@ -185,4 +185,76 @@ impl ApiHandler {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Range-streaming proxy for a peer's content file (B3). The browser's
|
||||
/// `<video>`/`<audio>` element makes Range requests; we forward the Range
|
||||
/// header to the peer's `/content/<id>` (which already returns 206 Partial
|
||||
/// Content) and pass the bytes + Content-Range/Content-Type straight back.
|
||||
/// This replaces the old path of downloading the whole file as base64 into
|
||||
/// a non-seekable Blob URL, which broke playback/seeking for video and
|
||||
/// large audio. Same-origin + session-authenticated (checked by caller).
|
||||
/// Path: `/api/peer-content/<onion>/<content_id>`.
|
||||
pub(super) async fn handle_peer_content_stream(
|
||||
&self,
|
||||
path: &str,
|
||||
headers: &hyper::HeaderMap,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
let bad = |msg: &str| {
|
||||
Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"application/json",
|
||||
hyper::Body::from(serde_json::json!({ "error": msg }).to_string()),
|
||||
))
|
||||
};
|
||||
let rest = path.strip_prefix("/api/peer-content/").unwrap_or("");
|
||||
let (onion, content_id) = match rest.split_once('/') {
|
||||
Some((o, c)) if !o.is_empty() && !c.is_empty() => (o, c),
|
||||
_ => return bad("expected /api/peer-content/<onion>/<content_id>"),
|
||||
};
|
||||
// Validate to prevent SSRF / path traversal.
|
||||
let onion_norm = onion.trim_end_matches(".onion");
|
||||
let onion_ok = onion_norm.len() == 56
|
||||
&& onion_norm
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit());
|
||||
let id_ok = !content_id.contains("..")
|
||||
&& content_id
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'));
|
||||
if !onion_ok || !id_ok {
|
||||
return bad("invalid onion or content id");
|
||||
}
|
||||
|
||||
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)
|
||||
.timeout(std::time::Duration::from_secs(60));
|
||||
if let Some(r) = headers.get("range").and_then(|v| v.to_str().ok()) {
|
||||
req = req.header("Range", r.to_string());
|
||||
}
|
||||
match req.send_get().await {
|
||||
Ok((resp, _transport)) => {
|
||||
let status = resp.status().as_u16();
|
||||
let rh = resp.headers().clone();
|
||||
let bytes = resp.bytes().await.unwrap_or_default();
|
||||
let mut builder = Response::builder()
|
||||
.status(status)
|
||||
.header("Accept-Ranges", "bytes");
|
||||
for h in ["content-type", "content-range", "content-length"] {
|
||||
if let Some(v) = rh.get(h).and_then(|v| v.to_str().ok()) {
|
||||
builder = builder.header(h, v);
|
||||
}
|
||||
}
|
||||
Ok(builder
|
||||
.body(hyper::Body::from(bytes))
|
||||
.unwrap_or_else(|_| Response::new(hyper::Body::empty())))
|
||||
}
|
||||
Err(e) => Ok(build_response(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"application/json",
|
||||
hyper::Body::from(serde_json::json!({ "error": e.to_string() }).to_string()),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,7 +234,7 @@ impl RpcHandler {
|
||||
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||
|
||||
let path = format!("/content/{}", content_id);
|
||||
let (response, _transport) =
|
||||
let (response, transport) =
|
||||
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
|
||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||
.header("X-Federation-DID", local_did)
|
||||
@@ -242,6 +242,15 @@ impl RpcHandler {
|
||||
.send_get()
|
||||
.await
|
||||
.context("Failed to connect to peer")?;
|
||||
// Record which transport actually reached the peer (B14) so the UI
|
||||
// reflects FIPS vs Tor truthfully instead of always showing Tor/none.
|
||||
let _ = crate::federation::record_peer_transport(
|
||||
&self.config.data_dir,
|
||||
None,
|
||||
Some(onion),
|
||||
&transport.to_string(),
|
||||
)
|
||||
.await;
|
||||
|
||||
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
|
||||
let body: serde_json::Value = response.json().await.unwrap_or_default();
|
||||
@@ -294,13 +303,21 @@ impl RpcHandler {
|
||||
fips_npub.is_some()
|
||||
);
|
||||
|
||||
let (response, _transport) =
|
||||
let (response, transport) =
|
||||
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, "/content")
|
||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.send_get()
|
||||
.await
|
||||
.context("Failed to connect to peer")?;
|
||||
// Record which transport actually reached the peer (B14).
|
||||
let _ = crate::federation::record_peer_transport(
|
||||
&self.config.data_dir,
|
||||
None,
|
||||
Some(onion),
|
||||
&transport.to_string(),
|
||||
)
|
||||
.await;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow::anyhow!(
|
||||
@@ -309,11 +326,20 @@ impl RpcHandler {
|
||||
));
|
||||
}
|
||||
|
||||
let body: serde_json::Value = response
|
||||
let mut body: serde_json::Value = response
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse peer catalog")?;
|
||||
|
||||
// Surface the transport that actually reached the peer so the cloud
|
||||
// browse UI can show a FIPS/Tor pill instead of always assuming Tor (B21).
|
||||
if let Some(obj) = body.as_object_mut() {
|
||||
obj.insert(
|
||||
"transport".to_string(),
|
||||
serde_json::Value::String(transport.to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
@@ -353,7 +379,7 @@ impl RpcHandler {
|
||||
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||
|
||||
let path = format!("/content/{}", content_id);
|
||||
let (response, _transport) =
|
||||
let (response, transport) =
|
||||
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
|
||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||
.header("X-Federation-DID", local_did)
|
||||
@@ -362,6 +388,14 @@ impl RpcHandler {
|
||||
.send_get()
|
||||
.await
|
||||
.context("Failed to connect to peer")?;
|
||||
// Record which transport actually reached the peer (B14).
|
||||
let _ = crate::federation::record_peer_transport(
|
||||
&self.config.data_dir,
|
||||
None,
|
||||
Some(onion),
|
||||
&transport.to_string(),
|
||||
)
|
||||
.await;
|
||||
|
||||
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
|
||||
// Payment was rejected — token is spent but content not received
|
||||
@@ -418,13 +452,21 @@ impl RpcHandler {
|
||||
fips_npub.is_some()
|
||||
);
|
||||
|
||||
let (response, _transport) =
|
||||
let (response, transport) =
|
||||
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
|
||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.send_get()
|
||||
.await
|
||||
.context("Failed to connect to peer for preview")?;
|
||||
// Record which transport actually reached the peer (B14).
|
||||
let _ = crate::federation::record_peer_transport(
|
||||
&self.config.data_dir,
|
||||
None,
|
||||
Some(onion),
|
||||
&transport.to_string(),
|
||||
)
|
||||
.await;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow::anyhow!(
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
|
||||
|
||||
@@ -752,27 +752,33 @@ pub(super) async fn get_app_config(
|
||||
None,
|
||||
None,
|
||||
),
|
||||
"mempool-api" => (
|
||||
vec!["8999:8999".to_string()],
|
||||
vec!["/var/lib/archipelago/mempool:/data".to_string()],
|
||||
vec![
|
||||
"MEMPOOL_BACKEND=electrum".to_string(),
|
||||
"ELECTRUM_HOST=electrumx".to_string(),
|
||||
"ELECTRUM_PORT=50001".to_string(),
|
||||
"ELECTRUM_TLS_ENABLED=false".to_string(),
|
||||
"CORE_RPC_HOST=bitcoin-knots".to_string(),
|
||||
"CORE_RPC_PORT=8332".to_string(),
|
||||
"CORE_RPC_USERNAME=archipelago".to_string(),
|
||||
format!("CORE_RPC_PASSWORD={}", rpc_pass),
|
||||
"DATABASE_ENABLED=true".to_string(),
|
||||
"DATABASE_HOST=archy-mempool-db".to_string(),
|
||||
"DATABASE_DATABASE=mempool".to_string(),
|
||||
"DATABASE_USERNAME=mempool".to_string(),
|
||||
format!("DATABASE_PASSWORD={}", read_secret("mempool-db-password", "mempoolpass")),
|
||||
],
|
||||
None,
|
||||
None,
|
||||
),
|
||||
"mempool-api" => {
|
||||
// CORE_RPC_HOST must resolve to the actual Bitcoin node container —
|
||||
// bitcoin-knots OR bitcoin-core — else mempool-api can't reach RPC
|
||||
// on a Core node (B12). Falls back to bitcoin-knots if undetected.
|
||||
let bitcoin_rpc_host = super::dependencies::detect_bitcoin_rpc_host().await;
|
||||
(
|
||||
vec!["8999:8999".to_string()],
|
||||
vec!["/var/lib/archipelago/mempool:/data".to_string()],
|
||||
vec![
|
||||
"MEMPOOL_BACKEND=electrum".to_string(),
|
||||
"ELECTRUM_HOST=electrumx".to_string(),
|
||||
"ELECTRUM_PORT=50001".to_string(),
|
||||
"ELECTRUM_TLS_ENABLED=false".to_string(),
|
||||
format!("CORE_RPC_HOST={}", bitcoin_rpc_host),
|
||||
"CORE_RPC_PORT=8332".to_string(),
|
||||
"CORE_RPC_USERNAME=archipelago".to_string(),
|
||||
format!("CORE_RPC_PASSWORD={}", rpc_pass),
|
||||
"DATABASE_ENABLED=true".to_string(),
|
||||
"DATABASE_HOST=archy-mempool-db".to_string(),
|
||||
"DATABASE_DATABASE=mempool".to_string(),
|
||||
"DATABASE_USERNAME=mempool".to_string(),
|
||||
format!("DATABASE_PASSWORD={}", read_secret("mempool-db-password", "mempoolpass")),
|
||||
],
|
||||
None,
|
||||
None,
|
||||
)
|
||||
}
|
||||
"electrumx" | "mempool-electrs" | "electrs" => {
|
||||
(
|
||||
vec!["50001:50001".to_string()],
|
||||
|
||||
@@ -84,6 +84,78 @@ pub(super) async fn detect_running_deps() -> Result<RunningDeps> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Detect the container name of the running Bitcoin node so dependent stacks
|
||||
/// (mempool) can point CORE_RPC_HOST at the right host. Bitcoin Knots and Bitcoin
|
||||
/// Core are both reachable on archy-net by their container name — only the name
|
||||
/// differs (`bitcoin-knots` vs `bitcoin-core`), so hardcoding one breaks the
|
||||
/// other. Returns the first running BITCOIN_NAMES match; falls back to the
|
||||
/// default `bitcoin-knots` if none is detected (callers gate on has_bitcoin).
|
||||
pub(super) async fn detect_bitcoin_rpc_host() -> String {
|
||||
let out = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(15),
|
||||
tokio::process::Command::new("podman")
|
||||
.args(["ps", "--format", "{{.Names}}"])
|
||||
.output(),
|
||||
)
|
||||
.await;
|
||||
if let Ok(Ok(o)) = out {
|
||||
if o.status.success() {
|
||||
let running = String::from_utf8_lossy(&o.stdout);
|
||||
if let Some(name) = pick_bitcoin_host(&running) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
}
|
||||
"bitcoin-knots".to_string()
|
||||
}
|
||||
|
||||
/// Pure host-selection step of [`detect_bitcoin_rpc_host`], split out so it can
|
||||
/// be unit-tested without a podman runtime. Returns the first `podman ps` line
|
||||
/// whose trimmed name is one of [`BITCOIN_NAMES`]. (The Quadlet orchestrator
|
||||
/// mirrors this in `prod_orchestrator::bitcoin_host`.)
|
||||
fn pick_bitcoin_host(podman_names: &str) -> Option<String> {
|
||||
podman_names
|
||||
.lines()
|
||||
.map(|l| l.trim())
|
||||
.find(|name| BITCOIN_NAMES.contains(name))
|
||||
.map(|name| name.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod bitcoin_host_tests {
|
||||
use super::pick_bitcoin_host;
|
||||
|
||||
#[test]
|
||||
fn picks_knots() {
|
||||
let ps = "electrumx\nbitcoin-knots\narchy-mempool-db\n";
|
||||
assert_eq!(pick_bitcoin_host(ps).as_deref(), Some("bitcoin-knots"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picks_core() {
|
||||
let ps = "lnd\nbitcoin-core\nelectrumx\n";
|
||||
assert_eq!(pick_bitcoin_host(ps).as_deref(), Some("bitcoin-core"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picks_plain_bitcoin() {
|
||||
assert_eq!(pick_bitcoin_host("bitcoin\n").as_deref(), Some("bitcoin"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_when_no_bitcoin_node() {
|
||||
let ps = "electrumx\nlnd\narchy-mempool-db\n";
|
||||
assert_eq!(pick_bitcoin_host(ps), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_substring_matches() {
|
||||
// A companion UI container must NOT be mistaken for the node itself.
|
||||
let ps = "archy-bitcoin-ui\nbitcoin-knots-foo\n";
|
||||
assert_eq!(pick_bitcoin_host(ps), None);
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify that required dependency services are running before installing an app.
|
||||
/// Returns an error with a user-friendly message if dependencies are missing.
|
||||
pub(super) fn check_install_deps(package_id: &str, deps: &RunningDeps) -> Result<()> {
|
||||
|
||||
@@ -1152,6 +1152,9 @@ impl RpcHandler {
|
||||
let deps = super::dependencies::detect_running_deps().await?;
|
||||
super::dependencies::check_install_deps("mempool", &deps)?;
|
||||
let (_, rpc_pass) = crate::bitcoin_rpc::bitcoin_rpc_credentials().await;
|
||||
// CORE_RPC_HOST must match the actual Bitcoin node container name —
|
||||
// bitcoin-knots OR bitcoin-core — else mempool-api can't reach RPC (B12).
|
||||
let bitcoin_rpc_host = super::dependencies::detect_bitcoin_rpc_host().await;
|
||||
|
||||
install_log("INSTALL START: mempool (stack: mariadb + mempool-api + mempool-web)").await;
|
||||
|
||||
@@ -1275,7 +1278,7 @@ impl RpcHandler {
|
||||
"-e",
|
||||
"ELECTRUM_TLS_ENABLED=false",
|
||||
"-e",
|
||||
"CORE_RPC_HOST=bitcoin-knots",
|
||||
&format!("CORE_RPC_HOST={}", bitcoin_rpc_host),
|
||||
"-e",
|
||||
"CORE_RPC_PORT=8332",
|
||||
"-e",
|
||||
|
||||
@@ -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,8 +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 RUNTIME_ASSETS_DIR: &str = "/opt/archipelago/web-ui/archipelago-runtime";
|
||||
|
||||
/// Inserted into every server block of the nginx config that lacks the
|
||||
@@ -48,6 +62,34 @@ const NGINX_BITCOIN_STATUS_BLOCK: &str = "\n location /bitcoin-status {\n
|
||||
/// sync with the canonical block in image-recipe/configs/nginx-archipelago.conf.
|
||||
const NGINX_LND_PROXY_BLOCK: &str = "\n # LND REST proxy — backend handles auth + CORS\n location /proxy/lnd/ {\n proxy_pass http://127.0.0.1:5678;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header Cookie $http_cookie;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_connect_timeout 10s;\n proxy_read_timeout 10s;\n proxy_send_timeout 5s;\n error_page 502 503 = @backend_unavailable;\n error_page 504 = @backend_timeout;\n }\n";
|
||||
|
||||
/// Inserted into every server block lacking the peer-content streaming proxy.
|
||||
/// Without it, the browser's `<video>`/`<audio>` Range requests to
|
||||
/// `/api/peer-content/*` fall through to the SPA index.html (HTML, no Range)
|
||||
/// and peer media won't play (B3). Forwards Cookie (session auth) + Range and
|
||||
/// disables buffering so streaming works. Kept in sync with the canonical
|
||||
/// block in image-recipe/configs/nginx-archipelago.conf.
|
||||
const NGINX_PEER_CONTENT_BLOCK: &str = "\n # Peer content streaming proxy (B3) — Range-streams a peer's media file\n location /api/peer-content/ {\n proxy_pass http://127.0.0.1:5678;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header Cookie $http_cookie;\n proxy_set_header Range $http_range;\n proxy_buffering off;\n proxy_connect_timeout 10s;\n proxy_read_timeout 120s;\n error_page 502 503 = @backend_unavailable;\n error_page 504 = @backend_timeout;\n }\n";
|
||||
|
||||
/// B13 — Fedimint UI asset rewrite. Pre-fix nodes proxy /app/fedimint/ with only
|
||||
/// the nostr-provider injection (`sub_filter_once on`), so the UI's root-rooted
|
||||
/// CSS/JS asset URLs (href="/…", url("/…")) miss the proxy and load the SPA shell
|
||||
/// → unstyled UI. We swap that single sub_filter for the full rewrite set that
|
||||
/// reroots every asset URL under /app/fedimint/. NEW matches the canonical block
|
||||
/// in image-recipe/configs/nginx-archipelago.conf byte-for-byte so self-healed
|
||||
/// nodes converge to the same config fresh ISOs ship with.
|
||||
const NGINX_FEDIMINT_OLD: &str = " sub_filter_once on;\n sub_filter '</head>' '<script src=\"/nostr-provider.js\"></script></head>';\n }\n location /app/fedimint-gateway/ {";
|
||||
const NGINX_FEDIMINT_NEW: &str = " sub_filter_types text/css application/javascript application/json;\n sub_filter_once off;\n sub_filter 'href=\"/' 'href=\"/app/fedimint/';\n sub_filter 'src=\"/' 'src=\"/app/fedimint/';\n sub_filter \"href='/\" \"href='/app/fedimint/\";\n sub_filter \"src='/\" \"src='/app/fedimint/\";\n sub_filter 'url(\"/' 'url(\"/app/fedimint/';\n sub_filter \"url('/\" \"url('/app/fedimint/\";\n sub_filter '</head>' '<script src=\"/nostr-provider.js\"></script></head>';\n }\n location /app/fedimint-gateway/ {";
|
||||
|
||||
/// B13 Style B — the HTTPS app-proxy snippet's fedimint block has NO sub_filter
|
||||
/// at all (older than the main conf's), and the directive that follows it varies
|
||||
/// per node (fedimint-gateway vs tailscale), so a full-block match is unreliable.
|
||||
/// Instead we anchor on the unique :8175 proxy_pass (fedimint is the only block
|
||||
/// proxying there) and insert the reroot set right after it — directive order
|
||||
/// inside a location block is irrelevant to nginx. Idempotent via the same
|
||||
/// `href="/app/fedimint/` marker the main-conf heal leaves behind.
|
||||
const NGINX_FEDIMINT_SNIPPET_ANCHOR: &str = "proxy_pass http://127.0.0.1:8175/;";
|
||||
const NGINX_FEDIMINT_SNIPPET_INSERT: &str = "proxy_pass http://127.0.0.1:8175/;\n proxy_set_header Accept-Encoding \"\";\n sub_filter_types text/css application/javascript application/json;\n sub_filter_once off;\n sub_filter 'href=\"/' 'href=\"/app/fedimint/';\n sub_filter 'src=\"/' 'src=\"/app/fedimint/';\n sub_filter \"href='/\" \"href='/app/fedimint/\";\n sub_filter \"src='/\" \"src='/app/fedimint/\";\n sub_filter 'url(\"/' 'url(\"/app/fedimint/';\n sub_filter \"url('/\" \"url('/app/fedimint/\";\n sub_filter '</head>' '<script src=\"/nostr-provider.js\"></script></head>';";
|
||||
|
||||
/// Entry point called from main startup. Never returns an error to the caller —
|
||||
/// failing to bootstrap host artifacts must not prevent the backend from serving.
|
||||
pub async fn ensure_doctor_installed() {
|
||||
@@ -483,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
|
||||
@@ -503,7 +631,11 @@ async fn run_nginx() -> Result<bool> {
|
||||
|
||||
let mut changed = false;
|
||||
let mut patched_paths = Vec::<PathBuf>::new();
|
||||
for path in [NGINX_CONF_PATH, NGINX_ENABLED_CONF_PATH] {
|
||||
for path in [
|
||||
NGINX_CONF_PATH,
|
||||
NGINX_ENABLED_CONF_PATH,
|
||||
NGINX_HTTPS_SNIPPET_PATH,
|
||||
] {
|
||||
let candidate = Path::new(path);
|
||||
if !candidate.exists() {
|
||||
debug!("{} missing — skipping nginx bootstrap", path);
|
||||
@@ -521,19 +653,66 @@ async fn run_nginx() -> Result<bool> {
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
/// Reflective CORS add_headers that older configs placed inside the
|
||||
/// `/lnd-connect-info` location. The backend now sets a validated
|
||||
/// `Access-Control-Allow-Origin` for that endpoint (api/handler/proxy.rs), so
|
||||
/// leaving these in nginx emits a DUPLICATE header ("contains multiple values
|
||||
/// … but only one is allowed") and the LND wallet UI's cross-origin fetch is
|
||||
/// rejected. Stripped during nginx bootstrap so the backend solely owns CORS.
|
||||
const NGINX_LND_DUP_CORS: &str = " add_header Access-Control-Allow-Origin $http_origin always;\n add_header Access-Control-Allow-Credentials \"true\" always;\n";
|
||||
|
||||
async fn patch_nginx_conf(path: &str) -> Result<bool> {
|
||||
let content = fs::read_to_string(path)
|
||||
.await
|
||||
.with_context(|| format!("read {}", path))?;
|
||||
let missing_app_catalog = !content.contains("location /api/app-catalog");
|
||||
let missing_bitcoin_status = !content.contains("location /bitcoin-status");
|
||||
let missing_lnd_proxy = !content.contains("location /proxy/lnd/");
|
||||
if !missing_app_catalog && !missing_bitcoin_status && !missing_lnd_proxy {
|
||||
// Each "missing" flag is gated on the splice anchor actually being present,
|
||||
// so an included snippet that legitimately has none of these endpoints (the
|
||||
// HTTPS app-proxy snippet) neither tries to patch them nor logs warn-skips on
|
||||
// every boot — it falls through to the fedimint heal alone.
|
||||
let has_lnd_anchor = content.contains(" location /lnd-connect-info {")
|
||||
|| content.contains(" location /electrs-status {");
|
||||
let missing_app_catalog = content
|
||||
.contains(" # DWN endpoints — peer access over Tor (no auth)")
|
||||
&& !content.contains("location /api/app-catalog");
|
||||
let missing_bitcoin_status = content.contains(" location /electrs-status {")
|
||||
&& !content.contains("location /bitcoin-status");
|
||||
let missing_lnd_proxy = has_lnd_anchor && !content.contains("location /proxy/lnd/");
|
||||
let missing_peer_content = has_lnd_anchor && !content.contains("location /api/peer-content");
|
||||
let has_lnd_dup_cors = content.contains(NGINX_LND_DUP_CORS);
|
||||
// B13: fedimint block present but lacking the asset-rewrite sub_filters.
|
||||
let needs_fedimint_css = content.contains("location /app/fedimint/")
|
||||
&& !content.contains("'href=\"/' 'href=\"/app/fedimint/'");
|
||||
if !missing_app_catalog
|
||||
&& !missing_bitcoin_status
|
||||
&& !missing_lnd_proxy
|
||||
&& !missing_peer_content
|
||||
&& !has_lnd_dup_cors
|
||||
&& !needs_fedimint_css
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let mut patched = content.clone();
|
||||
|
||||
if has_lnd_dup_cors {
|
||||
// Drop the redundant nginx-side CORS headers so the backend's single
|
||||
// validated Access-Control-Allow-Origin is the only one returned.
|
||||
patched = patched.replace(NGINX_LND_DUP_CORS, "");
|
||||
}
|
||||
|
||||
if needs_fedimint_css {
|
||||
// Style A (main conf): the block already injects nostr-provider, so swap
|
||||
// its single-sub_filter tail for the full asset-rewrite set. No-op if the
|
||||
// node's fedimint block doesn't match OLD.
|
||||
patched = patched.replace(NGINX_FEDIMINT_OLD, NGINX_FEDIMINT_NEW);
|
||||
// Style B (HTTPS app-proxy snippet): the block has no sub_filter to swap,
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
if missing_lnd_proxy {
|
||||
// Prefer the `/lnd-connect-info` anchor (present since 2026-03-17); fall
|
||||
// back to `/electrs-status` (since 2026-03-08) for even older configs.
|
||||
@@ -552,6 +731,22 @@ async fn patch_nginx_conf(path: &str) -> Result<bool> {
|
||||
}
|
||||
}
|
||||
|
||||
if missing_peer_content {
|
||||
// Same anchoring as the LND proxy: prepend the block to every server
|
||||
// block so /api/peer-content/* reaches the backend instead of the SPA.
|
||||
let anchor = if patched.contains(" location /lnd-connect-info {") {
|
||||
" location /lnd-connect-info {"
|
||||
} else {
|
||||
" location /electrs-status {"
|
||||
};
|
||||
if patched.contains(anchor) {
|
||||
let replacement = format!("{}{}", NGINX_PEER_CONTENT_BLOCK, anchor);
|
||||
patched = patched.replace(anchor, &replacement);
|
||||
} else {
|
||||
warn!("nginx conf missing anchor — skipping /api/peer-content patch");
|
||||
}
|
||||
}
|
||||
|
||||
if missing_bitcoin_status {
|
||||
let anchor = " location /electrs-status {";
|
||||
if !patched.contains(anchor) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -772,6 +772,8 @@ pub struct ProdContainerOrchestrator {
|
||||
use_quadlet_backends: bool,
|
||||
#[cfg(test)]
|
||||
test_disk_gb: Option<u64>,
|
||||
#[cfg(test)]
|
||||
test_bitcoin_host: Option<String>,
|
||||
}
|
||||
|
||||
struct FileSecretsProvider {
|
||||
@@ -832,6 +834,8 @@ impl ProdContainerOrchestrator {
|
||||
use_quadlet_backends: config.use_quadlet_backends,
|
||||
#[cfg(test)]
|
||||
test_disk_gb: None,
|
||||
#[cfg(test)]
|
||||
test_bitcoin_host: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -850,6 +854,7 @@ impl ProdContainerOrchestrator {
|
||||
secrets_dir: PathBuf::from("/var/lib/archipelago/secrets"),
|
||||
use_quadlet_backends: false,
|
||||
test_disk_gb: None,
|
||||
test_bitcoin_host: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1380,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
|
||||
@@ -2314,9 +2341,45 @@ impl ProdContainerOrchestrator {
|
||||
host_ip,
|
||||
host_mdns,
|
||||
disk_gb,
|
||||
// Cheap default; resolve_dynamic_env fills the real node name on
|
||||
// demand (it costs a podman call) only for manifests that use
|
||||
// {{BITCOIN_HOST}}, rather than every app on every reconcile.
|
||||
bitcoin_host: "bitcoin-knots".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Container name of the running Bitcoin node (`bitcoin-knots` or
|
||||
/// `bitcoin-core`) for the `{{BITCOIN_HOST}}` derived-env placeholder.
|
||||
/// Synchronous `podman ps` to match the surrounding host-fact detection;
|
||||
/// defaults to `bitcoin-knots` when none is running (B12).
|
||||
fn bitcoin_host(&self) -> String {
|
||||
#[cfg(test)]
|
||||
if let Some(host) = &self.test_bitcoin_host {
|
||||
return host.clone();
|
||||
}
|
||||
// Mirrors api::rpc::package::dependencies (the legacy install path);
|
||||
// both Bitcoin node variants are reachable on archy-net by name.
|
||||
const BITCOIN_NAMES: &[&str] = &["bitcoin-knots", "bitcoin-core", "bitcoin"];
|
||||
let names = Command::new("podman")
|
||||
.args(["ps", "--format", "{{.Names}}"])
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
|
||||
.unwrap_or_default();
|
||||
names
|
||||
.lines()
|
||||
.map(|l| l.trim())
|
||||
.find(|name| BITCOIN_NAMES.contains(name))
|
||||
.map(|name| name.to_string())
|
||||
.unwrap_or_else(|| "bitcoin-knots".to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn set_bitcoin_host_for_test(&mut self, host: &str) {
|
||||
self.test_bitcoin_host = Some(host.to_string());
|
||||
}
|
||||
|
||||
fn detect_host_ip() -> Option<String> {
|
||||
let output = Command::new("hostname").arg("-I").output().ok()?;
|
||||
if !output.status.success() {
|
||||
@@ -2400,7 +2463,18 @@ impl ProdContainerOrchestrator {
|
||||
}
|
||||
|
||||
fn resolve_dynamic_env(&self, manifest: &mut AppManifest) -> Result<()> {
|
||||
let facts = self.detect_host_facts();
|
||||
let mut facts = self.detect_host_facts();
|
||||
// Only pay the podman cost to detect Knots-vs-Core when this manifest
|
||||
// actually templates the Bitcoin node into its env (mempool — B12).
|
||||
if manifest
|
||||
.app
|
||||
.container
|
||||
.derived_env
|
||||
.iter()
|
||||
.any(|e| e.template.contains("{{BITCOIN_HOST}}"))
|
||||
{
|
||||
facts.bitcoin_host = self.bitcoin_host();
|
||||
}
|
||||
let mut env = manifest.app.environment.clone();
|
||||
env.extend(manifest.app.container.resolve_derived_env(&facts));
|
||||
|
||||
@@ -3489,6 +3563,35 @@ app:
|
||||
assert!(!calls.iter().any(|c| c.starts_with("build_image:")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mempool_core_rpc_host_follows_bitcoin_node() {
|
||||
// B12: mempool's CORE_RPC_HOST must resolve to whichever Bitcoin node
|
||||
// container is running (Knots OR Core), not a hardcoded value.
|
||||
let yaml = "app:\n id: mempool-api\n name: mempool-api\n version: 1.0.0\n container:\n image: x:1\n derived_env:\n - key: CORE_RPC_HOST\n template: \"{{BITCOIN_HOST}}\"\n";
|
||||
|
||||
for (node, expected) in [
|
||||
("bitcoin-core", "bitcoin-core"),
|
||||
("bitcoin-knots", "bitcoin-knots"),
|
||||
] {
|
||||
let rt = Arc::new(MockRuntime::default());
|
||||
let mut orch = orch_with(rt).await;
|
||||
orch.set_bitcoin_host_for_test(node);
|
||||
|
||||
let mut manifest = AppManifest::parse(yaml).unwrap();
|
||||
orch.resolve_dynamic_env(&mut manifest).unwrap();
|
||||
|
||||
assert!(
|
||||
manifest
|
||||
.app
|
||||
.environment
|
||||
.iter()
|
||||
.any(|e| e == &format!("CORE_RPC_HOST={expected}")),
|
||||
"node={node}: expected CORE_RPC_HOST={expected}, got {:?}",
|
||||
manifest.app.environment
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn install_fresh_build_when_image_absent() {
|
||||
let rt = Arc::new(MockRuntime::default());
|
||||
|
||||
@@ -58,7 +58,43 @@ pub async fn load_nodes(data_dir: &Path) -> Result<Vec<FederatedNode>> {
|
||||
.await
|
||||
.context("Failed to read federation nodes")?;
|
||||
let file: NodesFile = serde_json::from_str(&content).unwrap_or_default();
|
||||
Ok(file.nodes)
|
||||
Ok(dedup_nodes_by_onion(file.nodes))
|
||||
}
|
||||
|
||||
/// Collapse entries that share an onion. An onion is a node's stable, unique
|
||||
/// network identity, so two entries with the same onion are the SAME physical
|
||||
/// node lingering under two dids (e.g. after a did/key change). Returning both
|
||||
/// duplicates the node in the trusted-node list (B1) and the chat list (B2).
|
||||
/// Keep the first occurrence and merge any missing fips_npub/name/last_state
|
||||
/// from the duplicates into it, then drop them. Non-destructive to disk; the
|
||||
/// deduped list persists the next time nodes are saved (add/sync).
|
||||
fn dedup_nodes_by_onion(nodes: Vec<FederatedNode>) -> Vec<FederatedNode> {
|
||||
use std::collections::HashMap;
|
||||
let mut by_onion: HashMap<String, usize> = HashMap::new();
|
||||
let mut out: Vec<FederatedNode> = Vec::with_capacity(nodes.len());
|
||||
for node in nodes {
|
||||
let key = node.onion.trim_end_matches(".onion").to_string();
|
||||
if key.is_empty() {
|
||||
out.push(node);
|
||||
continue;
|
||||
}
|
||||
if let Some(&idx) = by_onion.get(&key) {
|
||||
let kept = &mut out[idx];
|
||||
if kept.fips_npub.is_none() {
|
||||
kept.fips_npub = node.fips_npub;
|
||||
}
|
||||
if kept.name.is_none() {
|
||||
kept.name = node.name;
|
||||
}
|
||||
if kept.last_state.is_none() {
|
||||
kept.last_state = node.last_state;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
by_onion.insert(key, out.len());
|
||||
out.push(node);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Look up a federated peer's FIPS npub given their onion address.
|
||||
@@ -314,6 +350,44 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dedup_nodes_by_onion_collapses_same_onion() {
|
||||
// Two entries share an onion (same physical node under two dids) — must
|
||||
// collapse to one, keeping the first did and merging fips_npub/name (B1/B2).
|
||||
let mut dup = make_node("did:key:zDUP", "shared.onion");
|
||||
dup.fips_npub = Some("npub1merged".to_string());
|
||||
dup.name = Some("Sapien".to_string());
|
||||
let nodes = vec![
|
||||
make_node("did:key:zKEEP", "shared.onion"),
|
||||
dup,
|
||||
make_node("did:key:zOTHER", "other.onion"),
|
||||
];
|
||||
let out = dedup_nodes_by_onion(nodes);
|
||||
assert_eq!(out.len(), 2, "two distinct onions remain");
|
||||
let kept = out.iter().find(|n| n.onion == "shared.onion").unwrap();
|
||||
assert_eq!(kept.did, "did:key:zKEEP", "keeps first did for the onion");
|
||||
assert_eq!(
|
||||
kept.fips_npub.as_deref(),
|
||||
Some("npub1merged"),
|
||||
"merges fips_npub from the dropped duplicate"
|
||||
);
|
||||
assert_eq!(
|
||||
kept.name.as_deref(),
|
||||
Some("Sapien"),
|
||||
"merges name from the dup"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dedup_onion_suffix_insensitive() {
|
||||
// The ".onion" suffix must not affect the match.
|
||||
let nodes = vec![
|
||||
make_node("did:key:z1", "abc"),
|
||||
make_node("did:key:z2", "abc.onion"),
|
||||
];
|
||||
assert_eq!(dedup_nodes_by_onion(nodes).len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_load_nodes_empty_when_no_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -145,6 +145,27 @@ async fn merge_transitive_peers(
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Same physical node advertised under a DIFFERENT did? Match on the
|
||||
// onion (its stable network identity). Without this, a node that
|
||||
// appears under two dids (e.g. after a key/did change) gets added
|
||||
// twice — showing up duplicated in the trusted-node list (B1) and as
|
||||
// two separate mesh chat contacts (B2). Merge into the existing entry.
|
||||
let hint_onion = hint.onion.trim_end_matches(".onion");
|
||||
if !hint_onion.is_empty() {
|
||||
if let Some(existing) = nodes
|
||||
.iter_mut()
|
||||
.find(|n| n.onion.trim_end_matches(".onion") == hint_onion)
|
||||
{
|
||||
if existing.fips_npub.is_none() && hint.fips_npub.is_some() {
|
||||
existing.fips_npub = hint.fips_npub.clone();
|
||||
}
|
||||
if existing.name.is_none() && hint.name.is_some() {
|
||||
existing.name = hint.name.clone();
|
||||
}
|
||||
refreshed += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
nodes.push(FederatedNode {
|
||||
did: hint.did.clone(),
|
||||
pubkey: hint.pubkey.clone(),
|
||||
|
||||
@@ -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
|
||||
@@ -99,7 +100,17 @@ pub(crate) async fn seed_federation_peers_into_mesh(
|
||||
Ok(n) => n,
|
||||
Err(_) => return,
|
||||
};
|
||||
// Skip nodes whose onion we've already seeded: the same physical node can
|
||||
// linger in the federation list under two dids (see B1/B2). Seeding both
|
||||
// would create two chat contacts for one node — one by name+logo and one
|
||||
// by raw did. One onion → one mesh contact.
|
||||
let mut seen_onions = std::collections::HashSet::new();
|
||||
for node in nodes {
|
||||
let onion_key = node.onion.trim_end_matches(".onion").to_string();
|
||||
if !onion_key.is_empty() && !seen_onions.insert(onion_key) {
|
||||
tracing::debug!(did = %node.did, onion = %node.onion, "skipping duplicate federation node (onion already seeded)");
|
||||
continue;
|
||||
}
|
||||
upsert_federation_peer(state, &node.pubkey, &node.did, node.name.as_deref()).await;
|
||||
}
|
||||
}
|
||||
@@ -200,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> {
|
||||
@@ -294,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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -858,6 +858,11 @@ pub struct HostFacts {
|
||||
/// `/` if the data partition is not yet mounted). Drives the
|
||||
/// prune-vs-full-node decision in bitcoin-knots custom_args.
|
||||
pub disk_gb: u64,
|
||||
/// Container name of the running Bitcoin node — `bitcoin-knots` or
|
||||
/// `bitcoin-core` — so dependents (mempool's CORE_RPC_HOST) reach the
|
||||
/// right host. Both are reachable on archy-net by their container name;
|
||||
/// only the name differs. Falls back to `bitcoin-knots` when undetected.
|
||||
pub bitcoin_host: String,
|
||||
}
|
||||
|
||||
impl HostFacts {
|
||||
@@ -868,13 +873,14 @@ impl HostFacts {
|
||||
host_ip: "192.168.1.116".to_string(),
|
||||
host_mdns: "archi-thinkpad.local".to_string(),
|
||||
disk_gb: 2000,
|
||||
bitcoin_host: "bitcoin-knots".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Supported placeholder names in `DerivedEnv::template`. Keep in sync
|
||||
/// with `HostFacts`. Centralized so validation and rendering agree.
|
||||
const DERIVED_PLACEHOLDERS: &[&str] = &["HOST_IP", "HOST_MDNS", "DISK_GB"];
|
||||
const DERIVED_PLACEHOLDERS: &[&str] = &["HOST_IP", "HOST_MDNS", "DISK_GB", "BITCOIN_HOST"];
|
||||
|
||||
fn validate_derived_template(key: &str, template: &str) -> Result<(), ManifestError> {
|
||||
// Walk `{{NAME}}` occurrences and ensure each NAME is recognized.
|
||||
@@ -957,7 +963,8 @@ impl ContainerConfig {
|
||||
.template
|
||||
.replace("{{HOST_IP}}", &facts.host_ip)
|
||||
.replace("{{HOST_MDNS}}", &facts.host_mdns)
|
||||
.replace("{{DISK_GB}}", &facts.disk_gb.to_string());
|
||||
.replace("{{DISK_GB}}", &facts.disk_gb.to_string())
|
||||
.replace("{{BITCOIN_HOST}}", &facts.bitcoin_host);
|
||||
format!("{}={}", e.key, value)
|
||||
})
|
||||
.collect()
|
||||
@@ -1463,6 +1470,10 @@ app:
|
||||
key: "INFO".to_string(),
|
||||
template: "{{HOST_IP}}-{{DISK_GB}}".to_string(),
|
||||
},
|
||||
DerivedEnv {
|
||||
key: "CORE_RPC_HOST".to_string(),
|
||||
template: "{{BITCOIN_HOST}}".to_string(),
|
||||
},
|
||||
],
|
||||
secret_env: vec![],
|
||||
data_uid: None,
|
||||
@@ -1471,11 +1482,13 @@ app:
|
||||
host_ip: "192.168.1.116".to_string(),
|
||||
host_mdns: "archi-thinkpad.local".to_string(),
|
||||
disk_gb: 2000,
|
||||
bitcoin_host: "bitcoin-core".to_string(),
|
||||
};
|
||||
|
||||
let out = c.resolve_derived_env(&facts);
|
||||
assert_eq!(out[0], "FM_API_URL=ws://archi-thinkpad.local:8174");
|
||||
assert_eq!(out[1], "INFO=192.168.1.116-2000");
|
||||
assert_eq!(out[2], "CORE_RPC_HOST=bitcoin-core");
|
||||
}
|
||||
|
||||
struct MapSecretsProvider {
|
||||
|
||||
|
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
|
||||
|
||||
@@ -652,7 +652,14 @@ server {
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_set_header Accept-Encoding "";
|
||||
sub_filter_once on;
|
||||
sub_filter_types text/css application/javascript application/json;
|
||||
sub_filter_once off;
|
||||
sub_filter 'href="/' 'href="/app/fedimint/';
|
||||
sub_filter 'src="/' 'src="/app/fedimint/';
|
||||
sub_filter "href='/" "href='/app/fedimint/";
|
||||
sub_filter "src='/" "src='/app/fedimint/";
|
||||
sub_filter 'url("/' 'url("/app/fedimint/';
|
||||
sub_filter "url('/" "url('/app/fedimint/";
|
||||
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
|
||||
}
|
||||
location /app/fedimint-gateway/ {
|
||||
|
||||
@@ -174,7 +174,14 @@ location /app/fedimint/ {
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_set_header Accept-Encoding "";
|
||||
sub_filter_once on;
|
||||
sub_filter_types text/css application/javascript application/json;
|
||||
sub_filter_once off;
|
||||
sub_filter 'href="/' 'href="/app/fedimint/';
|
||||
sub_filter 'src="/' 'src="/app/fedimint/';
|
||||
sub_filter "href='/" "href='/app/fedimint/";
|
||||
sub_filter "src='/" "src='/app/fedimint/";
|
||||
sub_filter 'url("/' 'url("/app/fedimint/';
|
||||
sub_filter "url('/" "url('/app/fedimint/";
|
||||
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
|
||||
}
|
||||
location /app/fedimint-gateway/ {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"version": "1.7.95-alpha",
|
||||
"version": "1.7.98-alpha",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "neode-ui",
|
||||
"version": "1.7.95-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.95-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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -102,7 +102,15 @@ class FileBrowserClient {
|
||||
const res = await fetch(`${this.baseUrl}/api/resources${safePath}`, {
|
||||
headers: this.headers(),
|
||||
})
|
||||
if (!res.ok) throw new Error(`Failed to list directory: ${res.status}`)
|
||||
if (!res.ok) throw new Error(`File Browser is not available (HTTP ${res.status})`)
|
||||
// When File Browser isn't installed, nginx falls through to the SPA and
|
||||
// returns index.html (200, text/html); when it's down it returns 502.
|
||||
// Either way res.json() would throw the opaque "Unexpected token '<'"
|
||||
// error, so detect a non-JSON body and surface a friendly message instead.
|
||||
const contentType = res.headers.get('content-type') || ''
|
||||
if (!contentType.includes('application/json')) {
|
||||
throw new Error('File Browser is not available — install or start the File Browser app to use your folders')
|
||||
}
|
||||
const data: FileBrowserListResponse = await res.json()
|
||||
return (data.items || []).map((item) => ({
|
||||
...item,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -84,12 +84,18 @@ const router = createRouter({
|
||||
meta: { public: true },
|
||||
},
|
||||
{
|
||||
// The kiosk display no longer has its own launcher screen. It runs the
|
||||
// normal app (onboarding → login → dashboard) like any other client.
|
||||
// This route only persists kiosk mode + safe-area insets, then redirects
|
||||
// to the root app. The launcher still points Chromium here (not directly
|
||||
// at `/`) so the 'kiosk' flag gets set — App.vue uses it to skip the
|
||||
// remote relay, which would otherwise double xdotool input on the kiosk
|
||||
// display. Public so the auth guard doesn't bounce us before beforeEnter.
|
||||
path: '/kiosk',
|
||||
name: 'kiosk',
|
||||
component: () => import('../views/Kiosk.vue'),
|
||||
meta: { public: true },
|
||||
component: () => import('../views/RootRedirect.vue'),
|
||||
beforeEnter: (to) => {
|
||||
// Persist kiosk mode before redirect so App.vue can skip the remote relay
|
||||
// (relay duplicates xdotool input on the kiosk display)
|
||||
localStorage.setItem('kiosk', 'true')
|
||||
const safeArea = to.query.safe_area
|
||||
const safeAreaPx = Array.isArray(safeArea) ? safeArea[0] : safeArea
|
||||
@@ -106,6 +112,8 @@ const router = createRouter({
|
||||
if (safeAreaYPx && /^\d{1,3}$/.test(safeAreaYPx)) {
|
||||
localStorage.setItem('archipelago_kiosk_safe_area_y_px', safeAreaYPx)
|
||||
}
|
||||
// Grid screen removed — hand off to the normal app flow.
|
||||
return { path: '/' }
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -62,7 +62,6 @@
|
||||
.glass-card:focus-visible,
|
||||
.sidebar-nav-item:focus-visible,
|
||||
.path-option-card:focus-visible,
|
||||
.kiosk-app-tile:focus-visible,
|
||||
input:focus-visible,
|
||||
textarea:focus-visible,
|
||||
select:focus-visible {
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
@@ -482,7 +482,7 @@ const cloudFolderDisplay = computed(() => cloudFolderCount.value !== null ? Stri
|
||||
|
||||
onMounted(async () => {
|
||||
try { const usage = await fileBrowserClient.getUsage(); cloudStorageUsed.value = usage.totalSize; cloudFolderCount.value = usage.folderCount } catch { /* not running */ }
|
||||
loadSystemStats(); systemStatsInterval = setInterval(loadSystemStats, 30000); checkUpdateStatus(); loadWeb5Status()
|
||||
loadSystemStats(); systemStatsInterval = setInterval(loadSystemStats, 10000); checkUpdateStatus(); loadWeb5Status()
|
||||
})
|
||||
|
||||
// Wallet modals
|
||||
@@ -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,286 +0,0 @@
|
||||
<template>
|
||||
<div class="kiosk-root" tabindex="0" ref="kioskRoot">
|
||||
<!-- Kiosk launcher grid -->
|
||||
<div class="kiosk-launcher">
|
||||
<!-- Header -->
|
||||
<div class="kiosk-header">
|
||||
<div class="flex items-center gap-4">
|
||||
<img :src="FALLBACK_ICON" alt="Archipelago" class="w-10 h-10" />
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-white font-archipelago">Archipelago</h1>
|
||||
<p class="text-sm text-white/50">{{ currentTime }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="kiosk-status-pill" :class="isConnected ? 'status-success' : 'status-error'">
|
||||
<div class="w-2 h-2 rounded-full" :class="isConnected ? 'bg-green-400' : 'bg-red-400'"></div>
|
||||
{{ isConnected ? t('kiosk.online') : t('kiosk.offline') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- App grid -->
|
||||
<div class="kiosk-grid">
|
||||
<button
|
||||
v-for="app in launchableApps"
|
||||
:key="app.id"
|
||||
class="kiosk-app-tile"
|
||||
@click="openApp(app)"
|
||||
:data-controller-focusable="true"
|
||||
>
|
||||
<div class="kiosk-app-icon-wrap">
|
||||
<img
|
||||
:src="app.icon"
|
||||
:alt="app.title"
|
||||
class="kiosk-app-icon"
|
||||
@error="($event.target as HTMLImageElement).src = FALLBACK_ICON"
|
||||
/>
|
||||
<div
|
||||
class="kiosk-app-status"
|
||||
:class="app.running ? 'bg-green-400' : 'bg-white/30'"
|
||||
/>
|
||||
</div>
|
||||
<span class="kiosk-app-label">{{ app.title }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="kiosk-footer">
|
||||
<span class="text-white/30 text-sm">{{ t('kiosk.navHint') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
|
||||
const { t } = useI18n()
|
||||
const store = useAppStore()
|
||||
const appLauncher = useAppLauncherStore()
|
||||
const kioskRoot = ref<HTMLElement | null>(null)
|
||||
|
||||
interface KioskApp {
|
||||
id: string
|
||||
title: string
|
||||
icon: string
|
||||
url: string
|
||||
running: boolean
|
||||
}
|
||||
|
||||
// Public asset path — construct with BASE_URL to avoid Vite resolving it as a module import
|
||||
const FALLBACK_ICON = `${import.meta.env.BASE_URL}assets/img/favico.png`
|
||||
|
||||
const currentTime = ref('')
|
||||
|
||||
const isConnected = computed(() => store.isConnected)
|
||||
|
||||
// Build list of launchable apps from the store's package data
|
||||
const launchableApps = computed<KioskApp[]>(() => {
|
||||
const pkgs = store.data?.['package-data'] || {}
|
||||
const apps: KioskApp[] = []
|
||||
|
||||
// App URL mappings. Bitcoin UI uses its direct host-network port; loading it
|
||||
// through /app/bitcoin-ui/ can render a blank shell because its assets are
|
||||
// rooted at /.
|
||||
const urlMap: Record<string, string> = {
|
||||
'bitcoin-knots': 'http://' + window.location.hostname + ':8334',
|
||||
'lnd': '/app/lnd/',
|
||||
'mempool': '/app/mempool/',
|
||||
'btcpay-server': '/app/btcpay/',
|
||||
'homeassistant': '/app/homeassistant/',
|
||||
'grafana': '/app/grafana/',
|
||||
'jellyfin': '/app/jellyfin/',
|
||||
'nextcloud': '/app/nextcloud/',
|
||||
'immich': '/app/immich/',
|
||||
'photoprism': '/app/photoprism/',
|
||||
'vaultwarden': '/app/vaultwarden/',
|
||||
'filebrowser': '/app/filebrowser/',
|
||||
'searxng': '/app/searxng/',
|
||||
'ollama': '/app/ollama/',
|
||||
'portainer': '/app/portainer/',
|
||||
'uptime-kuma': '/app/uptime-kuma/',
|
||||
'nginx-proxy-manager': '/app/nginx-proxy-manager/',
|
||||
'tailscale': '/app/tailscale/',
|
||||
'fedimint': '/app/fedimint/',
|
||||
'fedimint-gateway': '/app/fedimint-gateway/',
|
||||
'indeedhub': 'http://localhost:7778',
|
||||
'botfights': 'http://localhost:9100',
|
||||
'nwnn': 'https://nwnn.l484.com',
|
||||
'484-kitchen': 'https://484.kitchen',
|
||||
'call-the-operator': 'https://cta.tx1138.com',
|
||||
'arch-presentation': 'https://present.l484.com',
|
||||
'syntropy-institute': 'https://syntropy.institute',
|
||||
't-zero': 'https://teeminuszero.net',
|
||||
}
|
||||
|
||||
for (const [id, pkg] of Object.entries(pkgs)) {
|
||||
const url = urlMap[id]
|
||||
if (!url) continue
|
||||
|
||||
const isRunning = pkg.state === 'running' ||
|
||||
pkg.installed?.status === 'running'
|
||||
|
||||
apps.push({
|
||||
id,
|
||||
title: pkg.manifest?.title || id,
|
||||
icon: pkg['static-files']?.icon || FALLBACK_ICON,
|
||||
url,
|
||||
running: isRunning,
|
||||
})
|
||||
}
|
||||
|
||||
// Sort: running apps first, then alphabetical
|
||||
return apps.sort((a, b) => {
|
||||
if (a.running !== b.running) return a.running ? -1 : 1
|
||||
return a.title.localeCompare(b.title)
|
||||
})
|
||||
})
|
||||
|
||||
function openApp(app: KioskApp) {
|
||||
// Delegate to the app launcher — handles iframe overlay vs new-tab
|
||||
appLauncher.open({ url: app.url, title: app.title })
|
||||
}
|
||||
|
||||
// Clock updater
|
||||
let clockInterval: ReturnType<typeof setInterval> | undefined
|
||||
function updateClock() {
|
||||
const now = new Date()
|
||||
currentTime.value = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
updateClock()
|
||||
clockInterval = setInterval(updateClock, 30000)
|
||||
kioskRoot.value?.focus()
|
||||
|
||||
// Connect WebSocket if not already
|
||||
if (!store.isConnected) {
|
||||
store.connectWebSocket().catch(() => {})
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (clockInterval) clearInterval(clockInterval)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.kiosk-root {
|
||||
position: fixed;
|
||||
left: var(--kiosk-safe-area-x, 0px);
|
||||
top: var(--kiosk-safe-area-y, 0px);
|
||||
width: calc(100vw - (var(--kiosk-safe-area-x, 0px) * 2));
|
||||
height: calc(100vh - (var(--kiosk-safe-area-y, 0px) * 2));
|
||||
background: #000;
|
||||
outline: none;
|
||||
overflow: hidden;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.kiosk-launcher {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: clamp(1rem, 3vh, 2rem) clamp(1.5rem, 4vw, 3rem);
|
||||
background: linear-gradient(180deg, #0a0a12 0%, #000 100%);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.kiosk-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-bottom: 2rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.kiosk-status-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.kiosk-grid {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 1.5rem;
|
||||
align-content: start;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.kiosk-app-tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1.25rem 0.75rem;
|
||||
border-radius: 1rem;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
transition: all 0.25s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.kiosk-app-tile:hover,
|
||||
.kiosk-app-tile:focus-visible {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(251, 146, 60, 0.4);
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 0 30px rgba(251, 146, 60, 0.15);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.kiosk-app-icon-wrap {
|
||||
position: relative;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.kiosk-app-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 16px;
|
||||
object-fit: cover;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.kiosk-app-status {
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
right: -2px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid #000;
|
||||
}
|
||||
|
||||
.kiosk-app-label {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.kiosk-footer {
|
||||
padding-top: 1.5rem;
|
||||
text-align: center;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -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) {
|
||||
|
||||
@@ -30,7 +30,15 @@
|
||||
</svg>
|
||||
</div>
|
||||
<div class="hidden md:block">
|
||||
<h1 class="text-2xl font-bold text-white">{{ peerDisplayName }}</h1>
|
||||
<div class="flex items-center gap-2">
|
||||
<h1 class="text-2xl font-bold text-white">{{ peerDisplayName }}</h1>
|
||||
<span
|
||||
v-if="transportPill"
|
||||
:class="transportPill.cls"
|
||||
:title="transportPill.title"
|
||||
class="text-xs px-2 py-0.5 rounded-full font-medium"
|
||||
>{{ transportPill.label }}</span>
|
||||
</div>
|
||||
<p v-if="currentPeer?.did" class="text-sm text-white/50 font-mono truncate max-w-md" :title="currentPeer.did">{{ currentPeer.did }}</p>
|
||||
<p v-else class="text-sm text-white/50">Peer files</p>
|
||||
</div>
|
||||
@@ -43,7 +51,7 @@
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<p class="text-white/50 text-sm">Connecting via Tor... This may take a few seconds.</p>
|
||||
<p class="text-white/50 text-sm">Connecting to peer… This may take a few seconds.</p>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
@@ -82,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
|
||||
@@ -142,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)" />
|
||||
@@ -226,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>
|
||||
@@ -288,6 +315,23 @@ const catalogItems = ref<CatalogItem[]>([])
|
||||
const downloading = ref<string | null>(null)
|
||||
const playing = ref<string | null>(null)
|
||||
const purchaseError = ref<string | null>(null)
|
||||
// Transport actually used to reach this peer (returned by content.browse-peer)
|
||||
// so we can show a FIPS/Tor pill instead of always assuming Tor (B21).
|
||||
const transport = ref<string | null>(null)
|
||||
const transportPill = computed(() => {
|
||||
switch (transport.value) {
|
||||
case 'fips':
|
||||
return { label: 'FIPS', cls: 'bg-green-500/20 text-green-300', title: 'Connected over the fast encrypted mesh (FIPS)' }
|
||||
case 'mesh':
|
||||
return { label: 'Mesh', cls: 'bg-green-500/20 text-green-300', title: 'Connected over the mesh' }
|
||||
case 'lan':
|
||||
return { label: 'LAN', cls: 'bg-blue-500/20 text-blue-300', title: 'Connected over the local network' }
|
||||
case 'tor':
|
||||
return { label: 'Tor', cls: 'bg-amber-500/20 text-amber-300', title: 'Connected over Tor (slower)' }
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})
|
||||
const previewUrls = reactive<Record<string, string>>({})
|
||||
const audioPlayer = useAudioPlayer()
|
||||
|
||||
@@ -295,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
|
||||
@@ -329,12 +377,13 @@ async function loadCatalog() {
|
||||
loading.value = true
|
||||
catalogError.value = ''
|
||||
try {
|
||||
const result = await rpcClient.call<{ items?: CatalogItem[] }>({
|
||||
const result = await rpcClient.call<{ items?: CatalogItem[]; transport?: string }>({
|
||||
method: 'content.browse-peer',
|
||||
params: { onion },
|
||||
timeout: 30000,
|
||||
})
|
||||
catalogItems.value = result?.items ?? []
|
||||
transport.value = result?.transport ?? null
|
||||
} catch (e: unknown) {
|
||||
catalogError.value = e instanceof Error ? e.message : 'Failed to connect to peer'
|
||||
if (!hadItems) catalogItems.value = []
|
||||
@@ -511,6 +560,22 @@ async function playMedia(item: CatalogItem) {
|
||||
|
||||
const paid = isPaidItem(item.access)
|
||||
|
||||
// Free content: stream via the Range-capable proxy (B3) so the player can
|
||||
// seek and start instantly, instead of downloading the whole file as a
|
||||
// base64 blob into a non-seekable Blob URL (which broke video/large audio).
|
||||
if (!paid) {
|
||||
const streamUrl = `/api/peer-content/${encodeURIComponent(onion)}/${encodeURIComponent(item.id)}`
|
||||
if (item.mime_type.startsWith('audio/')) {
|
||||
audioPlayer.play(streamUrl, item.filename.split('/').pop() || item.filename)
|
||||
} else if (item.mime_type.startsWith('video/')) {
|
||||
videoPlayerItem.value = item
|
||||
videoPlayerUrl.value = streamUrl
|
||||
videoPlayerPaid.value = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Paid content: use the preview/download flow below.
|
||||
// If we already have a preview blob URL, use it
|
||||
const existingUrl = previewUrls[item.id]
|
||||
if (existingUrl) {
|
||||
@@ -562,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)
|
||||
|
||||
@@ -13,7 +13,10 @@
|
||||
index is still being built (the Electrum server can't serve clients
|
||||
until then). Mirrors the Fedimint Guardian "wait page" design. -->
|
||||
<Transition name="content-fade">
|
||||
<div v-if="electrsSync" class="absolute inset-0 z-10 flex flex-col items-center justify-center">
|
||||
<!-- Sync overlay only while ElectrumX is actively indexing. If the status
|
||||
goes stale (ElectrumX disconnected/unresponsive) we stop blocking and
|
||||
let the app's own UI load instead of a loader stuck on top (B7). -->
|
||||
<div v-if="electrsSync && !electrsSync.stale" class="absolute inset-0 z-10 flex flex-col items-center justify-center">
|
||||
<div class="text-center px-8 w-full max-w-md">
|
||||
<div class="w-16 h-16 mx-auto mb-4 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-orange-300 animate-pulse" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -42,7 +45,7 @@
|
||||
</Transition>
|
||||
|
||||
<div
|
||||
v-if="appUrl && !iframeBlocked && !electrsSync"
|
||||
v-if="appUrl && !iframeBlocked && (!electrsSync || electrsSync.stale)"
|
||||
class="absolute inset-0 app-session-frame-scroll-host"
|
||||
tabindex="-1"
|
||||
@pointerdown="focusIframe"
|
||||
|
||||
@@ -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
|
||||
}>()
|
||||
|
||||
@@ -69,11 +69,18 @@
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs text-white/60">Address (host:port)</span>
|
||||
<input v-model="draft.address" type="text" placeholder="192.168.1.116:8668" class="px-3 py-2 rounded-md bg-white/5 border border-white/10 text-sm text-white focus:border-white/30 focus:outline-none" />
|
||||
<input v-model="draft.address" type="text" placeholder="185.18.221.160:8443" class="px-3 py-2 rounded-md bg-white/5 border border-white/10 text-sm text-white focus:border-white/30 focus:outline-none" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs text-white/60">Transport</span>
|
||||
<select v-model="draft.transport" class="px-3 py-2 rounded-md bg-white/5 border border-white/10 text-sm text-white focus:border-white/30 focus:outline-none">
|
||||
<option value="tcp">tcp (public anchor / over internet)</option>
|
||||
<option value="udp">udp (same LAN)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 sm:col-span-2">
|
||||
<span class="text-xs text-white/60">Label (optional)</span>
|
||||
<input v-model="draft.label" type="text" placeholder="Home anchor" class="px-3 py-2 rounded-md bg-white/5 border border-white/10 text-sm text-white focus:border-white/30 focus:outline-none" />
|
||||
<input v-model="draft.label" type="text" placeholder="Public anchor" class="px-3 py-2 rounded-md bg-white/5 border border-white/10 text-sm text-white focus:border-white/30 focus:outline-none" />
|
||||
</label>
|
||||
<button type="submit" class="sm:col-span-2 min-h-[44px] glass-button rounded-lg text-sm font-medium disabled:opacity-60" :disabled="adding || !draft.npub || !draft.address">{{ adding ? 'Adding…' : 'Add anchor' }}</button>
|
||||
</form>
|
||||
@@ -106,9 +113,10 @@ const applying = ref(false)
|
||||
const statusMessage = ref('')
|
||||
const statusIsError = ref(false)
|
||||
|
||||
const draft = reactive<Pick<SeedAnchor, 'npub' | 'address' | 'label'>>({
|
||||
const draft = reactive<Pick<SeedAnchor, 'npub' | 'address' | 'transport' | 'label'>>({
|
||||
npub: '',
|
||||
address: '',
|
||||
transport: 'tcp',
|
||||
label: '',
|
||||
})
|
||||
|
||||
@@ -136,7 +144,7 @@ async function addAnchor() {
|
||||
params: {
|
||||
npub: draft.npub.trim(),
|
||||
address: draft.address.trim(),
|
||||
transport: 'udp',
|
||||
transport: draft.transport,
|
||||
label: draft.label.trim(),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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,59 @@ 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">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.96-alpha</span>
|
||||
<span class="text-xs text-white/40">June 15, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>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.</p>
|
||||
<p>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 — so the on-device display always matches the rest of the interface.</p>
|
||||
<p>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.</p>
|
||||
<p>Behind the scenes, a new automated two-node test now exercises real node-to-node features — browsing another node's shared files and handling a removed node — against live nodes before each release, so node-to-node problems are caught earlier.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.95-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
@@ -1483,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">
|
||||
@@ -1507,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,27 +1,34 @@
|
||||
{
|
||||
"version": "1.7.95-alpha",
|
||||
"release_date": "2026-06-15",
|
||||
"version": "1.7.98-alpha",
|
||||
"release_date": "2026-06-16",
|
||||
"changelog": [
|
||||
"Browsing another node's shared files now works over the fast encrypted mesh. Opening a peer's cloud could fail with a generic \"Operation failed\" message because the request for their file list wasn't permitted over the mesh and came back as \"not found\" \u2014 and it never retried over Tor. The mesh now serves the file list directly, and if a peer can't answer over the mesh the node automatically falls back to Tor instead of giving up.",
|
||||
"Nodes you remove from your federation now stay removed. Previously a deleted node could quietly come back the next time you synced with another node that still listed it. Removed nodes are now remembered as removed and won't reappear on their own \u2014 only if you add them back yourself.",
|
||||
"The app credentials pop-up now appears as a normal centred box with a dimmed background over the whole screen, instead of stretching to fill the entire screen."
|
||||
"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.95-alpha",
|
||||
"new_version": "1.7.95-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.95-alpha/archipelago",
|
||||
"sha256": "1036f6822387449f6ce0c2544c81c96d424d11c16343d239ae215b3ef1fcb827",
|
||||
"size_bytes": 44360792
|
||||
"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.95-alpha.tar.gz",
|
||||
"current_version": "1.7.95-alpha",
|
||||
"new_version": "1.7.95-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.95-alpha/archipelago-frontend-1.7.95-alpha.tar.gz",
|
||||
"sha256": "d78ec0b2f57cde9722c43bf540730ae91d91a4317ccb1ea26eb27645b35e66c8",
|
||||
"size_bytes": 184075611
|
||||
"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,27 +1,34 @@
|
||||
{
|
||||
"version": "1.7.95-alpha",
|
||||
"release_date": "2026-06-15",
|
||||
"version": "1.7.98-alpha",
|
||||
"release_date": "2026-06-16",
|
||||
"changelog": [
|
||||
"Browsing another node's shared files now works over the fast encrypted mesh. Opening a peer's cloud could fail with a generic \"Operation failed\" message because the request for their file list wasn't permitted over the mesh and came back as \"not found\" \u2014 and it never retried over Tor. The mesh now serves the file list directly, and if a peer can't answer over the mesh the node automatically falls back to Tor instead of giving up.",
|
||||
"Nodes you remove from your federation now stay removed. Previously a deleted node could quietly come back the next time you synced with another node that still listed it. Removed nodes are now remembered as removed and won't reappear on their own \u2014 only if you add them back yourself.",
|
||||
"The app credentials pop-up now appears as a normal centred box with a dimmed background over the whole screen, instead of stretching to fill the entire screen."
|
||||
"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.95-alpha",
|
||||
"new_version": "1.7.95-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.95-alpha/archipelago",
|
||||
"sha256": "1036f6822387449f6ce0c2544c81c96d424d11c16343d239ae215b3ef1fcb827",
|
||||
"size_bytes": 44360792
|
||||
"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.95-alpha.tar.gz",
|
||||
"current_version": "1.7.95-alpha",
|
||||
"new_version": "1.7.95-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.95-alpha/archipelago-frontend-1.7.95-alpha.tar.gz",
|
||||
"sha256": "d78ec0b2f57cde9722c43bf540730ae91d91a4317ccb1ea26eb27645b35e66c8",
|
||||
"size_bytes": 184075611
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env bash
|
||||
# Multi-node RPC harness library.
|
||||
#
|
||||
# Unlike tests/lifecycle/lib/rpc.bash (which targets a single ARCHY_HOST),
|
||||
# this drives N independent archipelago nodes in one run so we can exercise
|
||||
# real node-to-node paths: federation sync over Tor, FIPS anchoring, etc.
|
||||
#
|
||||
# A "node handle" is a short label (e.g. A, B, alice). For each handle you
|
||||
# register a base URL + UI password; the lib logs in and keeps that node's
|
||||
# session/CSRF cookies in its own state file so calls never cross wires.
|
||||
#
|
||||
# Usage:
|
||||
# source tests/multinode/lib/multinode.bash
|
||||
# node_register A https://192.168.1.228 password123
|
||||
# node_register B http://192.168.1.116 'ThisIsWeb54321@'
|
||||
# node_login A; node_login B
|
||||
# node_rpc A node.tor-address
|
||||
# node_result B federation.list-nodes
|
||||
#
|
||||
# Requires: curl, jq.
|
||||
#
|
||||
# Note: this is a library — it does NOT set shell options (set -u/-e), since
|
||||
# that would leak into the sourcing script. Each function guards its own vars
|
||||
# with ${var:-} defaults. Callers set their own options.
|
||||
|
||||
# Where per-node session state lives (one file per handle).
|
||||
MULTINODE_STATE_DIR="${MULTINODE_STATE_DIR:-/tmp/archy-multinode}"
|
||||
mkdir -p "$MULTINODE_STATE_DIR"
|
||||
|
||||
# handle -> base url / password, kept in associative arrays.
|
||||
declare -gA _MN_URL
|
||||
declare -gA _MN_PW
|
||||
declare -gA _MN_SESSION
|
||||
declare -gA _MN_CSRF
|
||||
|
||||
# node_register HANDLE BASE_URL PASSWORD
|
||||
node_register() {
|
||||
local h="$1" url="$2" pw="$3"
|
||||
_MN_URL[$h]="${url%/}"
|
||||
_MN_PW[$h]="$pw"
|
||||
}
|
||||
|
||||
_mn_session_file() { echo "$MULTINODE_STATE_DIR/session-$1"; }
|
||||
|
||||
# node_login HANDLE — authenticate and capture session + csrf cookies.
|
||||
node_login() {
|
||||
local h="$1"
|
||||
local url="${_MN_URL[$h]:-}" pw="${_MN_PW[$h]:-}"
|
||||
if [[ -z "$url" || -z "$pw" ]]; then
|
||||
echo "node_login: handle '$h' not registered" >&2
|
||||
return 1
|
||||
fi
|
||||
local headers; headers=$(mktemp)
|
||||
local body
|
||||
body=$(curl -sk -D "$headers" -X POST "${url}/rpc/v1" \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data-raw "{\"jsonrpc\":\"2.0\",\"method\":\"auth.login\",\"params\":{\"password\":\"${pw}\"},\"id\":1}")
|
||||
local err; err=$(echo "$body" | jq -r '.error.message // empty' 2>/dev/null)
|
||||
if [[ -n "$err" ]]; then
|
||||
echo "node_login[$h] failed: $err" >&2
|
||||
rm -f "$headers"
|
||||
return 1
|
||||
fi
|
||||
local session csrf
|
||||
session=$(grep -i '^set-cookie: session=' "$headers" | head -1 | sed -E 's/.*session=([^;]+).*/\1/' | tr -d '\r')
|
||||
csrf=$(grep -i '^set-cookie: csrf_token=' "$headers" | head -1 | sed -E 's/.*csrf_token=([^;]+).*/\1/' | tr -d '\r')
|
||||
rm -f "$headers"
|
||||
if [[ -z "$session" || -z "$csrf" ]]; then
|
||||
echo "node_login[$h]: missing session/csrf cookie" >&2
|
||||
return 1
|
||||
fi
|
||||
_MN_SESSION[$h]="$session"
|
||||
_MN_CSRF[$h]="$csrf"
|
||||
printf '%s\n%s\n' "$session" "$csrf" > "$(_mn_session_file "$h")"
|
||||
}
|
||||
|
||||
# node_rpc HANDLE METHOD [PARAMS_JSON] — raw JSON-RPC response on stdout.
|
||||
node_rpc() {
|
||||
local h="$1" method="$2" params="${3:-}"
|
||||
local url="${_MN_URL[$h]:-}"
|
||||
local session="${_MN_SESSION[$h]:-}" csrf="${_MN_CSRF[$h]:-}"
|
||||
if [[ -z "$session" || -z "$csrf" ]] && [[ -f "$(_mn_session_file "$h")" ]]; then
|
||||
mapfile -t lines < "$(_mn_session_file "$h")"
|
||||
session="${lines[0]:-}"; csrf="${lines[1]:-}"
|
||||
_MN_SESSION[$h]="$session"; _MN_CSRF[$h]="$csrf"
|
||||
fi
|
||||
local payload
|
||||
if [[ -z "$params" ]]; then
|
||||
payload=$(jq -nc --arg m "$method" '{jsonrpc:"2.0",method:$m,id:1}')
|
||||
else
|
||||
payload=$(jq -nc --arg m "$method" --argjson p "$params" '{jsonrpc:"2.0",method:$m,params:$p,id:1}')
|
||||
fi
|
||||
curl -sk -X POST "${url}/rpc/v1" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H "Cookie: session=${session}; csrf_token=${csrf}" \
|
||||
-H "X-CSRF-Token: ${csrf}" \
|
||||
--data-raw "$payload"
|
||||
}
|
||||
|
||||
# node_result HANDLE METHOD [PARAMS_JSON] — .result on success; prints error to
|
||||
# stderr and returns non-zero on RPC error.
|
||||
node_result() {
|
||||
local resp; resp=$(node_rpc "$@")
|
||||
local err; err=$(echo "$resp" | jq -r '.error.message // empty' 2>/dev/null)
|
||||
if [[ -n "$err" ]]; then
|
||||
echo "node_result[$1 $2] error: $err" >&2
|
||||
return 1
|
||||
fi
|
||||
echo "$resp" | jq '.result'
|
||||
}
|
||||
|
||||
# node_onion HANDLE — echo this node's own .onion address (empty if none).
|
||||
node_onion() {
|
||||
node_result "$1" node.tor-address 2>/dev/null | jq -r '. // empty | if type=="object" then (.onion // .address // .tor_address // empty) else . end' 2>/dev/null
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env bash
|
||||
# Controlled two-node reproduction of node-to-node federation sync.
|
||||
#
|
||||
# Pairs two real nodes via federation.invite/join, triggers federation.sync-state
|
||||
# in both directions, and reports which transport actually carried the call and
|
||||
# any per-peer error. This is the controlled repro for the reported
|
||||
# "Tor connection cloud->node not working" symptom: raw Tor transport is known
|
||||
# good (see README), so this isolates whether the APP-level sync path works and,
|
||||
# if it fails, surfaces the exact error string.
|
||||
#
|
||||
# Env (override as needed):
|
||||
# A_URL A_PW node A base url + UI password (default .116 http)
|
||||
# B_URL B_PW node B base url + UI password (default .228 https)
|
||||
# FORCE_TOR=1 set both nodes' federation transport preference to Tor first
|
||||
#
|
||||
# Usage: tests/multinode/repro-federation-sync.sh
|
||||
set -uo pipefail
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$HERE/lib/multinode.bash"
|
||||
|
||||
A_URL="${A_URL:-http://192.168.1.116}"; A_PW="${A_PW:-ThisIsWeb54321@}"
|
||||
B_URL="${B_URL:-https://192.168.1.228}"; B_PW="${B_PW:-password123}"
|
||||
|
||||
bar() { printf '\n=== %s ===\n' "$*"; }
|
||||
|
||||
node_register A "$A_URL" "$A_PW"
|
||||
node_register B "$B_URL" "$B_PW"
|
||||
|
||||
bar "login"
|
||||
node_login A || { echo "A login failed"; exit 1; }
|
||||
node_login B || { echo "B login failed"; exit 1; }
|
||||
echo "A=$A_URL B=$B_URL logged in"
|
||||
|
||||
bar "onions"
|
||||
A_ONION=$(node_onion A); B_ONION=$(node_onion B)
|
||||
echo "A onion: ${A_ONION:-<none>}"
|
||||
echo "B onion: ${B_ONION:-<none>}"
|
||||
|
||||
if [[ "${FORCE_TOR:-0}" == "1" ]]; then
|
||||
bar "force federation transport = tor on both"
|
||||
node_rpc A transport.set-preference '{"service":"federation","pref":"tor"}' | jq -c '.result // .error'
|
||||
node_rpc B transport.set-preference '{"service":"federation","pref":"tor"}' | jq -c '.result // .error'
|
||||
fi
|
||||
|
||||
bar "federation state BEFORE"
|
||||
echo "A knows:"; node_result A federation.list-nodes | jq -r '.[]? | " \(.name // "?") did=\(.did[0:24])… last_seen=\(.last_seen // "never")"' 2>/dev/null || echo " (none/err)"
|
||||
echo "B knows:"; node_result B federation.list-nodes | jq -r '.[]? | " \(.name // "?") did=\(.did[0:24])… last_seen=\(.last_seen // "never")"' 2>/dev/null || echo " (none/err)"
|
||||
|
||||
bar "pair: A invites, B joins"
|
||||
INV_A=$(node_result A federation.invite)
|
||||
CODE_A=$(echo "$INV_A" | jq -r '.code // empty')
|
||||
echo "A invite code: ${CODE_A:0:40}…"
|
||||
if [[ -n "$CODE_A" ]]; then
|
||||
node_result B federation.join "$(jq -nc --arg c "$CODE_A" '{code:$c}')" \
|
||||
&& echo "B joined A" || echo "B join FAILED"
|
||||
fi
|
||||
|
||||
bar "pair: B invites, A joins"
|
||||
INV_B=$(node_result B federation.invite)
|
||||
CODE_B=$(echo "$INV_B" | jq -r '.code // empty')
|
||||
echo "B invite code: ${CODE_B:0:40}…"
|
||||
if [[ -n "$CODE_B" ]]; then
|
||||
node_result A federation.join "$(jq -nc --arg c "$CODE_B" '{code:$c}')" \
|
||||
&& echo "A joined B" || echo "A join FAILED"
|
||||
fi
|
||||
|
||||
bar "trigger sync-state on A (A dials its peers)"
|
||||
node_result A federation.sync-state | jq '.'
|
||||
|
||||
bar "trigger sync-state on B (B dials its peers)"
|
||||
node_result B federation.sync-state | jq '.'
|
||||
|
||||
bar "federation state AFTER (look for fresh last_seen + transport)"
|
||||
echo "A knows:"; node_result A federation.list-nodes | jq -r '.[]? | " \(.name // "?") last_seen=\(.last_seen // "never") transport=\(.last_transport // .transport // "?")"' 2>/dev/null
|
||||
echo "B knows:"; node_result B federation.list-nodes | jq -r '.[]? | " \(.name // "?") last_seen=\(.last_seen // "never") transport=\(.last_transport // .transport // "?")"' 2>/dev/null
|
||||
|
||||
bar "done"
|
||||
@@ -0,0 +1,156 @@
|
||||
#!/usr/bin/env bash
|
||||
# Two-node (optionally three-node) end-to-end smoke suite for the full app.
|
||||
#
|
||||
# Unlike repro-federation-sync.sh (a diagnostic that just prints state), this
|
||||
# is an ASSERTION suite: every check is pass/fail and the script exits non-zero
|
||||
# if any required check fails. It exercises the real node-to-node surface and
|
||||
# specifically guards the bugs fixed in v1.7.94 / v1.7.95:
|
||||
# - FIPS auto-connects to the public anchor (v1.7.94)
|
||||
# - peer content browse works over the mesh, not just Tor (v1.7.95 — the
|
||||
# `/content` catalog used to 404 over FIPS and never fall back to Tor)
|
||||
# - a removed federation node stays removed, incl. transitive re-discovery
|
||||
# (v1.7.95 tombstone) — the transitive case needs node C.
|
||||
#
|
||||
# Nodes (override via env):
|
||||
# A_URL A_PW node A (default .116 http)
|
||||
# B_URL B_PW node B (default .228 https)
|
||||
# C_URL C_PW node C (OPTIONAL — enables the transitive-tombstone test)
|
||||
#
|
||||
# Requires both nodes on v1.7.95-alpha+ for the content-browse and tombstone
|
||||
# checks; older peers SKIP those (reported, not failed).
|
||||
#
|
||||
# Usage:
|
||||
# tests/multinode/smoke.sh
|
||||
# A_URL=http://192.168.1.116 B_URL=https://192.168.1.228 tests/multinode/smoke.sh
|
||||
set -uo pipefail
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$HERE/lib/multinode.bash"
|
||||
|
||||
A_URL="${A_URL:-http://192.168.1.116}"; A_PW="${A_PW:-ThisIsWeb54321@}"
|
||||
B_URL="${B_URL:-https://192.168.1.228}"; B_PW="${B_PW:-password123}"
|
||||
C_URL="${C_URL:-}"; C_PW="${C_PW:-}"
|
||||
|
||||
# ── tiny assertion framework ──────────────────────────────────────────────
|
||||
PASS=0; FAIL=0; SKIP=0
|
||||
declare -a FAILED_NAMES
|
||||
green() { printf '\033[32m%s\033[0m' "$*"; }
|
||||
red() { printf '\033[31m%s\033[0m' "$*"; }
|
||||
yellow(){ printf '\033[33m%s\033[0m' "$*"; }
|
||||
section() { printf '\n\033[1m── %s ──\033[0m\n' "$*"; }
|
||||
ok() { printf ' %s %s\n' "$(green ✓)" "$1"; PASS=$((PASS+1)); }
|
||||
no() { printf ' %s %s\n' "$(red ✗)" "$1"; FAIL=$((FAIL+1)); FAILED_NAMES+=("$1"); }
|
||||
skip() { printf ' %s %s (%s)\n' "$(yellow —)" "$1" "$2"; SKIP=$((SKIP+1)); }
|
||||
# assert_eq NAME EXPECTED ACTUAL
|
||||
assert_eq() { [[ "$2" == "$3" ]] && ok "$1" || no "$1 (expected '$2', got '$3')"; }
|
||||
# assert_true NAME VALUE — passes when VALUE is "true"
|
||||
assert_true() { [[ "$2" == "true" ]] && ok "$1" || no "$1 (got '$2')"; }
|
||||
|
||||
# did_of HANDLE — this node's own DID via node.did (string or {did:...}).
|
||||
did_of() {
|
||||
node_result "$1" node.did 2>/dev/null \
|
||||
| jq -r 'if type=="string" then . elif type=="object" then (.did // .node_did // empty) else empty end' 2>/dev/null
|
||||
}
|
||||
|
||||
# pair HANDLE_INVITER HANDLE_JOINER — invite + join one direction. Echo "ok"/"fail".
|
||||
pair() {
|
||||
local inv code
|
||||
inv=$(node_result "$1" federation.invite 2>/dev/null)
|
||||
code=$(echo "$inv" | jq -r '.code // empty' 2>/dev/null)
|
||||
[[ -z "$code" ]] && { echo "fail"; return; }
|
||||
if node_result "$2" federation.join "$(jq -nc --arg c "$code" '{code:$c}')" >/dev/null 2>&1; then
|
||||
echo "ok"
|
||||
else echo "fail"; fi
|
||||
}
|
||||
|
||||
node_register A "$A_URL" "$A_PW"
|
||||
node_register B "$B_URL" "$B_PW"
|
||||
HAVE_C=0
|
||||
if [[ -n "$C_URL" && -n "$C_PW" ]]; then node_register C "$C_URL" "$C_PW"; HAVE_C=1; fi
|
||||
|
||||
# ── 1. reachability + auth ────────────────────────────────────────────────
|
||||
section "reachability + login"
|
||||
node_login A && ok "A login ($A_URL)" || { no "A login ($A_URL)"; echo "A unreachable — aborting"; exit 1; }
|
||||
node_login B && ok "B login ($B_URL)" || { no "B login ($B_URL)"; echo "B unreachable — aborting"; exit 1; }
|
||||
if [[ $HAVE_C == 1 ]]; then node_login C && ok "C login ($C_URL)" || { no "C login"; HAVE_C=0; }; fi
|
||||
|
||||
A_ONION=$(node_onion A); B_ONION=$(node_onion B)
|
||||
[[ -n "$A_ONION" ]] && ok "A has onion address" || no "A has onion address"
|
||||
[[ -n "$B_ONION" ]] && ok "B has onion address" || no "B has onion address"
|
||||
|
||||
# ── 2. FIPS mesh: daemon up + anchor connected (v1.7.94) ──────────────────
|
||||
section "FIPS mesh / anchor"
|
||||
for h in A B; do
|
||||
s=$(node_result "$h" fips.status 2>/dev/null)
|
||||
if [[ -z "$s" ]]; then skip "$h fips.status" "no FIPS RPC (old build?)"; continue; fi
|
||||
assert_true "$h FIPS service active" "$(echo "$s" | jq -r '.service_active')"
|
||||
ac=$(echo "$s" | jq -r '.anchor_connected')
|
||||
if [[ "$ac" == "true" ]]; then ok "$h anchor connected"
|
||||
else skip "$h anchor connected" "anchor_connected=$ac — node may need v1.7.94 + a moment to handshake"; fi
|
||||
done
|
||||
|
||||
# ── 3. federation pairing (both directions) ───────────────────────────────
|
||||
section "federation pairing"
|
||||
assert_eq "A invites, B joins" "ok" "$(pair A B)"
|
||||
assert_eq "B invites, A joins" "ok" "$(pair B A)"
|
||||
# both should now list each other
|
||||
node_result A federation.sync-state >/dev/null 2>&1
|
||||
node_result B federation.sync-state >/dev/null 2>&1
|
||||
A_SEES_B=$(node_result A federation.list-nodes 2>/dev/null | jq -r --arg o "${B_ONION%.onion}" 'any((.nodes // .)[]?; (.onion // "" | gsub("\\.onion$";"")) == $o)')
|
||||
B_SEES_A=$(node_result B federation.list-nodes 2>/dev/null | jq -r --arg o "${A_ONION%.onion}" 'any((.nodes // .)[]?; (.onion // "" | gsub("\\.onion$";"")) == $o)')
|
||||
assert_true "A's node list contains B" "$A_SEES_B"
|
||||
assert_true "B's node list contains A" "$B_SEES_A"
|
||||
|
||||
# ── 4. peer content browse over the mesh (v1.7.95 fix) ────────────────────
|
||||
section "peer content browse (was: 404 over mesh, no Tor fallback)"
|
||||
if [[ -n "$B_ONION" ]]; then
|
||||
resp=$(node_rpc A content.browse-peer "$(jq -nc --arg o "$B_ONION" '{onion:$o}')")
|
||||
err=$(echo "$resp" | jq -r '.error.message // empty')
|
||||
if [[ -z "$err" ]]; then
|
||||
ok "A browses B's content catalog (HTTP 200)"
|
||||
elif echo "$err" | grep -q '404'; then
|
||||
no "A browses B's content — still 404 over mesh (is B on v1.7.95?): $err"
|
||||
else
|
||||
# Other errors (peer offline, no content shared) are environmental, not the bug.
|
||||
skip "A browses B's content" "non-404 error: $err"
|
||||
fi
|
||||
else
|
||||
skip "A browses B's content" "B has no onion"
|
||||
fi
|
||||
|
||||
# ── 5. removed-node tombstone (v1.7.95) ───────────────────────────────────
|
||||
section "removed-node tombstone"
|
||||
B_DID=$(did_of B)
|
||||
if [[ -z "$B_DID" ]]; then
|
||||
skip "remove B then verify stays removed" "couldn't resolve B's DID"
|
||||
else
|
||||
if node_result A federation.remove-node "$(jq -nc --arg d "$B_DID" '{did:$d}')" >/dev/null 2>&1; then
|
||||
still=$(node_result A federation.list-nodes 2>/dev/null | jq -r --arg d "$B_DID" 'any((.nodes // .)[]?; .did == $d)')
|
||||
assert_eq "B removed from A's list" "false" "$still"
|
||||
# Transitive test needs C: A federated with B and C; C federated with B;
|
||||
# A removes B; A syncs with C (who advertises B) → B must NOT reappear.
|
||||
if [[ $HAVE_C == 1 ]]; then
|
||||
pair A C >/dev/null; pair C A >/dev/null; pair C B >/dev/null
|
||||
node_result A federation.sync-state >/dev/null 2>&1
|
||||
reappeared=$(node_result A federation.list-nodes 2>/dev/null | jq -r --arg d "$B_DID" 'any((.nodes // .)[]?; .did == $d)')
|
||||
assert_eq "B does NOT reappear via transitive sync with C" "false" "$reappeared"
|
||||
else
|
||||
skip "transitive reappear via 3rd node" "set C_URL/C_PW to enable"
|
||||
fi
|
||||
# re-add restores B (explicit re-add clears the tombstone)
|
||||
pair B A >/dev/null
|
||||
node_result A federation.sync-state >/dev/null 2>&1
|
||||
readded=$(node_result A federation.list-nodes 2>/dev/null | jq -r --arg d "$B_DID" 'any((.nodes // .)[]?; .did == $d)')
|
||||
assert_true "explicit re-pair brings B back (tombstone cleared)" "$readded"
|
||||
else
|
||||
skip "remove B" "remove-node RPC failed (B may already be absent)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── summary ───────────────────────────────────────────────────────────────
|
||||
section "summary"
|
||||
printf ' %s passed, %s failed, %s skipped\n' "$(green $PASS)" "$([[ $FAIL -gt 0 ]] && red $FAIL || echo $FAIL)" "$(yellow $SKIP)"
|
||||
if [[ $FAIL -gt 0 ]]; then
|
||||
printf ' failed:\n'; for n in "${FAILED_NAMES[@]}"; do printf ' - %s\n' "$n"; done
|
||||
exit 1
|
||||
fi
|
||||
echo " all required checks passed"
|
||||
@@ -0,0 +1,260 @@
|
||||
# ▶▶ 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.
|
||||
|
||||
**Resume command (run elsewhere):**
|
||||
```
|
||||
cd ~/Projects/archy && git fetch gitea-vps2 && git checkout main && git reset --hard gitea-vps2/main && cat tests/production-quality/TRACKER.md
|
||||
```
|
||||
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)**, **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 — 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.
|
||||
|
||||
**Ship .97 when ready:** ./scripts/create-release.sh 1.7.97-alpha (curate CHANGELOG ≥3 layman bullets first + run scripts/sync-whats-new.py; SKIP_RELEASE_TESTS=1 only for the 2 known-flaky vitest timing tests) → scripts/publish-release-assets.sh 1.7.97-alpha gitea-vps2 → git push gitea-vps2 main + tag. (gitea-local push fails: token rejected — non-blocking.)
|
||||
|
||||
---
|
||||
|
||||
# Production-Quality Bug Tracker
|
||||
|
||||
Living tracker for the post-v1.7.96 "no new features until production quality" push.
|
||||
Updated continuously as we investigate → fix → test → pass. Kept in-repo so progress
|
||||
survives a session cutoff.
|
||||
|
||||
## Rules (from user, 2026-06-15)
|
||||
- **No new features** until the OS is production / no-bugs quality.
|
||||
- **Test-harness-first**: build/extend a harness for each bug before fixing.
|
||||
- **Validate every fix on `.116` + `.198`** (both 192.168.1.x, pw ThisIsWeb54321@) **+ the harness** BEFORE it goes into any release. (.198 still carries the LND CORS nginx duplicate → good for fix-(a) validation; .116 does not.)
|
||||
- **Priority order**: cloud/federated-nodes + mesh FIRST, then app-specific, then low-pri.
|
||||
|
||||
## Status legend
|
||||
`TODO` · `INVESTIGATING` · `ROOT-CAUSED` · `FIXING` · `TESTING` (on .116+harness) · `PASSED` · `SHIPPED`
|
||||
|
||||
## Release status
|
||||
- **v1.7.96-alpha — SHIPPED** (2026-06-15). Live on vps2 (primary OTA): manifest v1.7.96-alpha, assets HTTP 200, `main@8c3c7954` + tag present. Contents: kiosk grid removal + FIPS TCP/UDP anchor selector. NOTE: gitea-local (localhost) mirror push failed (token rejected → /login); non-blocking, needs refreshed token.
|
||||
- **v1.7.97-alpha — IN PROGRESS** (this push). Will bundle the verified fixes below.
|
||||
|
||||
---
|
||||
|
||||
## 🔴🔴 TOP PRIORITY
|
||||
|
||||
### B5 — LND "connect your wallet" details/QR broken fleet-wide — ROOT-CAUSED
|
||||
Origin: user escalation. Symptom: LND connect screen (served on app port :18083) can't load details/QR.
|
||||
Two distinct root causes (confirmed live):
|
||||
- **(a) Duplicate ACAO** on `/lnd-connect-info` (seen on .103): backend sets `Access-Control-Allow-Origin` (proxy.rs:108) AND nginx `add_header` adds a second → browser rejects "multiple values". nginx config drift. Fix: bootstrap.rs nginx patch must strip the redundant `add_header` from the `/lnd-connect-info` location (backend owns CORS).
|
||||
- **(b) No ACAO on `/proxy/lnd/v1/*` 401** (fleet-wide): the unauth/auth-layer 401 is produced before the CORS-adding proxy handler (proxy.rs:135 `handle_lnd_proxy`). Browser → "No 'Access-Control-Allow-Origin' header". Fix: ensure auth-layer/early-return responses for `/proxy/lnd` + `/lnd-connect-info` carry CORS headers.
|
||||
- `.116` `/lnd-connect-info` returns a single correct ACAO → symptom varies by node's nginx state.
|
||||
- Backend CORS helper: handler/mod.rs `app_cors_origin()` (:270) — reflects Origin when its host == request host.
|
||||
- Backend change → ships in .97. **Status: ✅ PASSED — verified on .116, .198, .103 (harness 4/4 each). Ready to bundle into .97.**
|
||||
- Caveat: bootstrap's nginx dup-strip runs a few seconds AFTER /health goes green (async patch+reload) — converges within ~1 min of restart; not instant. Acceptable.
|
||||
- **CODE CHANGES MADE (uncommitted):**
|
||||
- `core/archipelago/src/bootstrap.rs`: added `NGINX_LND_DUP_CORS` const + strip in `patch_nginx_conf()` (removes the duplicate nginx `add_header` ACAO from `/lnd-connect-info` so the backend's single header wins). Idempotent; runs on startup nginx bootstrap. → fixes (a)
|
||||
- `core/archipelago/src/api/handler/mod.rs`: new `unauthorized_cors(origin)` helper (:~205) + `/proxy/lnd/` route (:~505) computes origin first and returns `unauthorized_cors` so the 401 carries ACAO. → fixes (b)
|
||||
- Test on **.116** for (b); test on **.103** for (a) [.116 has no dup to strip].
|
||||
- **2026-06-15 RESULT — .116 (fix b): harness 4/4 PASS** (sideloaded built binary, restarted). `/proxy/lnd/v1/*` now returns CORS on the 401. ✅
|
||||
- (Correction: an earlier "LND container MISSING" reading was a FALSE alarm — `docker` isn't in the non-interactive PATH; runtime is **podman**. Verified `lnd Up 9h` — containers SURVIVED the restart cleanly.)
|
||||
- Next: deploy to .103 + run harness to confirm fix (a) (nginx dup strip).
|
||||
- **Harness:** `tests/production-quality/lnd-cors-test.sh <node>` — asserts single correct ACAO on /lnd-connect-info + ACAO present on /proxy/lnd/v1/{getinfo,channels}. Baseline (2026-06-15): .116 = 2 pass/2 fail (proxy missing ACAO); .103 = 1 pass/3 fail (connect-info dup + proxy missing).
|
||||
- **FIX PLAN (precise):**
|
||||
1. (b) handler/mod.rs:504-508 `/proxy/lnd/` returns `Self::unauthorized()` (401, NO CORS) when session check fails → browser CORS wall. Add CORS (app_cors_origin) to that 401. Same pattern for any other app-origin early-return.
|
||||
2. (a) nginx `/lnd-connect-info` location double-adds ACAO (backend + nginx `add_header`). Strip the nginx `add_header Access-Control-Allow-Origin` there; backend owns CORS. Update bootstrap.rs nginx patch to remove it on existing nodes (idempotent).
|
||||
- Verify: rebuild backend, deploy to .116, run harness → expect 3/3 (or 4 assertions) PASS on .116 AND .103.
|
||||
|
||||
---
|
||||
|
||||
## 🔴 PRIORITY — cloud / federation / mesh
|
||||
|
||||
### B1 — Trusted-node list not clean — PASSED (onion-dedup; unit test 2/2; live .198 15→13 distinct, healthy). UI visual-confirm recommended.
|
||||
Dupes, erroneous names, and non-convergent group membership across nodes. Expected: trusted nodes form a transitive group (every node connects to any newly-added trusted node; all nodes show the same set). `.103` has a long/dirty list.
|
||||
|
||||
### B2 — Duplicate chat contact for one node — PASSED (resolved by load-dedup feeding mesh seed; unit-tested). UI visual-confirm recommended.
|
||||
Federated peer "sapien" shows TWO chats: one "sapien" WITHOUT archy logo (looks non-federated) + one named by raw DID `did:key:z6MkoSbN5CM7fBaQg2nWbCymEkFXsHnuXvec9Mjo5RtJf9dQ`. Same node keyed by both federated identity and raw DID → merge to one. Code: core/archipelago/src/mesh + mesh/typed_messages.rs (note :233 — meshcore adverts don't carry archy pubkey).
|
||||
|
||||
### B3 — Cloud peer media won't preview/play — FIXING (code done: /api/peer-content streaming proxy + playMedia streams free content)
|
||||
Music/video preview files on peer nodes' cloud don't play (streaming/range/content-type over mesh+Tor peer fetch).
|
||||
|
||||
### B4 — Cloud "my folders" fails (JSON parse / 502) — PASSED (content-type guard; built, guard in bundle, deployed .198). UI visual-confirm recommended.
|
||||
`Unexpected token '<', "<!doctype"` when FileBrowser absent (`/app/filebrowser/api/resources` → SPA index.html), and **502** when FileBrowser is down (seen on .103). filebrowser-client.ts:102/:106. Fix: detect FileBrowser unavailable, friendly prompt; consider nginx returning JSON 404/502 for missing `/app/<app>/` instead of SPA shell. Handle BOTH absent + down.
|
||||
|
||||
### B14 — cloud browse transport not recorded — FIXED (record_peer_transport in 4 content handlers; build OK). NOTE: live data shows FIPS reaches only ~4/15 peers, 6 fall back to Tor genuinely → see B14b.
|
||||
Browsing trusted/peer nodes in the Cloud tab connects over Tor instead of FIPS (should prefer FIPS like the rest of mesh; same for peer browsing). cf project_fips_integration, project_tor_node_to_node_works (last_transport should be fips/mesh).
|
||||
|
||||
---
|
||||
|
||||
## 🟠 APP-SPECIFIC
|
||||
|
||||
### B6 — ElectrumX install gate — PARTIAL (pruned-node gate already works; "no node present" half DEFERRED: false-positive risk without UI test, needs package-presence check)
|
||||
Show the yellow requirement badge when no full node / only a pruned node is present (reuse existing yellow badge pattern).
|
||||
|
||||
### B7 — ElectrumX UI stuck loader on top — FIXED (overlay hides + iframe shows when status stale; type-check green). UI-confirm.
|
||||
UI renders but a loader sits on top; possibly stale pre-sync screen not clearing.
|
||||
|
||||
### B9 — IndeedHub keeps stopping on nodes — TODO
|
||||
Container won't stay running (crash-loop / reconcile stop). Check logs + restart policy + health.
|
||||
|
||||
### B10 — Immich still crashes — TODO
|
||||
Recurring crash ("still" → prior attempts). Check container logs + resource limits + DB/ML deps.
|
||||
|
||||
### B11 — Companion app: "open in external browser" apps don't work — TODO
|
||||
Apps meant to open in a new/external browser don't launch from the companion app; need the phone-default-browser request-modal pattern mobile apps use. Relates to v1.7.90 "open in new tab from companion app".
|
||||
|
||||
### B12 — Mempool not connecting — FIXED (mempool host detect, 3 paths; unit-tested). Live bitcoin-core validation PENDING (no core node available).
|
||||
**Bigger than the original "stacks.rs:1278" framing.** `CORE_RPC_HOST=bitcoin-knots` was hardcoded in THREE env-render paths; on a bitcoin-core node the container is named `bitcoin-core`, so mempool-api can't resolve RPC. Both Knots and Core are reachable on `archy-net` by container name — only the name differs.
|
||||
- **Path 1 — legacy direct-podman** (`stacks.rs::install_mempool_stack`, used when no orchestrator): now `format!("CORE_RPC_HOST={}", detect_bitcoin_rpc_host())`. FIXED.
|
||||
- **Path 2 — `config.rs::get_app_config`** (install.rs legacy path): same. FIXED.
|
||||
- **Path 3 — Quadlet/manifest (THE MODERN FLEET PATH, e.g. .198)**: `prod_orchestrator` renders env from `apps/mempool-api/manifest.yml` static YAML. FIXED via a new `{{BITCOIN_HOST}}` derived-env placeholder: `HostFacts.bitcoin_host` (container/manifest.rs) + `resolve_derived_env` renders it; `prod_orchestrator::bitcoin_host()` detects Knots/Core via `podman ps` (test-injectable `set_bitcoin_host_for_test`); resolved on-demand only for manifests using the placeholder (perf). mempool-api manifest moved `CORE_RPC_HOST` from static env → `derived_env: {{BITCOIN_HOST}}`.
|
||||
- New helper `dependencies::detect_bitcoin_rpc_host()` + pure `pick_bitcoin_host()`.
|
||||
- **TESTS (all green):** `pick_bitcoin_host` 5 cases (knots/core/plain/none/substring-safety); container-crate `resolve_derived_env` renders `{{BITCOIN_HOST}}`; orchestrator `mempool_core_rpc_host_follows_bitcoin_node` (core→bitcoin-core, knots→bitcoin-knots). No-regression verified: picker returns `bitcoin-knots` live on .198 (so Knots nodes unchanged; existing mempool installs see no env drift).
|
||||
- **VALIDATION GAP:** cannot exercise on a live bitcoin-core node (none available; .198 is Knots where the fix is a no-op). Need a Core node to confirm end-to-end.
|
||||
- **FOLLOW-UP (B12b, NOT done):** same hardcode exists for siblings on bitcoin-core nodes — `config.rs` lnd(:724)/btcpay(:739)/electrumx(:782), and `prod_orchestrator::resolve_dynamic_env` fedimint `FM_BITCOIND_URL=...bitcoin-knots` (~:2425). Plus mempool-api manifest `dependencies: bitcoin-knots` (line 18) is Knots-specific bookkeeping (install-time check already accepts Core via BITCOIN_NAMES, so non-blocking). All can reuse `{{BITCOIN_HOST}}`. Deferred per user (mempool-only scope) — each needs its own validation, esp. LND/fedimint.
|
||||
- **NOTE (unrelated pre-existing failures):** 4 prod_orchestrator tests fail on clean HEAD too — `install_applies_data_uid_chown_before_create`, `install_writes_manifest_generated_files_before_create`, `manifest_generated_files_{do_not_overwrite_by_default,can_overwrite_when_declared}` — their fixtures pass tempdir volume sources that `validate_bind_source` rejects (only `/var/lib/archipelago/*` + 2 sockets allowed). NOT caused by B12; worth a separate fix.
|
||||
mempool can't reach the Bitcoin backend on some nodes. Investigate on .116. Check mempool→electrs→bitcoind wiring + deps.
|
||||
|
||||
### B13 — Fedimint UI not applying CSS — FIXED + VERIFIED on .198 (both HTTP + HTTPS)
|
||||
Root cause confirmed: the Fedimint Guardian page (served by :8175) is a server-rendered status page with ~7.8KB INLINE CSS plus image assets referenced root-rooted (`src="/assets/img/app-icons/fedimint.jpg"`, `url("/assets/img/bg-network.jpg")`). Without an asset rewrite those `/assets/...` URLs resolve against the archipelago SPA root: `bg-network.jpg` happens to exist there (shared design asset → loaded by luck) but `app-icons/fedimint.jpg` does NOT → **404** (the broken/visibly-missing icon). The `location /assets/` block uses `try_files $uri =404`, so missing fedimint assets 404 rather than fall through.
|
||||
|
||||
Fix = nginx sub_filter set that reroots every root-rooted asset URL (`href="/`, `src="/`, `url("/`, and single-quote variants) under `/app/fedimint/`, plus `proxy_set_header Accept-Encoding ""` so the upstream doesn't gzip (sub_filter can't rewrite gzipped bodies). Shipped two ways:
|
||||
- **Fresh ISOs** (committed a50b6df2): templates `image-recipe/configs/nginx-archipelago.conf` (HTTP) + `image-recipe/configs/snippets/archipelago-https-app-proxies.conf` (HTTPS).
|
||||
- **Already-deployed nodes** (bootstrap self-heal, this commit): `core/archipelago/src/bootstrap.rs::patch_nginx_conf` now heals BOTH the main conf (Style A — swaps the old single nostr-provider sub_filter tail for the full reroot set, byte-matches the shipped template) AND the HTTPS app-proxy snippet (Style B — anchors on the unique `:8175` proxy_pass and inserts the reroot set; robust to the snippet's varying trailing directive). `missing_*` flags now gated on their splice anchors so the healed snippet early-returns cleanly (no per-boot warn-skips). Idempotent via the `'href="/' 'href="/app/fedimint/'` marker.
|
||||
|
||||
VERIFIED on .198 (sideloaded built binary, restart, async self-heal converged ~15s):
|
||||
- HTTP `/app/fedimint/`: live conf healed byte-identical to template; app-icon **404→200 image/jpeg (41944b)**.
|
||||
- HTTPS `/app/fedimint/` (snippet): healed; same app-icon **404→200**; bg-network 200; root `/assets/img/app-icons/fedimint.jpg` returns 200 **text/html** (SPA shell) — proving the reroot is necessary.
|
||||
- `nginx -t` OK both times; containers survived restart (Quadlet); both files carry the marker exactly once (idempotent steady state); no warn spam in logs.
|
||||
NOTE: self-healed snippet is functionally correct but NOT byte-identical to the fresh-ISO snippet template (insert-after-proxy_pass vs full block) — acceptable; nginx ignores directive order/whitespace.
|
||||
|
||||
### 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 — 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 — 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.
|
||||
|
||||
### B19 — Failed download-update lands on Install button (should be Download) — TODO
|
||||
When an update download fails, the UI sometimes shows the Install button instead of returning to the Download button — a big UX issue (user can't retry the download cleanly). Check the SystemUpdate state machine's error/failure transition.
|
||||
|
||||
### B20 — Surface bitcoin-headers-over-mesh broadcast (send/receive toggles) — TODO (feature-adjacent, surfacing existing work)
|
||||
We previously broadcast bitcoin block headers over mesh to archipelago nodes but never fully surfaced it. Want two switches: "send headers" (you broadcast) and "receive headers" (you accept). NOTE: this is feature-adjacent — surfacing existing functionality; the user added it during the no-new-features push, so treat as low-priority polish until the bug list is clear. Code: mesh block-headers (mesh.block-headers RPC seen in logs; core/archipelago/src/mesh).
|
||||
|
||||
### B14b — FIPS reachability: many peers fall back to Tor — INVESTIGATED (needs FIPS-network depth)
|
||||
Live (2026-06-15) federation sync last_transport on .116/.198: ~4 peers fips, ~6 tor, ~5 none. So beyond the recording fix (B14), FIPS genuinely doesn't reach many federated peers (they use Tor). Investigate WHY: is fips_npub known for those peers? are they FIPS-online? is the shared anchor connecting them? (cf project_fips_integration, project_tor_node_to_node_works). This is the real "Tor not FIPS" depth.
|
||||
FINDINGS (.198, 2026-06-15): archipelago-fips ACTIVE; ALL 13 peers HAVE fips_npub; last_transport = 5 fips / 5 tor / 3 none. So it's NOT a missing-npub or service-down bug — FIPS genuinely reaches some peers and not others = DIAL-TIME reachability: the 'tor' peers aren't FIPS-reachable at dial time (offline, NAT, their FIPS not registered with the shared anchor), and 'none' = fully offline (X250 roam/beta/cellular). NEXT (deeper, needs FIPS-network debugging): verify a known-online peer (e.g. .228/.116) is reachable over FIPS from .198 right now; if an online FIPS peer still falls back to Tor → real anchor/registration bug; check fips daemon peer table + anchor connectivity. Likely partly peer-availability (not fully fixable in code).
|
||||
|
||||
### B21 — Show Tor/FIPS transport pill on cloud browse — FIXED (build+type-check green; deploy+UI-confirm on .116/.198)
|
||||
Tag whether the peer connection is Tor or FIPS and surface it as a small pill on the cloud browse screens / connection loader. Data source: federation node last_transport (now recorded by B14) exposed via federation.list-nodes; frontend renders a pill (FIPS=fast/green, Tor=slower) on PeerFiles.vue / Cloud peer view + the connection loader. Frontend-only-ish. FINDINGS: PeerFiles.vue:46 loader HARDCODES 'Connecting via Tor...' even when FIPS used (bug). Frontend types already have last_transport ('fips'|'tor'|'mesh'|'lan') federation/types.ts:31; NodeList.vue:167 already renders a transport indicator. PLAN: have content.browse-peer RETURN the transport used (B14 already computes it) → frontend shows a pill (FIPS green / Tor amber) on PeerFiles header + fix the loader text to reflect actual/attempted transport. Small backend (add transport to browse response) + frontend pill.
|
||||
|
||||
### B22 — Peer cloud download/audio errors (.228→.198) — TODO (pairs with B3)
|
||||
Observed 2026-06-15 browsing .228's cloud from .198: (a) downloading a peer cloud file → "Operation failed. Check server logs for details." (b) playing a peer AUDIO file → "Could not play audio. File Browser may not be running." (misleading — it's a peer file, not File Browser; that's the OLD base64/blob path B3 replaces). ACTION: (a) check content.download-peer backend error on .198 logs while downloading (likely the same Range/transport/timeout path as B3, or a peer-side 4xx); (b) verify B3 streaming fixes peer audio once deployed, and fix the misleading audioPlayer error string. Get server logs: ssh .198, journalctl -u archipelago | grep -i 'content\|peer\|download'.
|
||||
|
||||
### B23 — Archipelago group chat (all nodes) broken/slow over Tor — TODO (PRIORITY, mesh)
|
||||
The all-nodes "Archipelago group" chat (over Tor) doesn't seem to work. Facets:
|
||||
- (a) Group delivery unreliable / "doesn't work" over Tor.
|
||||
- (b) Messages may just be VERY SLOW (latency — likely Tor-only path; should use FIPS+Tor per the new transport method like B14, preferring FIPS).
|
||||
- (c) Add the SENDER CONTACT NAME to each message so you can differentiate who sent what (group messages lack attribution).
|
||||
- (d) Messages sometimes DUPLICATED (dedup by message id / sender_seq — cf mesh.ts:73 cross-transport identity (sender_pubkey, sender_seq); duplicate likely from receiving same msg over both transports or re-broadcast).
|
||||
Code: core/archipelago/src/mesh (typed_messages, listener), frontend Mesh.vue/stores/mesh.ts. Relates to B2 (identity), B14/B14b (transport). Test on .116/.198 (+ a Tor-only peer like .228).
|
||||
|
||||
### B8 — netbird app doesn't work — TODO (LOW / much later)
|
||||
|
||||
(RETRACTED: CryptPad placeholder-icon — user says cryptpad is fine.)
|
||||
|
||||
---
|
||||
|
||||
## 📋 vps2 Gitea issues (lfg2025/archy) — imported 2026-06-15
|
||||
- G#1 [Bug] Strange peer request behaviour — TODO (likely related to B1/federation)
|
||||
- G#2 [Bug] Fix flashing USB from kiosk — TODO
|
||||
- G#3 [Feature] VPN Configuration — DEFERRED (feature; no new features until production quality)
|
||||
- G#4 [Bug] Bitcoind is slow — TODO
|
||||
- G#5 [Feature] OpenWRT and TollGate integration — DEFERRED (feature)
|
||||
- G#6 [Feature] Move dashboard/monitoring link to home screen — DEFERRED (feature)
|
||||
- G#7 [Bug] Scrolling with Companion app — TODO
|
||||
|
||||
---
|
||||
|
||||
## Gitea issue mapping (vps2 lfg2025/archy)
|
||||
All backlog bugs now mirrored as Gitea issues: B1→#8, B2→#9, B3→#10, B4→#11, B5→#12, B6→#13, B7→#14, B8→#15, B9→#16, B10→#17, B11→#18, B12→#19, B13→#20, B14→#21, B15→#22, B16→#23, B17→#24, B18→#25, B19→#26. (Pre-existing G#1–7 remain; some overlap, e.g. G#1 strange-peer ≈ B1.) Close the Gitea issue when a bug is verified+shipped.
|
||||
|
||||
## INVESTIGATION FINDINGS 2026-06-15 (B1/B2/B3/B4/B14) — cutoff insurance
|
||||
|
||||
**B1 trusted-node divergence** — ROOT-CAUSED. `federation/sync.rs` `merge_transitive_peers()` (~:140) dedupes ONLY by DID; the SAME physical node appears under multiple DIDs (same `onion` + `fips_npub`) → duplicate entries ("Arch Dev" ×2, "Sapien" ×2). No background convergence → lists diverge (.103=16 nodes, .116/.198=15). Model: `federation/types.rs:24` FederatedNode (PK=did); storage `federation/storage.rs` nodes.json; add_node dedupes by DID only (:125). FIX: in merge_transitive_peers add a SECOND match arm — if no DID match, match by normalized `onion` (trim .onion); if found, treat as same node (merge fips_npub/name, don't add). Same dedup on add_node. Plus a one-time cleanup of existing dup DIDs (remove-node the stale one). TEST: after sync, all 3 nodes have identical node set, no two entries share an onion.
|
||||
|
||||
**B2 duplicate chat contact** — ROOT-CAUSED (same root as B1). Two federation DIDs (same onion/fips_npub, e.g. "Sapien" dids z6MkoSbN… + z6MkeYMU…) get seeded as TWO mesh contacts: `mesh/mod.rs` `seed_federation_peers_into_mesh()` (~:94) upserts per-pubkey contact_id; frontend `Mesh.vue` `mergeKeyForPeer()` (~:492) keys by DID so two DIDs = two rows. FIX: (backend) in seed, skip a node whose onion was already seeded (HashSet of onions); (frontend) Mesh.vue merge by onion when DIDs differ but onion matches. Fixing B1's onion-dedup largely resolves this too. TEST: one "Sapien" row; `mesh.peers` has one contact for the shared onion.
|
||||
|
||||
**B3 peer media won't play** — ROOT-CAUSED. `PeerFiles.vue` `playMedia()`/`loadPreview()` (~:358,:508) fetch the WHOLE file via RPC `content.preview-peer`/`content.download-peer` (`api/rpc/content.rs` :393,:213) which base64-encodes the entire file; frontend makes a Blob URL → browser can't Range-seek → video/large-audio won't play (+ 30/120s timeouts truncate big files). The peer's HTTP `/content/<id>` handler (`api/handler/content.rs` :49) ALREADY supports Range/206 + Accept-Ranges. FIX (bigger): add a local streaming proxy endpoint `/api/peer-content/{onion}/{id}` in `api/handler/mod.rs` that forwards the browser's Range header to the peer's `/content/<id>` (via fips::dial PeerRequest) and streams back 206 + Content-Range + Content-Type; frontend sets `<video>/<audio>` src to that URL (not a blob). TEST: curl Range on the new endpoint → 206 + Content-Range; video seeks/plays.
|
||||
|
||||
**B4 cloud my-folders <!doctype/502** — ROOT-CAUSED. `filebrowser-client.ts` `listDirectory()` (:99) does `res.json()` (:106) after only an `res.ok` check; when FileBrowser is ABSENT nginx serves SPA index.html (200, '<!doctype') → JSON crash; when DOWN → 502. FIX (frontend, low-risk): guard res content-type !== application/json → throw typed "FileBrowser unavailable" handled by Cloud.vue/CloudFolder.vue empty-state; same guard in login() (:71) + getUsage() (:215). OPTIONAL nginx: add `error_page 502 503 = @filebrowser_unavailable` returning JSON in the /app/filebrowser/ block (image-recipe/configs/nginx-archipelago.conf ~:411). TEST: stop filebrowser on .116/.198 → Cloud shows friendly state, no doctype crash.
|
||||
|
||||
**B14 cloud browse Tor-not-FIPS** — ROOT-CAUSED (nuance). FIPS-first logic WORKS (`fips/dial.rs` send_get :331 tries FIPS, falls back to Tor on 404/5xx; v1.7.94 fix). BUT the 4 content handlers in `api/rpc/content.rs` (browse :297, download :237, download_paid :356, preview :421) capture `_transport` and NEVER call `record_peer_transport()` → UI badge shows Tor/null even when FIPS used. FIX: add `record_peer_transport(data_dir, None, Some(onion), &transport.to_string())` after each successful send_get (storage.rs:84 has the fn). ⚠️ VERIFY on nodes whether FIPS is ACTUALLY used or genuinely falling back to Tor (if genuinely Tor, deeper FIPS-reachability issue beyond recording). TEST: after browse, last_transport = fips (when peer FIPS-reachable).
|
||||
|
||||
## INVESTIGATION FINDINGS 2026-06-15 (B6/B7/B12/B13/B15/B16) — cutoff insurance
|
||||
|
||||
**B13 Fedimint CSS** — app HTML (docker/fedimint-ui/index.html) uses absolute /assets/* paths; under /app/fedimint/ the browser requests /assets/* which hit the main SPA, not :8175 → unstyled. FIX: nginx sub_filter rewrite (same proven pattern as indeedhub/botfights blocks) in image-recipe/configs/nginx-archipelago.conf (/app/fedimint/ ~:641) + snippets/archipelago-https-app-proxies.conf (~:164) + bootstrap patch for existing nodes. Rewrites href/src/url '/' → '/app/fedimint/'. TEST: curl .../app/fedimint/assets/...css → 200 real CSS.
|
||||
|
||||
**B6 ElectrumX archival gate** — electrs needs a NON-pruned full node; install card doesn't warn at a glance. /bitcoin-status returns blockchain_info.pruned. Yellow badge pattern exists (MarketplaceAppCard.vue). FIX (frontend, simple): show a yellow "Requires a full archive Bitcoin node (not pruned)" note on the electrumx card (MarketplaceAppCard.vue ~:53). catalog.json electrumx already has requires.
|
||||
|
||||
**B7 ElectrumX stuck loader** — sync overlay gated by electrsSync (useElectrsSync.ts syncing = status!=='synced'); if status never flips to 'synced' (stale/crash) the overlay blocks the UI forever. AppSessionFrame.vue:44 iframe gate `!electrsSync`. FIX (frontend): fail-open — allow iframe when electrsSync?.stale (and add a timeout in useElectrsSync.ts so a slow/stale status stops blocking after ~5min).
|
||||
|
||||
**B15 bitcoin sync UI lag** — Home.vue:485 polls every 30s. FIX: faster bitcoin refresh (~5-10s) (separate interval for bitcoin vs system stats).
|
||||
|
||||
**B16 bitcoin status vanishes** — homeStatus.ts refreshBitcoin clears/leaves bitcoinAvailable null on a failed/transitional poll → HomeSystemCard.vue:60 v-if hides the card. FIX: retain last-known bitcoinAvailable on transient failure + show an "Updating…" badge instead of disappearing.
|
||||
|
||||
**B12 mempool not connecting** — stacks.rs:1278 + apps/mempool-api/manifest.yml:50 hardcode CORE_RPC_HOST=bitcoin-knots; on nodes running bitcoin-core (not knots) mempool-api gets getaddrinfo ENOTFOUND bitcoin-knots. Also ELECTRUM_HOST=electrumx absent on pruned nodes (docs/CONTAINER_LIFECYCLE_HANDOFF.md:654). FIX: detect which bitcoin container runs (knots vs core) + set CORE_RPC_HOST dynamically; qualify the mempool stack so it doesn't half-start without electrumx. Backend (stacks.rs) — medium risk, test on .116.
|
||||
|
||||
- 2026-06-15 (cont. 2): **B15 ✅** (poll 30s→10s) + **B7 ✅** (ElectrumX loader fail-open on stale) — committed `c0d41cf8`, type-check green. **B6 PARTIAL** (pruned gate already works; no-node-present half deferred). Fanned out investigations for B6/B7/B12/B13/B15/B16 — all root-caused with fix plans in FINDINGS above.
|
||||
- **DEFERRED with ready plans (need a backend build + careful patch, or UI test, or live repro):** B13 (fedimint CSS — nginx sub_filter asset rewrite; bootstrap exact-match patch is fragile, do carefully), B12 (mempool host — dynamic bitcoin-knots/core detect in stacks.rs), B16 (bitcoin status retain — UI-test to avoid stale-as-live), B6 no-node-present half, B14b (FIPS net depth), B22/B23 (need live repro).
|
||||
- **NEXT options:** (a) continue backend batch B13+B12 (one build); (b) do UI confirms on .116/.198 + cut v1.7.97-alpha with the ~10 committed fixes (LND incident + cloud/federation/mesh).
|
||||
- **Committed fixes awaiting .97:** B5, B1, B2, B4, B14, B21, B3, B15, B7 (+ B6 pruned-gate already live). All on vps2 main; NOT on fleet yet.
|
||||
|
||||
## Progress log
|
||||
- 2026-06-15: tracker created. v1.7.96-alpha shipped. All 19 bugs filed as Gitea issues #8–#26. vps2 feature issues (G#3/5/6) deferred (no new features).
|
||||
- 2026-06-15: **B5 (LND CORS) ✅ DONE** — root-caused, both fixes implemented, verified on .116/.198/.103 (harness 4/4 each), committed `1db720af`, pushed to vps2 main. Will bundle into .97 (Gitea #12 to close on .97 ship).
|
||||
- Validation nodes: .116 + .198 (pw ThisIsWeb54321@). Runtime is podman (docker not in non-interactive PATH). Sideload binary → /usr/local/bin/archipelago + restart (containers survive on these nodes).
|
||||
- 2026-06-15 (cont.): **B1,B2,B4 ✅** dedup+guard — committed `ed493106`, unit-tested 2/2, live .198 healthy. **B14 ✅** transport recording — committed `1c6dc153` (after build-repair: used private `crate::federation::storage::` path → E0603; fixed to re-exported `crate::federation::`). **B21 ✅** Tor/FIPS pill — committed `0801dd66`. All pushed to vps2 main; builds verified EXIT 0.
|
||||
- **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).
|
||||
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env bash
|
||||
# lnd-cors-test.sh — assert the LND "connect your wallet" endpoints return
|
||||
# correct CORS headers for the cross-origin call from the LND UI app (:18083).
|
||||
#
|
||||
# Bug B5: /lnd-connect-info duplicated ACAO on some nodes; /proxy/lnd/v1/* 401
|
||||
# carries no ACAO fleet-wide. Browser blocks both.
|
||||
#
|
||||
# Usage: ./lnd-cors-test.sh <node-host> (e.g. 192.168.1.116 or 100.102.169.103)
|
||||
# Exit 0 = all assertions pass.
|
||||
|
||||
set -uo pipefail
|
||||
HOST="${1:?usage: lnd-cors-test.sh <node-host>}"
|
||||
ORIGIN="http://${HOST}:18083"
|
||||
BASE="http://${HOST}"
|
||||
PASS=0; FAIL=0
|
||||
say() { printf '%s\n' "$*"; }
|
||||
ok() { PASS=$((PASS+1)); say " PASS: $1"; }
|
||||
bad() { FAIL=$((FAIL+1)); say " FAIL: $1"; }
|
||||
|
||||
# Count ACAO header lines (case-insensitive) in a header dump.
|
||||
acao_count() { grep -ci '^access-control-allow-origin:' <<<"$1"; }
|
||||
acao_value() { grep -i '^access-control-allow-origin:' <<<"$1" | head -1 | sed 's/^[^:]*:[[:space:]]*//' | tr -d '\r'; }
|
||||
|
||||
say "== B5 LND CORS — node ${HOST} (origin ${ORIGIN}) =="
|
||||
|
||||
# 1) /lnd-connect-info — exactly ONE ACAO, value == origin
|
||||
H=$(curl -s -m 8 -D - -o /dev/null -H "Origin: ${ORIGIN}" "${BASE}/lnd-connect-info" 2>/dev/null)
|
||||
N=$(acao_count "$H"); V=$(acao_value "$H")
|
||||
[ "$N" = "1" ] && ok "/lnd-connect-info has exactly 1 ACAO header" || bad "/lnd-connect-info ACAO count=$N (want 1)"
|
||||
[ "$V" = "$ORIGIN" ] && ok "/lnd-connect-info ACAO value == origin" || bad "/lnd-connect-info ACAO='$V' (want '$ORIGIN')"
|
||||
|
||||
# 2) /proxy/lnd/v1/getinfo — ACAO present even on 401 (unauth)
|
||||
H=$(curl -s -m 8 -D - -o /dev/null -H "Origin: ${ORIGIN}" "${BASE}/proxy/lnd/v1/getinfo" 2>/dev/null)
|
||||
N=$(acao_count "$H")
|
||||
[ "$N" -ge 1 ] && ok "/proxy/lnd/v1/getinfo has ACAO (even unauth)" || bad "/proxy/lnd/v1/getinfo missing ACAO (count=$N)"
|
||||
[ "$N" -le 1 ] || bad "/proxy/lnd/v1/getinfo duplicate ACAO (count=$N)"
|
||||
|
||||
# 3) /proxy/lnd/v1/channels — same
|
||||
H=$(curl -s -m 8 -D - -o /dev/null -H "Origin: ${ORIGIN}" "${BASE}/proxy/lnd/v1/channels" 2>/dev/null)
|
||||
N=$(acao_count "$H")
|
||||
[ "$N" = "1" ] && ok "/proxy/lnd/v1/channels has exactly 1 ACAO" || bad "/proxy/lnd/v1/channels ACAO count=$N (want 1)"
|
||||
|
||||
say ""
|
||||
say "== ${HOST}: ${PASS} passed, ${FAIL} failed =="
|
||||
[ "$FAIL" -eq 0 ]
|
||||