From c0a5635ba3639a392c5eaf0a99d54cd06da74cc0 Mon Sep 17 00:00:00 2001 From: archipelago Date: Tue, 28 Jul 2026 07:33:32 -0400 Subject: [PATCH 01/60] =?UTF-8?q?feat(fips):=20A3.10=20=E2=80=94=20last-kn?= =?UTF-8?q?own-good=20endpoint=20fallback=20for=20direct=20peering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New fips/endpoints.rs: an npub-keyed store (/fips-endpoints.json) of every endpoint a peer was last seen connected at (fipsctl show peers transport_addr/transport_type — covers LAN, Tailscale, and WAN alike), refreshed each anchor tick, 30-day retention. The anchor tick now escalates LAN → last-known-good → anchor tree: any federation peer with a fips npub that is neither currently connected nor covered by a live LAN direct entry gets its last-known-good endpoint re-dialed (idempotent fipsctl connect, bounded by apply()'s per-connect cap). This productizes the hand-applied .116↔.198 Tailscale fix of 2026-07-20 and closes RC2's "no endpoint fallback" gap. Co-Authored-By: Claude Fable 5 --- core/archipelago/src/fips/endpoints.rs | 205 +++++++++++++++++++++++++ core/archipelago/src/fips/mod.rs | 1 + core/archipelago/src/fips/service.rs | 46 ++++++ core/archipelago/src/server.rs | 36 +++++ 4 files changed, 288 insertions(+) create mode 100644 core/archipelago/src/fips/endpoints.rs diff --git a/core/archipelago/src/fips/endpoints.rs b/core/archipelago/src/fips/endpoints.rs new file mode 100644 index 00000000..7fbcd9a0 --- /dev/null +++ b/core/archipelago/src/fips/endpoints.rs @@ -0,0 +1,205 @@ +//! Last-known-good FIPS peer endpoints (A3.10). +//! +//! The LAN direct-peering tick (`anchors::lan_fips_anchors`) only helps peers +//! we can currently see on the LAN. When a federation peer's LAN path is gone +//! (renumbered network, remote site, mDNS blackout) the only route left is the +//! anchor spanning tree — the exact hairpin RC2 calls out. But if we were EVER +//! connected to that peer directly, the daemon knew a working endpoint for it +//! (`fipsctl show peers` → `transport_addr`/`transport_type`, which covers +//! LAN, Tailscale, and WAN endpoints alike). This module persists those +//! npub-keyed endpoints and re-offers them as dial candidates when the live +//! paths disappear: LAN → last-known-good → anchor tree. +//! +//! Persisted at `/fips-endpoints.json`. Entries are refreshed every +//! time the peer is seen connected and dropped after `RETENTION` without a +//! sighting, so a peer that genuinely moved doesn't get dialed at a stale +//! address forever ( `fipsctl connect` to a dead address is harmless but not +//! free). + +use std::collections::HashMap; +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use tokio::fs; + +use super::anchors::SeedAnchor; + +const FILE_NAME: &str = "fips-endpoints.json"; +/// Forget endpoints not seen connected for this long (seconds) — 30 days. +const RETENTION_SECS: u64 = 30 * 24 * 60 * 60; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct KnownEndpoint { + /// "ip:port" as reported by the daemon (`transport_addr`). + pub address: String, + /// "udp" | "tcp" (`transport_type`). + pub transport: String, + /// Unix seconds of the last time this peer was seen connected here. + pub last_ok_unix: u64, +} + +/// A currently-connected peer as parsed from `fipsctl show peers`. +#[derive(Debug, Clone)] +pub struct ConnectedPeer { + pub npub: String, + pub address: String, + pub transport: String, +} + +fn now_unix() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +pub async fn load(data_dir: &Path) -> HashMap { + let path = data_dir.join(FILE_NAME); + match fs::read(&path).await { + Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_default(), + Err(_) => HashMap::new(), + } +} + +async fn save(data_dir: &Path, map: &HashMap) -> Result<()> { + let path = data_dir.join(FILE_NAME); + let tmp = data_dir.join(format!("{FILE_NAME}.tmp")); + fs::write(&tmp, serde_json::to_vec_pretty(map)?).await?; + fs::rename(&tmp, &path).await?; + Ok(()) +} + +/// Merge the currently-connected peers into the store (refreshing their +/// timestamps), prune expired entries, persist, and return the updated map. +/// Persistence failures are non-fatal — the in-memory result is still +/// returned so this tick's fallback logic works. +pub async fn record_connected( + data_dir: &Path, + connected: &[ConnectedPeer], +) -> HashMap { + let mut map = load(data_dir).await; + let now = now_unix(); + let before = map.clone(); + for p in connected { + if p.npub.is_empty() || p.address.is_empty() { + continue; + } + map.insert( + p.npub.clone(), + KnownEndpoint { + address: p.address.clone(), + transport: p.transport.clone(), + last_ok_unix: now, + }, + ); + } + map.retain(|_, e| now.saturating_sub(e.last_ok_unix) <= RETENTION_SECS); + if map != before { + if let Err(e) = save(data_dir, &map).await { + tracing::debug!("fips endpoint store save failed (non-fatal): {e}"); + } + } + map +} + +/// Build fallback anchors for federation peers whose live paths are gone: +/// every `wanted_npub` that is neither currently connected nor covered by a +/// live LAN direct entry, but has a last-known-good endpoint, becomes a dial +/// candidate. `fipsctl connect` is idempotent and failure-tolerant, so a +/// stale candidate costs one failed dial, bounded by apply()'s per-connect +/// timeout. +pub fn fallback_anchors( + known: &HashMap, + wanted_npubs: &[String], + connected_npubs: &[String], + lan_direct: &[SeedAnchor], +) -> Vec { + let mut out = Vec::new(); + for npub in wanted_npubs { + if connected_npubs.iter().any(|c| c == npub) { + continue; + } + if lan_direct.iter().any(|a| &a.npub == npub) { + continue; + } + if let Some(e) = known.get(npub) { + out.push(SeedAnchor { + npub: npub.clone(), + address: e.address.clone(), + transport: e.transport.clone(), + label: "last-known-good endpoint (direct FIPS)".to_string(), + }); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ep(addr: &str) -> KnownEndpoint { + KnownEndpoint { + address: addr.to_string(), + transport: "udp".to_string(), + last_ok_unix: now_unix(), + } + } + + #[tokio::test] + async fn record_and_reload_roundtrip() { + let dir = tempfile::tempdir().unwrap(); + let connected = vec![ConnectedPeer { + npub: "npub1aaa".into(), + address: "100.114.134.21:2121".into(), + transport: "udp".into(), + }]; + let map = record_connected(dir.path(), &connected).await; + assert_eq!(map["npub1aaa"].address, "100.114.134.21:2121"); + let reloaded = load(dir.path()).await; + assert_eq!(reloaded, map); + } + + #[tokio::test] + async fn expired_entries_are_pruned_on_record() { + let dir = tempfile::tempdir().unwrap(); + let mut stale = HashMap::new(); + stale.insert( + "npub1old".to_string(), + KnownEndpoint { + address: "10.0.0.1:2121".into(), + transport: "udp".into(), + last_ok_unix: now_unix() - RETENTION_SECS - 60, + }, + ); + save(dir.path(), &stale).await.unwrap(); + let map = record_connected(dir.path(), &[]).await; + assert!(map.is_empty()); + } + + #[test] + fn fallback_skips_connected_and_lan_covered_peers() { + let mut known = HashMap::new(); + known.insert("npub1gone".to_string(), ep("100.1.2.3:2121")); + known.insert("npub1conn".to_string(), ep("100.1.2.4:2121")); + known.insert("npub1lan".to_string(), ep("100.1.2.5:2121")); + let wanted: Vec = ["npub1gone", "npub1conn", "npub1lan", "npub1never"] + .iter() + .map(|s| s.to_string()) + .collect(); + let connected = vec!["npub1conn".to_string()]; + let lan = vec![SeedAnchor { + npub: "npub1lan".into(), + address: "192.168.63.198:2121".into(), + transport: "udp".into(), + label: "LAN".into(), + }]; + let out = fallback_anchors(&known, &wanted, &connected, &lan); + assert_eq!(out.len(), 1); + assert_eq!(out[0].npub, "npub1gone"); + assert_eq!(out[0].address, "100.1.2.3:2121"); + // npub1never has no stored endpoint → nothing to dial. + } +} diff --git a/core/archipelago/src/fips/mod.rs b/core/archipelago/src/fips/mod.rs index ae192b5a..e945c154 100644 --- a/core/archipelago/src/fips/mod.rs +++ b/core/archipelago/src/fips/mod.rs @@ -29,6 +29,7 @@ pub mod anchors; pub mod app_ports; pub mod config; pub mod dial; +pub mod endpoints; pub mod iface; pub mod service; pub mod telemetry; diff --git a/core/archipelago/src/fips/service.rs b/core/archipelago/src/fips/service.rs index 53ca36d2..c88a051c 100644 --- a/core/archipelago/src/fips/service.rs +++ b/core/archipelago/src/fips/service.rs @@ -227,6 +227,52 @@ pub async fn peer_connectivity_summary(anchor_candidates: &[String]) -> (u32, bo (authenticated_peer_count, anchor_connected) } +/// Currently-connected peers with their live endpoints, from +/// `fipsctl show peers` (`transport_addr`/`transport_type`). Feeds the +/// last-known-good endpoint store (A3.10); empty on any failure. +pub async fn connected_peer_endpoints() -> Vec { + let peers_json = match Command::new("sudo") + .args(["-n", "fipsctl", "show", "peers"]) + .output() + .await + { + Ok(o) if o.status.success() => o.stdout, + _ => return Vec::new(), + }; + let parsed: serde_json::Value = match serde_json::from_slice(&peers_json) { + Ok(v) => v, + Err(_) => return Vec::new(), + }; + parsed + .get("peers") + .and_then(|p| p.as_array()) + .map(|peers| { + peers + .iter() + .filter(|p| { + p.get("connectivity") + .and_then(|c| c.as_str()) + .map(|s| s == "connected") + .unwrap_or(false) + }) + .filter_map(|p| { + let npub = p.get("npub").and_then(|n| n.as_str())?; + let address = p.get("transport_addr").and_then(|a| a.as_str())?; + let transport = p + .get("transport_type") + .and_then(|t| t.as_str()) + .unwrap_or("udp"); + Some(crate::fips::endpoints::ConnectedPeer { + npub: npub.to_string(), + address: address.to_string(), + transport: transport.to_string(), + }) + }) + .collect() + }) + .unwrap_or_default() +} + /// Read the upstream daemon's public key at `/etc/fips/fips.pub` and return /// it as a bech32 npub. Returns `Ok(None)` if the file doesn't exist — used /// as a fallback on legacy/dev nodes where no seed-derived key exists. diff --git a/core/archipelago/src/server.rs b/core/archipelago/src/server.rs index fbd0047c..8007d962 100644 --- a/core/archipelago/src/server.rs +++ b/core/archipelago/src/server.rs @@ -786,6 +786,42 @@ impl Server { if !direct.is_empty() { let _ = crate::fips::anchors::apply(&direct).await; } + + // A3.10 — endpoint fallback for direct peering. Record + // where currently-connected peers actually are (their + // transport_addr covers LAN, Tailscale, and WAN alike), + // then re-dial the last-known-good endpoint of every + // federation peer whose live paths are gone: not + // connected now, no LAN direct entry this tick. Escala- + // tion order is LAN → last-known-good → anchor tree; + // a stale candidate costs one bounded failed dial. + let connected = + crate::fips::service::connected_peer_endpoints().await; + let known = crate::fips::endpoints::record_connected( + &data_dir, &connected, + ) + .await; + let wanted: Vec = reg + .all_peers() + .await + .iter() + .filter_map(|p| p.fips_npub.clone()) + .collect(); + let connected_npubs: Vec = + connected.iter().map(|c| c.npub.clone()).collect(); + let fallback = crate::fips::endpoints::fallback_anchors( + &known, + &wanted, + &connected_npubs, + &direct, + ); + if !fallback.is_empty() { + tracing::info!( + count = fallback.len(), + "dialing last-known-good endpoints for disconnected federation peers" + ); + let _ = crate::fips::anchors::apply(&fallback).await; + } } let next = if daemon_restarting && fast_retries < MAX_FAST_RETRIES { From 0b2c36f095e159f377ea096ed5e853da0bfc092f Mon Sep 17 00:00:00 2001 From: ssmithx Date: Sat, 1 Aug 2026 19:28:25 +0000 Subject: [PATCH 02/60] fix(bitcoin): stop writing a datadir bitcoin.conf that conflicts with -conf=/tmp/rpc.conf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since a597c1d9 (bitcoind RPC creds off argv), bitcoin-core and bitcoin-knots launch bitcoind with -conf=/tmp/rpc.conf and pass all other settings as CLI args; bitcoind never reads /var/lib/archipelago/bitcoin/bitcoin.conf again. write_bitcoin_conf, ensure_bitcoin_rpc_config, and bootstrap's run_bitcoin_rpc_repair were never updated to match — they kept writing/ "repairing" server=/rpcbind=/rpcallowip=/listen= into that datadir file on every install, reinstall, and service restart. Bitcoin Core's own datadir-conflict safety check then refuses to start whenever that file exists alongside an explicit -conf= arg, so the write and every repair of it directly caused the crash it was trying to prevent. Also drop the "restart already-running container after bitcoin.conf repair" adoption-path branch: it assumed bind settings live in that file and needs a restart to pick them up, which hasn't been true since a597c1d9 — the running container's CLI args are already correct. Replaces both writers with remove_stale_bitcoin_conf(), which renames (not deletes) any leftover file so already-affected nodes self-heal on next install/restart instead of staying permanently broken. bitcoin_data_volume_gb is removed as dead code (it only fed the deleted prune= line in write_bitcoin_conf, itself unused since a597c1d9 hardcoded -prune=550 in the manifest's small-disk branch). Investigated after a crash loop on archy-x250-beta; full incident timeline and patch rationale in bitcoin-conf-crash-patch.md. --- .../src/api/rpc/package/install.rs | 244 ++++-------------- core/archipelago/src/bootstrap.rs | 52 ++-- 2 files changed, 69 insertions(+), 227 deletions(-) diff --git a/core/archipelago/src/api/rpc/package/install.rs b/core/archipelago/src/api/rpc/package/install.rs index 589b0b09..0261a091 100644 --- a/core/archipelago/src/api/rpc/package/install.rs +++ b/core/archipelago/src/api/rpc/package/install.rs @@ -307,19 +307,24 @@ impl RpcHandler { let deps = self.gate_install_deps(package_id).await?; check_bitcoin_pruning_compatibility(package_id).await?; log_optional_dep_info(package_id, &deps); - let repaired_bitcoin_conf = - if matches!(package_id, "bitcoin" | "bitcoin-core" | "bitcoin-knots") { - // Materialise the RPC password file before any install path - // runs. The orchestrator path resolves secret_env from - // /var/lib/archipelago/secrets/bitcoin-rpc-password at start - // time; if the file is missing, bitcoind exits within ms. - // bitcoin_rpc_credentials() generates + persists on first - // call (OnceCell-cached), so this is idempotent. - let _ = crate::bitcoin_rpc::bitcoin_rpc_credentials().await; - ensure_bitcoin_rpc_config().await? - } else { - false - }; + if matches!(package_id, "bitcoin" | "bitcoin-core" | "bitcoin-knots") { + // Materialise the RPC password file before any install path + // runs. The orchestrator path resolves secret_env from + // /var/lib/archipelago/secrets/bitcoin-rpc-password at start + // time; if the file is missing, bitcoind exits within ms. + // bitcoin_rpc_credentials() generates + persists on first + // call (OnceCell-cached), so this is idempotent. + let _ = crate::bitcoin_rpc::bitcoin_rpc_credentials().await; + // A stale datadir bitcoin.conf from an older install conflicts + // with the container's -conf=/tmp/rpc.conf launch (see + // apps/bitcoin-core & bitcoin-knots manifest.yml) and makes + // Bitcoin Core refuse to start at all. Clear it before + // (re)install. Unlike the old bind-setting "repair" this was + // replacing, it never requires restarting an already-running + // container — bitcoind doesn't read this file, so removing it + // changes nothing at runtime. + remove_stale_bitcoin_conf().await?; + } // For orchestrator-managed apps, skip the legacy "container exists → // adopt + return" probe entirely. The orchestrator's own install path @@ -389,37 +394,7 @@ impl RpcHandler { .trim() .to_string(); - if state == "running" && repaired_bitcoin_conf { - info!( - "Restarting existing container {} after bitcoin.conf RPC repair", - package_id - ); - let restart_output = tokio::process::Command::new("podman") - .args(["restart", package_id]) - .output() - .await - .context( - "Failed to restart existing container after bitcoin.conf repair", - )?; - if !restart_output.status.success() { - let stderr = String::from_utf8_lossy(&restart_output.stderr); - install_log(&format!( - "INSTALL ADOPT FAIL: {} - restart after RPC repair failed: {}", - package_id, stderr - )) - .await; - return Err(anyhow::anyhow!( - "Container {} exists but failed to restart after RPC repair: {}", - package_id, - stderr - )); - } - let _ = tokio::process::Command::new("podman") - .args(["restart", "archy-bitcoin-ui"]) - .output() - .await; - wait_for_adopted_container(package_id, package_id).await?; - } else if state != "running" { + if state != "running" { // Start the stopped/exited container info!("Starting existing container {} (was {})", package_id, state); let start_output = tokio::process::Command::new("podman") @@ -707,9 +682,13 @@ impl RpcHandler { } } - // Pre-install: write config files BEFORE chown (dir is still owned by archipelago user) + // Pre-install: clear a stale datadir bitcoin.conf BEFORE chown (dir is + // still owned by archipelago user). bitcoind is launched with + // -conf=/tmp/rpc.conf (see apps/bitcoin-core & bitcoin-knots + // manifest.yml) and never reads a datadir bitcoin.conf — if one + // exists, Bitcoin Core's own safety check refuses to start at all. if matches!(package_id, "bitcoin" | "bitcoin-core" | "bitcoin-knots") { - self.write_bitcoin_conf(&rpc_user, &rpc_pass).await?; + remove_stale_bitcoin_conf().await?; } if package_id == "lnd" { @@ -1418,96 +1397,13 @@ impl RpcHandler { } } - /// Write bitcoin.conf with rpcauth (salted HMAC hash, no plaintext password). - async fn write_bitcoin_conf(&self, rpc_user: &str, rpc_pass: &str) -> Result<()> { - let bitcoin_dir = "/var/lib/archipelago/bitcoin"; - let conf_path = format!("{}/bitcoin.conf", bitcoin_dir); - - // Idempotent: once bitcoin-knots (or a prior install) has started, - // the data dir is chowned into the container's user namespace - // (e.g. UID 100100 on the host) with 700 perms — the archipelago - // daemon can no longer stat or write there. Treat any non-NotFound - // error on the conf as "conf already provisioned by the container - // user" and skip. Matches the lnd.conf behavior below. - match tokio::fs::metadata(&conf_path).await { - Ok(_) => { - ensure_bitcoin_rpc_config().await?; - info!("bitcoin.conf already exists, ensured Bitcoin RPC config"); - return Ok(()); - } - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(_) => { - ensure_bitcoin_rpc_config().await?; - info!("bitcoin.conf path inaccessible, ensured Bitcoin RPC config via host helper"); - return Ok(()); - } - } - - use hmac::{Hmac, Mac}; - use sha2::Sha256; - let salt_bytes: [u8; 16] = rand::random(); - let salt_hex = hex::encode(salt_bytes); - let mut mac = Hmac::::new_from_slice(salt_hex.as_bytes()) - .expect("HMAC accepts any key length"); - mac.update(rpc_pass.as_bytes()); - let hash_hex = hex::encode(mac.finalize().into_bytes()); - let rpcauth_line = format!("rpcauth={}:{}${}", rpc_user, salt_hex, hash_hex); - - // Default to full archive — operators with 2TB+ drives shouldn't be - // silently pruned down to 550 MB. Users who want a pruned node can - // set `prune=N` in bitcoin.conf themselves after install. - // - // printtoconsole=0: bitcoind already writes debug.log in the datadir - // (self-shrunk on restart); duplicating it to stdout pushed every IBD - // "UpdateTip" line through conmon into journald (>1 GB/day). Deep - // debugging uses /var/lib/archipelago/bitcoin/debug.log. - // rpcbind=0.0.0.0 is REQUIRED inside a container: with rpcallowip set - // but no rpcbind, bitcoind binds RPC to 127.0.0.1 in the container - // netns only — LND / the Bitcoin UI dialing bitcoin-knots:8332 over - // the bridge get connection refused (fresh-install LND crash-loop + - // bitcoin-rpc 502, seen on the 1.7.99 ISO). The port publish stays - // 127.0.0.1-only on the host, so exposure is unchanged. - // Prune sized to the data volume. A full archive needs ~810 GB and - // grows; silently writing an unpruned config onto a small disk fills - // it mid-IBD (framework node 2026-07-14: unpruned mainnet on a 205 GB - // volume). Volumes with real archival headroom (≥1.2 TB) stay full - // archive; smaller ones get prune = 25% of the volume, clamped to - // [550 MB, 100 GB], leaving room for LND/apps sharing the disk. - let prune_line = match bitcoin_data_volume_gb().await { - Some(total_gb) if total_gb > 0 && total_gb < 1200 => { - let prune_mb = ((total_gb as f64 * 0.25 * 1024.0) as u64).clamp(550, 100_000); - info!( - volume_gb = total_gb, - prune_mb, "Data volume below archival size — enabling sized bitcoin prune" - ); - format!("prune={}\n", prune_mb) - } - _ => String::new(), - }; - - let bitcoin_conf = format!( - "\ -# rpcauth: salted hash only - no plaintext password in config or CLI\n\ -{}\n\ -server=1\n\ -rpcbind=0.0.0.0\n\ -rpcallowip=0.0.0.0/0\n\ -listen=1\n\ -rpcthreads=16\n\ -rpcworkqueue=256\n\ -printtoconsole=0\n\ -{}", - rpcauth_line, prune_line - ); - tokio::fs::create_dir_all(bitcoin_dir) - .await - .context("Failed to create bitcoin data directory")?; - tokio::fs::write(&conf_path, bitcoin_conf) - .await - .context("Failed to write bitcoin.conf")?; - info!("Created bitcoin.conf with rpcauth (no plaintext credentials)"); - Ok(()) - } + // write_bitcoin_conf removed: bitcoind is launched with -conf=/tmp/rpc.conf + // (see apps/bitcoin-core & bitcoin-knots manifest.yml, commit a597c1d9) + // and never reads a datadir bitcoin.conf. Writing one here created a + // fatal "-conf vs default bitcoin.conf" conflict on every subsequent + // start (Bitcoin Core's own datadir-conflict safety check). See + // `remove_stale_bitcoin_conf` below, which replaces both this and + // `ensure_bitcoin_rpc_config`. /// Write LND config file with Bitcoin RPC credentials. async fn write_lnd_conf(&self, rpc_user: &str, rpc_pass: &str) -> Result<()> { @@ -2602,28 +2498,12 @@ async fn wait_for_adopted_container(package_id: &str, container_name: &str) -> R )) } -/// Total size (GB) of the filesystem holding the bitcoin data dir, via -/// `df -k`. None when df fails (containers, exotic mounts) — callers treat -/// unknown as "don't prune" to preserve archival defaults on big iron. -async fn bitcoin_data_volume_gb() -> Option { - let target = if std::path::Path::new("/var/lib/archipelago").exists() { - "/var/lib/archipelago" - } else { - "/" - }; - let output = tokio::process::Command::new("df") - .args(["-k", target]) - .output() - .await - .ok()?; - if !output.status.success() { - return None; - } - let stdout = String::from_utf8_lossy(&output.stdout); - let line = stdout.lines().nth(1)?; - let kb: u64 = line.split_whitespace().nth(1)?.parse().ok()?; - Some(kb / 1024 / 1024) -} +// bitcoin_data_volume_gb removed with write_bitcoin_conf: it only fed that +// function's volume-aware `prune=` line, which bitcoind never read either +// (see remove_stale_bitcoin_conf). The manifest's shell entrypoint already +// computes DISK_GB_VALUE and hardcodes -prune=550 on small volumes — a +// real volume-aware prune fix belongs there, not in a conf file nothing +// reads. Tracked as follow-up in bitcoin-conf-crash-patch.md. /// One-shot probe: does bitcoind answer an authenticated getblockchaininfo? /// Works during IBD (the call answers with progress while syncing). Goes via @@ -2701,52 +2581,36 @@ async fn wait_for_bitcoin_rpc_gate(package_id: &str) -> Result<()> { Ok(()) } -async fn ensure_bitcoin_rpc_config() -> Result { +/// bitcoind reads only `/tmp/rpc.conf` + CLI args at container start (see +/// apps/bitcoin-core & bitcoin-knots manifest.yml, commit a597c1d9) — it +/// never reads a datadir bitcoin.conf. A leftover file from an older install +/// (or a manual edit) makes Bitcoin Core's own datadir-conflict safety check +/// refuse to start ("-conf=... vs default bitcoin.conf"). Remove it — via +/// the same host-privileged path the old writer/repairer used, since the +/// dir may already be chowned into the container's UID namespace by a +/// previous start — instead of "repairing" it into existence. +async fn remove_stale_bitcoin_conf() -> Result { let script = r#" set -eu conf=/var/lib/archipelago/bitcoin/bitcoin.conf [ -f "$conf" ] || exit 0 -changed=0 -tmp=$(mktemp) -awk -F= ' - /^(server|txindex|rpcbind|rpcallowip|rpcport|listen|bind|dbcache|rpcthreads|rpcworkqueue)=/ { - if (seen[$1]++) next - } - { print } -' "$conf" > "$tmp" -if ! cmp -s "$conf" "$tmp"; then - cat "$tmp" > "$conf" - changed=1 -fi -rm -f "$tmp" -ensure_line() { - line="$1" - key="${line%%=*}" - if ! grep -q "^${key}=" "$conf"; then - printf '%s\n' "$line" >> "$conf" - changed=1 - fi -} -ensure_line server=1 -ensure_line rpcbind=0.0.0.0 -ensure_line rpcallowip=0.0.0.0/0 -ensure_line listen=1 -ensure_line rpcthreads=16 -ensure_line rpcworkqueue=256 -[ "$changed" -eq 0 ] && exit 0 +mv "$conf" "$conf.disabled-$(date +%s)" exit 2 "#; let status = host_sudo(&["sh", "-lc", script]) .await - .context("ensure bitcoin.conf RPC bind settings")?; + .context("remove stale bitcoin.conf")?; match status.code() { Some(0) => Ok(false), Some(2) => { - install_log("INSTALL REPAIR: bitcoin.conf RPC bind settings added").await; + install_log( + "INSTALL REPAIR: removed stale bitcoin.conf (conflicts with -conf=/tmp/rpc.conf launch)", + ) + .await; Ok(true) } _ => Err(anyhow::anyhow!( - "bitcoin.conf RPC repair helper exited with {}", + "bitcoin.conf removal helper exited with {}", status )), } diff --git a/core/archipelago/src/bootstrap.rs b/core/archipelago/src/bootstrap.rs index 2739ff1c..644f806f 100644 --- a/core/archipelago/src/bootstrap.rs +++ b/core/archipelago/src/bootstrap.rs @@ -154,9 +154,9 @@ pub async fn ensure_doctor_installed() { } match run_bitcoin_rpc_repair().await { Ok(true) => { - info!("Repaired Bitcoin RPC bind settings; running Bitcoin containers left untouched") + info!("Removed stale bitcoin.conf; running Bitcoin containers left untouched") } - Ok(false) => debug!("Bitcoin RPC bind settings already usable"), + Ok(false) => debug!("No stale bitcoin.conf found"), Err(e) => warn!("Bitcoin RPC repair failed (non-fatal): {:#}", e), } match run_apps_dir_repair().await { @@ -577,52 +577,30 @@ exit 2 } async fn run_bitcoin_rpc_repair() -> Result { - // Older installs can have a container-owned bitcoin.conf with only rpcauth - // and printtoconsole. Repair it at startup so OTA fixes existing nodes - // without a manual uninstall/reinstall. Bind/port stay in the container - // command line to avoid duplicate RPC endpoint definitions. + // bitcoind is launched with -conf=/tmp/rpc.conf and never reads a + // datadir bitcoin.conf (apps/bitcoin-core & bitcoin-knots manifest.yml, + // commit a597c1d9 — bind/port live only on the container command line). + // A leftover file from an older install makes Bitcoin Core's own + // datadir-conflict safety check refuse to start on every subsequent + // start. Remove it instead of "repairing" it into existence — this + // previously wrote server=/rpcbind=/rpcallowip=/listen= into the file, + // which is exactly what caused the conflict. let script = r#" set -eu conf=/var/lib/archipelago/bitcoin/bitcoin.conf [ -f "$conf" ] || exit 0 -changed=0 -ensure_line() { - line="$1" - key="${line%%=*}" - if ! grep -q "^${key}=" "$conf"; then - printf '%s\n' "$line" >> "$conf" - changed=1 - fi -} -ensure_line server=1 -# rpcbind=0.0.0.0 is required inside the container: with rpcallowip set but -# no rpcbind, bitcoind binds RPC to the container's loopback only and every -# dial over the container network (LND, bitcoin-ui) is refused — the fresh- -# install "LND took 5 attempts" / bitcoin-rpc 502 failure (host publish stays -# 127.0.0.1-only, so exposure is unchanged). -ensure_line rpcbind=0.0.0.0 -ensure_line rpcallowip=0.0.0.0/0 -ensure_line listen=1 -# Log-volume fix: printtoconsole=1 duplicated every log line (incl. per-block -# IBD "UpdateTip" spam) into journald via conmon on top of the datadir -# debug.log bitcoind already writes. Console off; debug.log stays (bitcoind -# self-shrinks it on restart). -if grep -q '^printtoconsole=1' "$conf"; then - sed -i 's/^printtoconsole=1$/printtoconsole=0/' "$conf" - changed=1 -fi -[ "$changed" -eq 0 ] && exit 0 +mv "$conf" "$conf.disabled-$(date +%s)" exit 2 "#; let status = host_sudo(&["sh", "-lc", script]) .await - .context("repair bitcoin.conf RPC bind settings")?; + .context("remove stale bitcoin.conf RPC bind settings")?; match status.code() { Some(0) => Ok(false), // Do not restart Bitcoin from bootstrap. During IBD, an automatic - // restart can cost hours of progress. The repaired file is only a - // fallback for future starts; current containers keep their command-line - // RPC args until an operator or update intentionally restarts them. + // restart can cost hours of progress. Removing the stale file is + // only a fallback for future starts; current containers keep their + // command-line RPC args regardless. Some(2) => Ok(true), _ => { warn!("Bitcoin RPC repair helper exited with {}", status); From 9d225473b11d6cb76daf559fea9f48edbb5a435e Mon Sep 17 00:00:00 2001 From: archipelago Date: Tue, 4 Aug 2026 02:49:36 -0400 Subject: [PATCH 03/60] docs(1.7.121): record what shipped, both gate incidents, and the .122 queue Co-Authored-By: Claude Opus 5 (1M context) --- .planning/RELEASE-1.7.121-TASKS.md | 64 ++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/.planning/RELEASE-1.7.121-TASKS.md b/.planning/RELEASE-1.7.121-TASKS.md index ed5e769d..7542bda1 100644 --- a/.planning/RELEASE-1.7.121-TASKS.md +++ b/.planning/RELEASE-1.7.121-TASKS.md @@ -443,6 +443,70 @@ below is dead on every path. Pre-existing; spotted in the v1.7.120 build warning --- +## STATUS 2026-08-04 — what shipped in 1.7.121 and what did not + +### Shipped (committed + pushed) + +| Item | Commit | Verified | +|---|---|---| +| 3. Federation trust escalation | `c0cfc72a` | 42/42 federation tests | +| 3b. Trusted requires node password | `24ce8b39` | 44/44 + 79/79 + vue-tsc | +| 4. lnd-ui OTA pin + host networking | `5088aef5` | — | +| 1b. Manifest `auth:` declarations | `0c4826f8` | 73/73, all 56 manifests parse | +| 1c. App gate (engine + audit) | `0de67ca6` | 23/23 appgate | +| Dashboard backdrop-filter seam | `63d0183d` | 3/3, **live on archi-dev-box** | +| 7. Release refuses unsigned manifest | `cc9e1958` | dry-run: signed/stripped/wrong-signer | +| Gate safety model (`Option`) | `ab2c8b6e` | 75/75 incl. LND wallet-port case | +| Companion rebuild-loop | `719446c0` | podman behaviour proven first | +| 5. Federated peers messageable | `edc9a172` | predicate pinned across device types | + +### The two gate incidents — read before touching the gate again + +Both were ONE mistake: a safety decision read an ABSENT manifest field as a +value. A node's installed manifests always lag the binary, so "absent" is the +normal state, and the daemon acted on instructions no manifest ever gave. + +1. Gating any `session` port regardless of `bind` **published Bitcoin's + loopback-only RPC 8332 on the LAN/Tailscale/IPv6** within seconds of deploy. +2. The `bind`-keyed replacement looked safe (it protected `bind: 127.0.0.1`) + but LND's gRPC 10009 / REST 18080 carry an EMPTY bind — one container + recreate from pinning them to loopback and **breaking Zeus and every remote + wallet**. + +Now structural: `auth_policy()` classifies (undeclared → reported as +unprotected, always safe), `auth_is_declared()` gates action (undeclared → +never acted on). **Silence is not consent.** + +### Proven on the node, empirically, not by reasoning + +- Gate challenge → login → proxy works end to end over LAN and Tailscale. +- **Daemon-side publish rewriting was removed.** Publishes are built in several + places (`podman_client`, `package::install`, `stacks`); patching one covered + one — the strfry recreate went through another and the pin never fired. +- **Disk manifest edits do not apply to catalog-covered apps.** Even + `bind: 127.0.0.1` written into the node's strfry manifest was overridden by + the signed catalog. The catalog re-sign is REQUIRED; there is no shortcut. +- A loopback-bound host port is **unreachable** from a pasta container, so + loopback-pinning the Wyoming ports would break Home Assistant voice. + +### Open for 1.7.122 + +1. **Catalog re-sign** — `bind: 127.0.0.1` + `auth: session` on the ~39 gated + UI ports. This is what turns the gate from auditing into enforcing. Nothing + in code can substitute for it. +2. **Release-root rotation** — branch `rotate-release-root`, key + `did:key:z6Mkfu5LT…DLWT` / `1578adcc…4418`, validated as a real curve point. + **Sign the rotation release with the OLD key**; only the release after it + uses the new one. Re-sign the catalog too. +3. **Wyoming voice ports** (10200/10300/10400) — unauthenticated, and by the + operator's policy they should not be. Correct fix is co-locating Home + Assistant with the pine services on one container network so nothing is + published; needs a node running both. +4. **Item 2** filebrowser default login. **Items 6/6b** app updates + + multiversion (`versions[]` already exists, populated for 2 of 66 apps). +5. **`cargo-test-weekly` times out** at its 1500s cap on a loaded box — raise + the cap or split the stage; it is not a code failure. + ## RESUME HERE — next session **Landed this session (both pushed):** From cd58242935206875396ae995aacb5d1c16ccff48 Mon Sep 17 00:00:00 2001 From: archipelago Date: Tue, 4 Aug 2026 03:13:45 -0400 Subject: [PATCH 04/60] chore: release v1.7.121-alpha --- core/archipelago/Cargo.toml | 2 +- neode-ui/package-lock.json | 4 ++-- neode-ui/package.json | 2 +- release-manifest.json | 46 ++++++++++++++++++------------------- releases/manifest.json | 46 ++++++++++++++++++------------------- 5 files changed, 48 insertions(+), 52 deletions(-) diff --git a/core/archipelago/Cargo.toml b/core/archipelago/Cargo.toml index 9308b365..a956dab5 100644 --- a/core/archipelago/Cargo.toml +++ b/core/archipelago/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "archipelago" -version = "1.7.120-alpha" +version = "1.7.121-alpha" edition = "2021" description = "Archipelago Bitcoin Node OS - Native backend" authors = ["Archipelago Team"] diff --git a/neode-ui/package-lock.json b/neode-ui/package-lock.json index adf78675..415000a7 100644 --- a/neode-ui/package-lock.json +++ b/neode-ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "neode-ui", - "version": "1.7.120-alpha", + "version": "1.7.121-alpha", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "neode-ui", - "version": "1.7.120-alpha", + "version": "1.7.121-alpha", "dependencies": { "@scure/bip39": "^2.2.0", "@types/dompurify": "^3.0.5", diff --git a/neode-ui/package.json b/neode-ui/package.json index 4330e60c..2f1cc1eb 100644 --- a/neode-ui/package.json +++ b/neode-ui/package.json @@ -1,7 +1,7 @@ { "name": "neode-ui", "private": true, - "version": "1.7.120-alpha", + "version": "1.7.121-alpha", "type": "module", "scripts": { "start": "./start-dev.sh", diff --git a/release-manifest.json b/release-manifest.json index 24f23112..8c2b689d 100644 --- a/release-manifest.json +++ b/release-manifest.json @@ -1,36 +1,34 @@ { "changelog": [ - "**Security, and the reason to take this update: two ports on your node handed anyone who could reach them complete control of your money, with no password.** The Lightning app's port answered a plain web request with the LND admin macaroon, the TLS certificate and the node's onion address — everything needed to drain the wallet remotely, and the onion meant an attacker kept that ability even after losing access to your network. The Bitcoin app's port reached Bitcoin Core's control interface using credentials the node itself supplied on the caller's behalf, with a wallet loaded. Anything on your home network, your Tailscale network or the mesh could use either one. Both now require you to be logged in. If your node has been reachable by anyone you do not fully trust, treat the Lightning macaroon and the Bitcoin RPC password as known to them.", - "The Bitcoin and Lightning app screens can no longer be published as public Tor addresses automatically. They were one app-id away from being handed a worldwide, permanent address as a silent side effect of being installed — which would have re-opened the hole above to the entire internet. Turning Tor on for them deliberately still works; it just never happens on its own.", - "**Fixes shipped inside the program now actually reach apps that your system keeps running.** A container the node had been told to uninstall, but that the system service manager kept alive anyway, was quietly skipped by the part of the node that applies configuration — so it never received updates that shipped with the program. This was found the hard way: the Bitcoin control-interface fix above appeared to be installed and silently was not, while the Lightning half applied correctly, which is the most misleading way for a security fix to fail. Both halves are now proven to land on a real node.", - "The Lightning and Bitcoin node screens have been rebuilt to match what umbrelOS offers. Lightning gains Overview, Channels, Activity, Insights, Connect and Settings tabs with a sats/BTC switch; Bitcoin gains Insights, Peers, Connect and Sharing. Along the way: every copy button on those screens silently did nothing (the browser blocks clipboard access inside an embedded page) and now works; the channels link led to a dead page; and Node ID showed a bare key instead of the full address someone can actually connect to.", - "Updates to the Bitcoin screen show up without a hard refresh. The page was being cached by the browser, so a freshly updated screen kept rendering the previous one.", - "The AI sidebar loads again. It was asking for its program files at an address that pointed at the main app's files, where they do not exist, so it silently loaded nothing.", - "The navigation above the bottom bar no longer follows you between screens. Back buttons and the mesh tab bar stayed pinned over every other page once you had visited the screen that owns them. Keeping tabs loaded in the background — the change that made switching between them instant — means leaving a screen hides it rather than destroying it, and this floating navigation sits outside the screen it belongs to, so it was never being hidden with it. It is now tied to whether its own screen is on display. The speed is unchanged: the screens are still kept loaded, so returning to one is still instant.", - "Wallet: Lightning actions are now offered based on whether you actually have a usable channel rather than just a running node, sending is gated the same way, and an invoice you cannot yet receive offers to install a Lightning node instead of simply failing.", - "Onboarding and viewing fixes: the \"I have written down my recovery words\" tickbox is findable on short screens, paid pictures and videos open in the app's own viewer with a visible loading state instead of a blank browser tab, picture-in-picture survives changing tabs, and the FIPS/Tor labels on peer cards stay put instead of wrapping into the card below.", - "Key-material hardening across the node: a node that is already set up refuses to have its identity replaced by an unauthenticated request; first-boot secret generation now fails loudly instead of silently continuing with shared keys; the node proves its TLS certificate and key are actually a matching pair; the Bitcoin Core wallet path that kept a second copy of your spending key outside the encrypted store has been removed; and every place the node generates a key, token or nonce now names its source of randomness explicitly, enforced at build time." + "**Making another node \"Trusted\" now asks for your node password.** Trust was being handed out by machines rather than by you: any node able to reach yours could join and mark itself Trusted, because the check proved only that the caller owned the key it had just presented — never that you had approved it. Trust also spread on its own, since every peer a Trusted node advertised was added as Trusted too, so one grant quietly propagated across the whole federation. Uninvited joins are now capped at Observer, advertised peers arrive as Observers, and raising anyone to Trusted — whether by generating an invite or by changing the dropdown on a node — requires your password. Lowering trust deliberately does not, because the safe action must never be the inconvenient one. Existing peers are left exactly as they are rather than silently demoted, and each one now records how its trust was granted so you can review them.", + "**Nodes you have peered with can be messaged straight away.** Peering was not enough: you also had to be within LoRa radio range of the other node once before chat would work. The node picked how to send a message based on which radio was plugged in, and only one of those paths knew how to reach a peer over the mesh's internet transports — so on a node with a different radio, or no radio at all, messaging a peer you had just federated with simply failed until a radio contact happened to appear. Peered nodes are reachable without radio by definition, so that choice no longer depends on the hardware. Radio is still preferred when the other node is actually in range and the message fits.", + "The dashboard no longer flickers a vertical line across its cards. A rendering seam appeared at random while moving the mouse, because the two large cards used a background-blur effect that this system already disables everywhere else on the dashboard — that browser mis-draws it inside the dashboard's animated container, and these two cards had been missed when the workaround was written. Diagnosed from a single screenshot rather than by trying to reproduce it.", + "The Lightning screen will actually update from now on. Its image was set to \"latest\", and the container system will not re-fetch a label it already holds, so nodes kept the same Lightning screen forever no matter how many updates shipped. A separate copy of the same setting used only by brand-new installs also described the screen incorrectly, so fresh installs got a screen that never answered.", + "Apps that provide their own screens stop rebuilding themselves in a loop. On this system's own node one of them rebuilt every thirty-five seconds indefinitely, burning processor time and restarting the app each round. The node decided a rebuild was needed by comparing file dates against the image's creation date, but a rebuild that changes nothing reuses the existing image and leaves that date untouched — so the condition that triggered the rebuild was still true afterwards, forever. Nodes taking this update repair themselves the first time they check.", + "Groundwork you can see but that does not change access yet: the node can now tell you which of its app ports answer without a login, and every port that is deliberately open — Bitcoin's peer connections for syncing the chain, Lightning's wallet connections, the Electrum wallet protocol — now has to state in writing why it is safe, so the list of exceptions is something you can read rather than something you have to discover. The login gate that will sit in front of the rest is built and proven working end to end on a real node, but it is not yet closing any ports; that arrives with the signed app catalog that tells each app to hand its address over.", + "Releases can no longer ship an unsigned update file. Signing was skippable, and when it was skipped the release was still committed and tagged — producing an update that every node correctly refuses to install. It had been caught by hand every cycle; now the release simply stops.", + "Known gaps, disclosed rather than buried: the 5x real-node lifecycle gate was not run for this release. App ports other than the deliberate exceptions above are still reachable without a login — the gate reports them, and closing them needs the next signed catalog. Three voice-assistant ports are open without authentication and should not be; the correct fix puts them on a private network with the assistant instead, which needs testing on a node that runs both. Two nodes on the fleet still share SSH host keys (detection shipped, rotation remains a deliberate operator decision)." ], "components": [ { - "current_version": "1.7.120-alpha", - "download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.120-alpha/archipelago", + "current_version": "1.7.121-alpha", + "download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.121-alpha/archipelago", "name": "archipelago", - "new_version": "1.7.120-alpha", - "sha256": "304255655a22bae605d728d44e857ed170a19833b6237861fdf7d852d25d9680", - "size_bytes": 54017008 + "new_version": "1.7.121-alpha", + "sha256": "be5ef9fb284f539b06329d4108be53e55ae8cdb06cf1cf4beb90363de364706d", + "size_bytes": 54870968 }, { - "current_version": "1.7.120-alpha", - "download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.120-alpha/archipelago-frontend-1.7.120-alpha.tar.gz", - "name": "archipelago-frontend-1.7.120-alpha.tar.gz", - "new_version": "1.7.120-alpha", - "sha256": "cb9ea4dfcea3ac93dfb1ce1dca96ea30c74a4e6354471cde4d8f75c0441850a5", - "size_bytes": 210519311 + "current_version": "1.7.121-alpha", + "download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.121-alpha/archipelago-frontend-1.7.121-alpha.tar.gz", + "name": "archipelago-frontend-1.7.121-alpha.tar.gz", + "new_version": "1.7.121-alpha", + "sha256": "7898a9c11fa30cadc8f0fcf814bba1e3870d20663472f4c40e8e663b2359958f", + "size_bytes": 210526689 } ], - "release_date": "2026-08-03", - "signature": "e76e0ca5f249111a0a57df07f790997b1a4facf97da11a2d13fcb7ec9b80aea82925244d6083544504260b776ca4317cf44774e2c37bfaa13afae248e9675601", + "release_date": "2026-08-04", + "signature": "9d871c946e941b3c13f75fb799d8428841147267f4565920993ddd3aa0cd52d6d1a74481303cbbb26e68e6e5d44e2b711c9b7a22ad7dd73c94b4d3e39a0e2803", "signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur", - "version": "1.7.120-alpha" + "version": "1.7.121-alpha" } diff --git a/releases/manifest.json b/releases/manifest.json index 24f23112..8c2b689d 100644 --- a/releases/manifest.json +++ b/releases/manifest.json @@ -1,36 +1,34 @@ { "changelog": [ - "**Security, and the reason to take this update: two ports on your node handed anyone who could reach them complete control of your money, with no password.** The Lightning app's port answered a plain web request with the LND admin macaroon, the TLS certificate and the node's onion address — everything needed to drain the wallet remotely, and the onion meant an attacker kept that ability even after losing access to your network. The Bitcoin app's port reached Bitcoin Core's control interface using credentials the node itself supplied on the caller's behalf, with a wallet loaded. Anything on your home network, your Tailscale network or the mesh could use either one. Both now require you to be logged in. If your node has been reachable by anyone you do not fully trust, treat the Lightning macaroon and the Bitcoin RPC password as known to them.", - "The Bitcoin and Lightning app screens can no longer be published as public Tor addresses automatically. They were one app-id away from being handed a worldwide, permanent address as a silent side effect of being installed — which would have re-opened the hole above to the entire internet. Turning Tor on for them deliberately still works; it just never happens on its own.", - "**Fixes shipped inside the program now actually reach apps that your system keeps running.** A container the node had been told to uninstall, but that the system service manager kept alive anyway, was quietly skipped by the part of the node that applies configuration — so it never received updates that shipped with the program. This was found the hard way: the Bitcoin control-interface fix above appeared to be installed and silently was not, while the Lightning half applied correctly, which is the most misleading way for a security fix to fail. Both halves are now proven to land on a real node.", - "The Lightning and Bitcoin node screens have been rebuilt to match what umbrelOS offers. Lightning gains Overview, Channels, Activity, Insights, Connect and Settings tabs with a sats/BTC switch; Bitcoin gains Insights, Peers, Connect and Sharing. Along the way: every copy button on those screens silently did nothing (the browser blocks clipboard access inside an embedded page) and now works; the channels link led to a dead page; and Node ID showed a bare key instead of the full address someone can actually connect to.", - "Updates to the Bitcoin screen show up without a hard refresh. The page was being cached by the browser, so a freshly updated screen kept rendering the previous one.", - "The AI sidebar loads again. It was asking for its program files at an address that pointed at the main app's files, where they do not exist, so it silently loaded nothing.", - "The navigation above the bottom bar no longer follows you between screens. Back buttons and the mesh tab bar stayed pinned over every other page once you had visited the screen that owns them. Keeping tabs loaded in the background — the change that made switching between them instant — means leaving a screen hides it rather than destroying it, and this floating navigation sits outside the screen it belongs to, so it was never being hidden with it. It is now tied to whether its own screen is on display. The speed is unchanged: the screens are still kept loaded, so returning to one is still instant.", - "Wallet: Lightning actions are now offered based on whether you actually have a usable channel rather than just a running node, sending is gated the same way, and an invoice you cannot yet receive offers to install a Lightning node instead of simply failing.", - "Onboarding and viewing fixes: the \"I have written down my recovery words\" tickbox is findable on short screens, paid pictures and videos open in the app's own viewer with a visible loading state instead of a blank browser tab, picture-in-picture survives changing tabs, and the FIPS/Tor labels on peer cards stay put instead of wrapping into the card below.", - "Key-material hardening across the node: a node that is already set up refuses to have its identity replaced by an unauthenticated request; first-boot secret generation now fails loudly instead of silently continuing with shared keys; the node proves its TLS certificate and key are actually a matching pair; the Bitcoin Core wallet path that kept a second copy of your spending key outside the encrypted store has been removed; and every place the node generates a key, token or nonce now names its source of randomness explicitly, enforced at build time." + "**Making another node \"Trusted\" now asks for your node password.** Trust was being handed out by machines rather than by you: any node able to reach yours could join and mark itself Trusted, because the check proved only that the caller owned the key it had just presented — never that you had approved it. Trust also spread on its own, since every peer a Trusted node advertised was added as Trusted too, so one grant quietly propagated across the whole federation. Uninvited joins are now capped at Observer, advertised peers arrive as Observers, and raising anyone to Trusted — whether by generating an invite or by changing the dropdown on a node — requires your password. Lowering trust deliberately does not, because the safe action must never be the inconvenient one. Existing peers are left exactly as they are rather than silently demoted, and each one now records how its trust was granted so you can review them.", + "**Nodes you have peered with can be messaged straight away.** Peering was not enough: you also had to be within LoRa radio range of the other node once before chat would work. The node picked how to send a message based on which radio was plugged in, and only one of those paths knew how to reach a peer over the mesh's internet transports — so on a node with a different radio, or no radio at all, messaging a peer you had just federated with simply failed until a radio contact happened to appear. Peered nodes are reachable without radio by definition, so that choice no longer depends on the hardware. Radio is still preferred when the other node is actually in range and the message fits.", + "The dashboard no longer flickers a vertical line across its cards. A rendering seam appeared at random while moving the mouse, because the two large cards used a background-blur effect that this system already disables everywhere else on the dashboard — that browser mis-draws it inside the dashboard's animated container, and these two cards had been missed when the workaround was written. Diagnosed from a single screenshot rather than by trying to reproduce it.", + "The Lightning screen will actually update from now on. Its image was set to \"latest\", and the container system will not re-fetch a label it already holds, so nodes kept the same Lightning screen forever no matter how many updates shipped. A separate copy of the same setting used only by brand-new installs also described the screen incorrectly, so fresh installs got a screen that never answered.", + "Apps that provide their own screens stop rebuilding themselves in a loop. On this system's own node one of them rebuilt every thirty-five seconds indefinitely, burning processor time and restarting the app each round. The node decided a rebuild was needed by comparing file dates against the image's creation date, but a rebuild that changes nothing reuses the existing image and leaves that date untouched — so the condition that triggered the rebuild was still true afterwards, forever. Nodes taking this update repair themselves the first time they check.", + "Groundwork you can see but that does not change access yet: the node can now tell you which of its app ports answer without a login, and every port that is deliberately open — Bitcoin's peer connections for syncing the chain, Lightning's wallet connections, the Electrum wallet protocol — now has to state in writing why it is safe, so the list of exceptions is something you can read rather than something you have to discover. The login gate that will sit in front of the rest is built and proven working end to end on a real node, but it is not yet closing any ports; that arrives with the signed app catalog that tells each app to hand its address over.", + "Releases can no longer ship an unsigned update file. Signing was skippable, and when it was skipped the release was still committed and tagged — producing an update that every node correctly refuses to install. It had been caught by hand every cycle; now the release simply stops.", + "Known gaps, disclosed rather than buried: the 5x real-node lifecycle gate was not run for this release. App ports other than the deliberate exceptions above are still reachable without a login — the gate reports them, and closing them needs the next signed catalog. Three voice-assistant ports are open without authentication and should not be; the correct fix puts them on a private network with the assistant instead, which needs testing on a node that runs both. Two nodes on the fleet still share SSH host keys (detection shipped, rotation remains a deliberate operator decision)." ], "components": [ { - "current_version": "1.7.120-alpha", - "download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.120-alpha/archipelago", + "current_version": "1.7.121-alpha", + "download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.121-alpha/archipelago", "name": "archipelago", - "new_version": "1.7.120-alpha", - "sha256": "304255655a22bae605d728d44e857ed170a19833b6237861fdf7d852d25d9680", - "size_bytes": 54017008 + "new_version": "1.7.121-alpha", + "sha256": "be5ef9fb284f539b06329d4108be53e55ae8cdb06cf1cf4beb90363de364706d", + "size_bytes": 54870968 }, { - "current_version": "1.7.120-alpha", - "download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.120-alpha/archipelago-frontend-1.7.120-alpha.tar.gz", - "name": "archipelago-frontend-1.7.120-alpha.tar.gz", - "new_version": "1.7.120-alpha", - "sha256": "cb9ea4dfcea3ac93dfb1ce1dca96ea30c74a4e6354471cde4d8f75c0441850a5", - "size_bytes": 210519311 + "current_version": "1.7.121-alpha", + "download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.121-alpha/archipelago-frontend-1.7.121-alpha.tar.gz", + "name": "archipelago-frontend-1.7.121-alpha.tar.gz", + "new_version": "1.7.121-alpha", + "sha256": "7898a9c11fa30cadc8f0fcf814bba1e3870d20663472f4c40e8e663b2359958f", + "size_bytes": 210526689 } ], - "release_date": "2026-08-03", - "signature": "e76e0ca5f249111a0a57df07f790997b1a4facf97da11a2d13fcb7ec9b80aea82925244d6083544504260b776ca4317cf44774e2c37bfaa13afae248e9675601", + "release_date": "2026-08-04", + "signature": "9d871c946e941b3c13f75fb799d8428841147267f4565920993ddd3aa0cd52d6d1a74481303cbbb26e68e6e5d44e2b711c9b7a22ad7dd73c94b4d3e39a0e2803", "signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur", - "version": "1.7.120-alpha" + "version": "1.7.121-alpha" } From 4e455167e9ca83eb0e868c991c2a29fc68eb3601 Mon Sep 17 00:00:00 2001 From: archipelago Date: Tue, 4 Aug 2026 03:23:28 -0400 Subject: [PATCH 05/60] fix(release): accept https remotes when publishing assets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Publishing v1.7.121-alpha failed on auth after the manifest had already passed every check. The script required an `http://user:token@` remote, which left only `gitea-vps2` — whose token is dead — and rejected `gitea-ai`, the https remote whose credential actually works for git push. Same Gitea instance (146.59.87.168, v1.27.1) either way, so the restriction bought nothing and blocked the one usable path. Accepts http and https, and carries the scheme through to the API URL instead of hardcoding it. Note for diagnosis next time: `/api/v1/repos/.../releases` is publicly readable, so a 200 there does NOT prove the credential works. Use `/api/v1/user`, which requires real auth. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/publish-release-assets.sh | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/scripts/publish-release-assets.sh b/scripts/publish-release-assets.sh index 1650d0a9..90a609eb 100755 --- a/scripts/publish-release-assets.sh +++ b/scripts/publish-release-assets.sh @@ -39,18 +39,25 @@ if [ -x "$PROJECT_ROOT/core/target/release/archipelago" ]; then fi remote_url=$(git -C "$PROJECT_ROOT" remote get-url "$REMOTE") +# https is accepted as well as http. Requiring http:// meant the only remote +# whose credential actually works for git push (the https one) was rejected, +# while the http remote it forced you to use had a dead token — so publishing +# failed on auth after the manifest had already passed every check +# (v1.7.121-alpha, 2026-08-04). The scheme is carried through to the API URL +# rather than assumed. case "$remote_url" in - http://*@*) ;; - *) fail "$REMOTE must be an authenticated http:// Gitea remote URL for API uploads" ;; + http://*@*|https://*@*) ;; + *) fail "$REMOTE must be an authenticated http(s):// Gitea remote URL for API uploads" ;; esac -auth=${remote_url#http://} -auth=${auth%@*} -host_path=${remote_url#http://$auth@} +scheme=${remote_url%%://*} +rest=${remote_url#*://} +auth=${rest%%@*} +host_path=${rest#*@} host=${host_path%%/*} repo_path=${host_path#*/} repo_path=${repo_path%.git} -api="http://$host/api/v1/repos/$repo_path" +api="$scheme://$host/api/v1/repos/$repo_path" release_url="$api/releases/tags/v${VERSION}" echo "Pushing main and v${VERSION} to $REMOTE..." From 16642ad8b803761e20e15ec41a760c068fb179eb Mon Sep 17 00:00:00 2001 From: archipelago Date: Tue, 4 Aug 2026 06:09:37 -0400 Subject: [PATCH 06/60] feat(security): declare port auth policy across the app manifests 20 HTTP UIs move to bind: 127.0.0.1 + auth: gated (the daemon owns their external addresses and authenticates every connection); 5 loopback-only backends declare auth: local so the gate keeps its hands off. Protocol ports (LND, bitcoin p2p, electrum, CLN, gitea SSH, Wyoming, mDNS/SSDP) were already declared auth: none with rationales in earlier commits. Inert until the catalog is re-signed: nodes act only on declared fields delivered via the signed catalog, and the catalog overlay overrides these disk manifests everywhere they are installed. Co-Authored-By: Claude Fable 5 --- apps/archy-mempool-web/manifest.yml | 2 ++ apps/archy-nbxplorer/manifest.yml | 2 ++ apps/barkd/manifest.yml | 2 ++ apps/btcpay-server/manifest.yml | 2 ++ apps/did-wallet/manifest.yml | 2 ++ apps/fedimint-clientd/manifest.yml | 2 ++ apps/fedimint/manifest.yml | 2 ++ apps/filebrowser/manifest.yml | 2 ++ apps/gitea/manifest.yml | 2 ++ apps/grafana/manifest.yml | 2 ++ apps/home-assistant/manifest.yml | 2 ++ apps/immich/manifest.yml | 2 ++ apps/indeedhub/manifest.yml | 2 ++ apps/jellyfin/manifest.yml | 2 ++ apps/mempool-api/manifest.yml | 2 ++ apps/mempool/manifest.yml | 2 ++ apps/morphos-server/manifest.yml | 2 ++ apps/nextcloud/manifest.yml | 2 ++ apps/nostr-rs-relay/manifest.yml | 2 ++ apps/photoprism/manifest.yml | 2 ++ apps/portainer/manifest.yml | 2 ++ apps/searxng/manifest.yml | 2 ++ apps/strfry/manifest.yml | 2 ++ apps/uptime-kuma/manifest.yml | 2 ++ apps/vaultwarden/manifest.yml | 2 ++ 25 files changed, 50 insertions(+) diff --git a/apps/archy-mempool-web/manifest.yml b/apps/archy-mempool-web/manifest.yml index 6d84df8d..d18acc76 100644 --- a/apps/archy-mempool-web/manifest.yml +++ b/apps/archy-mempool-web/manifest.yml @@ -26,6 +26,8 @@ app: - host: 4080 container: 8080 protocol: tcp + bind: 127.0.0.1 + auth: gated environment: - FRONTEND_HTTP_PORT=8080 diff --git a/apps/archy-nbxplorer/manifest.yml b/apps/archy-nbxplorer/manifest.yml index 21ce43bf..a13c1bb7 100644 --- a/apps/archy-nbxplorer/manifest.yml +++ b/apps/archy-nbxplorer/manifest.yml @@ -33,6 +33,8 @@ app: - host: 32838 container: 32838 protocol: tcp + bind: 127.0.0.1 + auth: local volumes: - type: bind diff --git a/apps/barkd/manifest.yml b/apps/barkd/manifest.yml index 5ba3d40d..171ed869 100644 --- a/apps/barkd/manifest.yml +++ b/apps/barkd/manifest.yml @@ -51,6 +51,8 @@ app: - host: 3535 container: 3535 protocol: tcp + bind: 127.0.0.1 + auth: local volumes: # Holds the wallet DB, mnemonic and auth token. ARK funds are recoverable diff --git a/apps/btcpay-server/manifest.yml b/apps/btcpay-server/manifest.yml index 8f42a95c..703a9f0c 100644 --- a/apps/btcpay-server/manifest.yml +++ b/apps/btcpay-server/manifest.yml @@ -45,6 +45,8 @@ app: - host: 23000 container: 49392 protocol: tcp + bind: 127.0.0.1 + auth: gated volumes: - type: bind diff --git a/apps/did-wallet/manifest.yml b/apps/did-wallet/manifest.yml index c89f6561..413df0a2 100644 --- a/apps/did-wallet/manifest.yml +++ b/apps/did-wallet/manifest.yml @@ -30,6 +30,8 @@ app: - host: 8088 container: 8080 protocol: tcp # Web UI + bind: 127.0.0.1 + auth: gated volumes: - type: bind diff --git a/apps/fedimint-clientd/manifest.yml b/apps/fedimint-clientd/manifest.yml index 38d81bba..898b31be 100644 --- a/apps/fedimint-clientd/manifest.yml +++ b/apps/fedimint-clientd/manifest.yml @@ -66,6 +66,8 @@ app: - host: 8178 container: 8080 protocol: tcp + bind: 127.0.0.1 + auth: local volumes: # Same dir the first-boot bundled path uses + where the wallet bridge reads diff --git a/apps/fedimint/manifest.yml b/apps/fedimint/manifest.yml index 88f17e15..c3c7985a 100644 --- a/apps/fedimint/manifest.yml +++ b/apps/fedimint/manifest.yml @@ -58,6 +58,8 @@ app: - host: 8177 container: 8175 protocol: tcp + bind: 127.0.0.1 + auth: local volumes: - type: bind diff --git a/apps/filebrowser/manifest.yml b/apps/filebrowser/manifest.yml index 2d24bc14..a47b9431 100644 --- a/apps/filebrowser/manifest.yml +++ b/apps/filebrowser/manifest.yml @@ -27,6 +27,8 @@ app: - host: 8083 container: 80 protocol: tcp + bind: 127.0.0.1 + auth: gated volumes: - type: bind diff --git a/apps/gitea/manifest.yml b/apps/gitea/manifest.yml index 4926ba3f..0a4a2293 100644 --- a/apps/gitea/manifest.yml +++ b/apps/gitea/manifest.yml @@ -26,6 +26,8 @@ app: - host: 3001 container: 3000 protocol: tcp + bind: 127.0.0.1 + auth: gated - host: 2222 container: 22 protocol: tcp diff --git a/apps/grafana/manifest.yml b/apps/grafana/manifest.yml index ed6a6b42..3ef6938a 100644 --- a/apps/grafana/manifest.yml +++ b/apps/grafana/manifest.yml @@ -31,6 +31,8 @@ app: - host: 3000 container: 3000 protocol: tcp # Web UI + bind: 127.0.0.1 + auth: gated volumes: - type: bind diff --git a/apps/home-assistant/manifest.yml b/apps/home-assistant/manifest.yml index 135db17b..7a4c3a2f 100644 --- a/apps/home-assistant/manifest.yml +++ b/apps/home-assistant/manifest.yml @@ -30,6 +30,8 @@ app: - host: 8123 container: 8123 protocol: tcp # Web UI + bind: 127.0.0.1 + auth: gated volumes: - type: bind diff --git a/apps/immich/manifest.yml b/apps/immich/manifest.yml index 73fff674..a09b23dd 100644 --- a/apps/immich/manifest.yml +++ b/apps/immich/manifest.yml @@ -44,6 +44,8 @@ app: - host: 2283 container: 2283 protocol: tcp + bind: 127.0.0.1 + auth: gated volumes: - type: bind diff --git a/apps/indeedhub/manifest.yml b/apps/indeedhub/manifest.yml index 471678c4..a71f6777 100644 --- a/apps/indeedhub/manifest.yml +++ b/apps/indeedhub/manifest.yml @@ -38,6 +38,8 @@ app: - host: 7778 container: 7777 protocol: tcp # Web UI. Port 7777 on the host is reserved for the Nostr relay. + bind: 127.0.0.1 + auth: gated # Writable scratch the baked nginx needs; matches the legacy installer's # --tmpfs /run + /var/cache/nginx. diff --git a/apps/jellyfin/manifest.yml b/apps/jellyfin/manifest.yml index 7234c1c8..94ab1424 100644 --- a/apps/jellyfin/manifest.yml +++ b/apps/jellyfin/manifest.yml @@ -25,6 +25,8 @@ app: - host: 8096 container: 8096 protocol: tcp + bind: 127.0.0.1 + auth: gated volumes: - type: bind diff --git a/apps/mempool-api/manifest.yml b/apps/mempool-api/manifest.yml index 24b628ce..9bf8fe27 100644 --- a/apps/mempool-api/manifest.yml +++ b/apps/mempool-api/manifest.yml @@ -42,6 +42,8 @@ app: - host: 8999 container: 8999 protocol: tcp + bind: 127.0.0.1 + auth: local volumes: - type: bind diff --git a/apps/mempool/manifest.yml b/apps/mempool/manifest.yml index fbaf7263..ee64420b 100644 --- a/apps/mempool/manifest.yml +++ b/apps/mempool/manifest.yml @@ -33,6 +33,8 @@ app: - host: 4080 container: 8080 # mempool-frontend nginx listens on 8080 (FRONTEND_HTTP_PORT=8080) protocol: tcp # Web UI + bind: 127.0.0.1 + auth: gated volumes: - type: bind diff --git a/apps/morphos-server/manifest.yml b/apps/morphos-server/manifest.yml index dd032bf4..32975649 100644 --- a/apps/morphos-server/manifest.yml +++ b/apps/morphos-server/manifest.yml @@ -30,6 +30,8 @@ app: - host: 8089 container: 8080 protocol: tcp # Web UI + bind: 127.0.0.1 + auth: gated volumes: - type: bind diff --git a/apps/nextcloud/manifest.yml b/apps/nextcloud/manifest.yml index a8165868..6fb16d44 100644 --- a/apps/nextcloud/manifest.yml +++ b/apps/nextcloud/manifest.yml @@ -25,6 +25,8 @@ app: - host: 8085 container: 80 protocol: tcp + bind: 127.0.0.1 + auth: gated volumes: - type: bind diff --git a/apps/nostr-rs-relay/manifest.yml b/apps/nostr-rs-relay/manifest.yml index 975a07ae..5bfb5bde 100644 --- a/apps/nostr-rs-relay/manifest.yml +++ b/apps/nostr-rs-relay/manifest.yml @@ -31,6 +31,8 @@ app: - host: 18081 container: 8080 protocol: tcp # HTTP/WebSocket + bind: 127.0.0.1 + auth: gated volumes: - type: bind diff --git a/apps/photoprism/manifest.yml b/apps/photoprism/manifest.yml index 485d5936..88cbcb73 100644 --- a/apps/photoprism/manifest.yml +++ b/apps/photoprism/manifest.yml @@ -24,6 +24,8 @@ app: - host: 2342 container: 2342 protocol: tcp + bind: 127.0.0.1 + auth: gated volumes: - type: bind diff --git a/apps/portainer/manifest.yml b/apps/portainer/manifest.yml index 2307b504..70fdac89 100644 --- a/apps/portainer/manifest.yml +++ b/apps/portainer/manifest.yml @@ -27,6 +27,8 @@ app: - host: 9000 container: 9000 protocol: tcp + bind: 127.0.0.1 + auth: gated volumes: - type: bind diff --git a/apps/searxng/manifest.yml b/apps/searxng/manifest.yml index 1eeb727d..0727ff36 100644 --- a/apps/searxng/manifest.yml +++ b/apps/searxng/manifest.yml @@ -29,6 +29,8 @@ app: - host: 8888 container: 8080 protocol: tcp # Web UI + bind: 127.0.0.1 + auth: gated volumes: - type: bind diff --git a/apps/strfry/manifest.yml b/apps/strfry/manifest.yml index ef74ed72..eac12a1d 100644 --- a/apps/strfry/manifest.yml +++ b/apps/strfry/manifest.yml @@ -29,6 +29,8 @@ app: - host: 8090 container: 7777 protocol: tcp # HTTP/WebSocket (strfry listens on 7777) + bind: 127.0.0.1 + auth: gated volumes: - type: bind diff --git a/apps/uptime-kuma/manifest.yml b/apps/uptime-kuma/manifest.yml index e58f0bbe..391b3c88 100644 --- a/apps/uptime-kuma/manifest.yml +++ b/apps/uptime-kuma/manifest.yml @@ -26,6 +26,8 @@ app: - host: 3002 container: 3001 protocol: tcp + bind: 127.0.0.1 + auth: gated volumes: - type: bind diff --git a/apps/vaultwarden/manifest.yml b/apps/vaultwarden/manifest.yml index 2f3d49d3..1e85629f 100644 --- a/apps/vaultwarden/manifest.yml +++ b/apps/vaultwarden/manifest.yml @@ -25,6 +25,8 @@ app: - host: 8082 container: 80 protocol: tcp + bind: 127.0.0.1 + auth: gated volumes: - type: bind From 6d9d87caa6c668521906082973a516173c1571df Mon Sep 17 00:00:00 2001 From: archipelago Date: Tue, 4 Aug 2026 06:09:37 -0400 Subject: [PATCH 07/60] chore: sync Cargo.lock with the 1.7.121-alpha version bump Co-Authored-By: Claude Fable 5 --- core/Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/Cargo.lock b/core/Cargo.lock index fb7dfa18..6453ad1a 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -104,7 +104,7 @@ dependencies = [ [[package]] name = "archipelago" -version = "1.7.120-alpha" +version = "1.7.121-alpha" dependencies = [ "anyhow", "archipelago-container", From 8210ca0a2aa227b6abcda42b6014571797da77ea Mon Sep 17 00:00:00 2001 From: archipelago Date: Tue, 4 Aug 2026 06:28:06 -0400 Subject: [PATCH 08/60] =?UTF-8?q?chore(catalog):=20sign=20catalog=20with?= =?UTF-8?q?=20port=20auth=20policy=20=E2=80=94=2020=20UIs=20gated,=20exemp?= =?UTF-8?q?tions=20declared?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Embeds the manifest port declarations (bind: 127.0.0.1 + auth: gated on 20 HTTP UIs, auth: local on loopback backends, auth: none + rationale on protocol ports) into the signed catalog so nodes enforce the app gate. Also carries bitcoin-ui/lnd-ui 1.7.119 version drift. Co-Authored-By: Claude Fable 5 --- releases/app-catalog.json | 109 +++++++++++++++++++++++++++++++++----- 1 file changed, 95 insertions(+), 14 deletions(-) diff --git a/releases/app-catalog.json b/releases/app-catalog.json index bb2eb1ff..11feb497 100644 --- a/releases/app-catalog.json +++ b/releases/app-catalog.json @@ -25,6 +25,7 @@ "name": "AI Assistant", "ports": [ { + "auth": "local", "bind": "127.0.0.1", "container": 80, "host": 5180, @@ -228,6 +229,8 @@ "name": "Mempool Web", "ports": [ { + "auth": "gated", + "bind": "127.0.0.1", "container": 8080, "host": 4080, "protocol": "tcp" @@ -302,6 +305,8 @@ "name": "NBXplorer", "ports": [ { + "auth": "local", + "bind": "127.0.0.1", "container": 32838, "host": 32838, "protocol": "tcp" @@ -374,6 +379,8 @@ "name": "Ark Wallet", "ports": [ { + "auth": "local", + "bind": "127.0.0.1", "container": 3535, "host": 3535, "protocol": "tcp" @@ -462,12 +469,15 @@ "name": "Bitcoin Core", "ports": [ { + "auth": "local", "bind": "127.0.0.1", "container": 8332, "host": 8332, "protocol": "tcp" }, { + "auth": "none", + "auth_rationale": "Bitcoin p2p gossip. Peers are anonymous by design and speak the Bitcoin wire protocol, not HTTP.", "container": 8333, "host": 8333, "protocol": "tcp" @@ -605,12 +615,15 @@ "name": "Bitcoin Knots", "ports": [ { + "auth": "local", "bind": "127.0.0.1", "container": 8332, "host": 8332, "protocol": "tcp" }, { + "auth": "none", + "auth_rationale": "Bitcoin p2p gossip. Peers are anonymous by design and speak the Bitcoin wire protocol, not HTTP.", "container": 8333, "host": 8333, "protocol": "tcp" @@ -671,7 +684,7 @@ ] }, "bitcoin-ui": { - "image": "146.59.87.168:3000/lfg2025/bitcoin-ui:1.7.84-alpha", + "image": "146.59.87.168:3000/lfg2025/bitcoin-ui:1.7.119-alpha", "manifest": { "app": { "container": { @@ -719,7 +732,7 @@ ] } }, - "version": "1.7.84-alpha" + "version": "1.7.119-alpha" }, "botfights": { "manifest": { @@ -931,6 +944,8 @@ "name": "BTCPay Server", "ports": [ { + "auth": "gated", + "bind": "127.0.0.1", "container": 49392, "host": 23000, "protocol": "tcp" @@ -1001,11 +1016,15 @@ "name": "Core Lightning (CLN)", "ports": [ { + "auth": "none", + "auth_rationale": "Lightning p2p. The BOLT-8 noise handshake authenticates and encrypts the channel itself.", "container": 9735, "host": 9736, "protocol": "tcp" }, { + "auth": "none", + "auth_rationale": "Core Lightning gRPC, authenticated by mutual TLS client certificates.", "container": 9835, "host": 9835, "protocol": "tcp" @@ -1075,6 +1094,8 @@ "name": "Web5 DID Wallet", "ports": [ { + "auth": "gated", + "bind": "127.0.0.1", "container": 8080, "host": 8088, "protocol": "tcp" @@ -1219,6 +1240,8 @@ "name": "ElectrumX", "ports": [ { + "auth": "none", + "auth_rationale": "Electrum wire protocol over TCP. Electrum wallets speak it directly and cannot hold a session cookie.", "container": 50001, "host": 50001, "protocol": "tcp" @@ -1342,6 +1365,8 @@ "protocol": "tcp" }, { + "auth": "local", + "bind": "127.0.0.1", "container": 8175, "host": 8177, "protocol": "tcp" @@ -1416,6 +1441,8 @@ "name": "Fedimint Client", "ports": [ { + "auth": "local", + "bind": "127.0.0.1", "container": 8080, "host": 8178, "protocol": "tcp" @@ -1601,6 +1628,8 @@ "name": "File Browser", "ports": [ { + "auth": "gated", + "bind": "127.0.0.1", "container": 80, "host": 8083, "protocol": "tcp" @@ -1753,11 +1782,15 @@ }, "ports": [ { + "auth": "gated", + "bind": "127.0.0.1", "container": 3000, "host": 3001, "protocol": "tcp" }, { + "auth": "none", + "auth_rationale": "Git over SSH, authenticated by the user's own SSH keypair. Not HTTP, so the gate cannot serve a login page here.", "container": 22, "host": 2222, "protocol": "tcp" @@ -1842,6 +1875,8 @@ "name": "Grafana", "ports": [ { + "auth": "gated", + "bind": "127.0.0.1", "container": 3000, "host": 3000, "protocol": "tcp" @@ -1925,6 +1960,8 @@ "name": "Home Assistant", "ports": [ { + "auth": "gated", + "bind": "127.0.0.1", "container": 8123, "host": 8123, "protocol": "tcp" @@ -2034,6 +2071,8 @@ "name": "Immich", "ports": [ { + "auth": "gated", + "bind": "127.0.0.1", "container": 2283, "host": 2283, "protocol": "tcp" @@ -2273,6 +2312,8 @@ "name": "IndeeHub", "ports": [ { + "auth": "gated", + "bind": "127.0.0.1", "container": 7777, "host": 7778, "protocol": "tcp" @@ -2765,6 +2806,8 @@ "name": "Jellyfin", "ports": [ { + "auth": "gated", + "bind": "127.0.0.1", "container": 8096, "host": 8096, "protocol": "tcp" @@ -2852,11 +2895,15 @@ "name": "Lightning Stack", "ports": [ { + "auth": "none", + "auth_rationale": "Lightning p2p. The BOLT-8 noise handshake authenticates and encrypts the channel itself.", "container": 9735, "host": 9738, "protocol": "tcp" }, { + "auth": "none", + "auth_rationale": "LND gRPC, authenticated by macaroon over TLS. Remote wallets depend on reaching this directly.", "container": 10009, "host": 10010, "protocol": "tcp" @@ -2950,16 +2997,22 @@ "name": "LND", "ports": [ { + "auth": "none", + "auth_rationale": "Lightning p2p. The BOLT-8 noise handshake authenticates and encrypts the channel itself.", "container": 9735, "host": 9735, "protocol": "tcp" }, { + "auth": "none", + "auth_rationale": "LND gRPC, authenticated by macaroon over TLS. Zeus and other remote wallets depend on reaching this directly.", "container": 10009, "host": 10009, "protocol": "tcp" }, { + "auth": "none", + "auth_rationale": "LND REST, authenticated by macaroon over TLS. A browser login page would break Zeus and every non-browser wallet client.", "container": 8080, "host": 18080, "protocol": "tcp" @@ -2998,7 +3051,7 @@ "version": "v0.18.4-beta" }, "lnd-ui": { - "image": "146.59.87.168:3000/lfg2025/lnd-ui:latest", + "image": "146.59.87.168:3000/lfg2025/lnd-ui:1.7.119-alpha", "manifest": { "app": { "container": { @@ -3025,25 +3078,19 @@ }, "id": "lnd-ui", "name": "LND UI", - "ports": [ - { - "container": 80, - "host": 18083, - "protocol": "tcp" - } - ], + "ports": [], "resources": { "memory_limit": "64Mi" }, "security": { - "network_policy": "bridge", + "network_policy": "host", "readonly_root": false }, "version": "1.0.0", "volumes": [] } }, - "version": "latest" + "version": "1.7.119-alpha" }, "mempool": { "image": "146.59.87.168:3000/lfg2025/mempool-frontend:v3.0.1", @@ -3093,6 +3140,8 @@ "name": "Mempool Explorer", "ports": [ { + "auth": "gated", + "bind": "127.0.0.1", "container": 8080, "host": 4080, "protocol": "tcp" @@ -3196,6 +3245,8 @@ "name": "Mempool API", "ports": [ { + "auth": "local", + "bind": "127.0.0.1", "container": 8999, "host": 8999, "protocol": "tcp" @@ -3255,6 +3306,8 @@ "name": "MorphOS Server", "ports": [ { + "auth": "gated", + "bind": "127.0.0.1", "container": 8080, "host": 8089, "protocol": "tcp" @@ -3569,6 +3622,8 @@ "protocol": "tcp" }, { + "auth": "none", + "auth_rationale": "STUN over UDP for NAT traversal; it must answer unauthenticated probes to do its job at all.", "container": 3478, "host": 3478, "protocol": "udp" @@ -3653,6 +3708,8 @@ "name": "Nextcloud", "ports": [ { + "auth": "gated", + "bind": "127.0.0.1", "container": 80, "host": 8085, "protocol": "tcp" @@ -3731,6 +3788,8 @@ }, "ports": [ { + "auth": "gated", + "bind": "127.0.0.1", "container": 8080, "host": 18081, "protocol": "tcp" @@ -3832,6 +3891,8 @@ "name": "PhotoPrism", "ports": [ { + "auth": "gated", + "bind": "127.0.0.1", "container": 2342, "host": 2342, "protocol": "tcp" @@ -4066,6 +4127,8 @@ "name": "Pine Wake Word (openWakeWord)", "ports": [ { + "auth": "none", + "auth_rationale": "Wyoming voice protocol, a binary local-only stream consumed by Home Assistant; not HTTP and not browser-reachable.", "container": 10400, "host": 10400, "protocol": "tcp" @@ -4144,6 +4207,8 @@ "name": "Pine Piper (TTS)", "ports": [ { + "auth": "none", + "auth_rationale": "Wyoming voice protocol, a binary local-only stream consumed by Home Assistant; not HTTP and not browser-reachable.", "container": 10200, "host": 10200, "protocol": "tcp" @@ -4226,6 +4291,8 @@ "name": "Pine Whisper (STT)", "ports": [ { + "auth": "none", + "auth_rationale": "Wyoming voice protocol, a binary local-only stream consumed by Home Assistant; not HTTP and not browser-reachable.", "container": 10300, "host": 10300, "protocol": "tcp" @@ -4298,6 +4365,8 @@ "name": "Portainer", "ports": [ { + "auth": "gated", + "bind": "127.0.0.1", "container": 9000, "host": 9000, "protocol": "tcp" @@ -4394,11 +4463,15 @@ "protocol": "tcp" }, { + "auth": "none", + "auth_rationale": "mDNS is UDP multicast service discovery; gating it would break .local name resolution for every device on the LAN.", "container": 5353, "host": 5353, "protocol": "udp" }, { + "auth": "none", + "auth_rationale": "SSDP/UPnP discovery is UDP multicast — there is no HTTP request to gate and no client that could hold a session.", "container": 1900, "host": 1900, "protocol": "udp" @@ -4478,6 +4551,8 @@ "name": "SearXNG", "ports": [ { + "auth": "gated", + "bind": "127.0.0.1", "container": 8080, "host": 8888, "protocol": "tcp" @@ -4549,6 +4624,8 @@ }, "ports": [ { + "auth": "gated", + "bind": "127.0.0.1", "container": 7777, "host": 8090, "protocol": "tcp" @@ -4639,6 +4716,8 @@ "name": "Uptime Kuma", "ports": [ { + "auth": "gated", + "bind": "127.0.0.1", "container": 3001, "host": 3002, "protocol": "tcp" @@ -4720,6 +4799,8 @@ "name": "Vaultwarden", "ports": [ { + "auth": "gated", + "bind": "127.0.0.1", "container": 80, "host": 8082, "protocol": "tcp" @@ -4756,7 +4837,7 @@ } }, "schema": 1, - "signature": "1fe1b962317212c15b83c9ae8b0b2957f9663bb7dda3f4d117123aab496ddd4f94fa48f7d4abfd4be5b771510aed51f8ede870e55cb3fd21ce2453bea1d3510e", + "signature": "cc83d0be50ce6144e2b5693a7175d7743d4a19141f4ef9a46a3c88d2dadd848acda9c25063e7a8b5643cecb2ccde279762a00a9715a5d26c99a95b492bc82a05", "signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur", - "updated": "2026-07-31" + "updated": "2026-08-04" } From 3760a00ea36e4038ecf0dcfc9ae036e95829d2bf Mon Sep 17 00:00:00 2001 From: archipelago Date: Tue, 4 Aug 2026 08:13:59 -0400 Subject: [PATCH 09/60] fix(security): Tor onions for gated ports forward to the gate, not the app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tor carries no session cookie, so HiddenServicePort → 127.0.0.1: reached the app around the gate — the last transport the gate did not cover. The gate now binds 127.0.0.2 (its own loopback, distinct from the app's 127.0.0.1, so no app needs a second port), and regenerate_torrc forwards declared-gated ports there. Undeclared ports keep today's target: absence of the field is not an instruction. The 127.0.0.2 claim deliberately does not count toward the unprotected audit — a port whose only claim is the Tor loopback is still wide open on the LAN and must keep warning. Co-Authored-By: Claude Fable 5 --- core/archipelago/src/api/rpc/tor/mod.rs | 63 ++++++++++++++++++++++-- core/archipelago/src/appgate/listener.rs | 30 +++++++++++ 2 files changed, 88 insertions(+), 5 deletions(-) diff --git a/core/archipelago/src/api/rpc/tor/mod.rs b/core/archipelago/src/api/rpc/tor/mod.rs index aad7dcec..7b3cc5af 100644 --- a/core/archipelago/src/api/rpc/tor/mod.rs +++ b/core/archipelago/src/api/rpc/tor/mod.rs @@ -222,6 +222,18 @@ pub(in crate::api::rpc) async fn regenerate_torrc(config: &ServicesConfig) -> Re lines.push("# ControlPort disabled for security".to_string()); lines.push(String::new()); + // Ports whose manifests declare `auth: gated` forward to the gate's own + // loopback (127.0.0.2, where the app-gate listener binds — see + // `appgate::listener::GATE_TOR_UPSTREAM`) instead of the app's 127.0.0.1. + // Tor carries no session cookie, so an onion pointed at the app is an + // unauthenticated bypass of the gate. Declared-gated ports only: an + // undeclared port keeps today's target, because absence of the field is + // not an instruction (the v1.7.121 incident rule). + let gated_ports: std::collections::HashSet = crate::appgate::identity::build_port_map() + .gated_ports() + .map(|g| g.port) + .collect(); + for svc in &config.services { if !svc.enabled { continue; @@ -240,7 +252,7 @@ pub(in crate::api::rpc) async fn regenerate_torrc(config: &ServicesConfig) -> Re lines.push("HiddenServicePort 10009 127.0.0.1:10009".to_string()); } } else { - lines.push(format!("HiddenServicePort 80 127.0.0.1:{}", svc.local_port)); + lines.push(app_hidden_service_port_line(svc.local_port, &gated_ports)); } lines.push(String::new()); @@ -248,6 +260,24 @@ pub(in crate::api::rpc) async fn regenerate_torrc(config: &ServicesConfig) -> Re let content = lines.join("\n"); let staging = "/var/lib/archipelago/tor-config/torrc.staged"; + write_staged_torrc(&content, staging).await +} + +/// The `HiddenServicePort` line for an HTTP app onion. Gated ports forward to +/// the gate's Tor upstream; everything else to the app itself. +fn app_hidden_service_port_line( + local_port: u16, + gated_ports: &std::collections::HashSet, +) -> String { + let upstream = if gated_ports.contains(&local_port) { + crate::appgate::listener::GATE_TOR_UPSTREAM.to_string() + } else { + "127.0.0.1".to_string() + }; + format!("HiddenServicePort 80 {}:{}", upstream, local_port) +} + +async fn write_staged_torrc(content: &str, staging: &str) -> Result<()> { let config_dir = Path::new(staging) .parent() .unwrap_or_else(|| Path::new("/var/lib/archipelago/tor-config")); @@ -256,14 +286,37 @@ pub(in crate::api::rpc) async fn regenerate_torrc(config: &ServicesConfig) -> Re .await .context("Failed to write staged torrc")?; - debug!( - "Staged torrc with {} enabled services", - config.services.iter().filter(|s| s.enabled).count() - ); + debug!("Staged torrc ({} bytes)", content.len()); Ok(()) } +#[cfg(test)] +mod torrc_tests { + use super::app_hidden_service_port_line; + use std::collections::HashSet; + + #[test] + fn gated_port_forwards_to_the_gate_not_the_app() { + let gated: HashSet = [8082u16].into_iter().collect(); + assert_eq!( + app_hidden_service_port_line(8082, &gated), + "HiddenServicePort 80 127.0.0.2:8082" + ); + } + + #[test] + fn undeclared_port_keeps_the_app_loopback_target() { + // Absence of `auth: gated` is not an instruction — the onion keeps + // pointing at the app, exactly as before this change. + let gated: HashSet = [8082u16].into_iter().collect(); + assert_eq!( + app_hidden_service_port_line(9100, &gated), + "HiddenServicePort 80 127.0.0.1:9100" + ); + } +} + // ─── Hostname Sync ─────────────────────────────────────────────── pub(in crate::api::rpc) async fn sync_single_hostname(name: &str, address: &str) { diff --git a/core/archipelago/src/appgate/listener.rs b/core/archipelago/src/appgate/listener.rs index 10af2946..4e1a198d 100644 --- a/core/archipelago/src/appgate/listener.rs +++ b/core/archipelago/src/appgate/listener.rs @@ -44,6 +44,15 @@ use tracing::{debug, info, warn}; /// apps are installed while the daemon runs. const SWEEP_INTERVAL: std::time::Duration = std::time::Duration::from_secs(60); +/// The gate's own loopback address, distinct from the app's `127.0.0.1`. +/// +/// Tor cannot present a session cookie, so `HiddenServicePort → 127.0.0.1` +/// reaches the app around the gate. Instead torrc forwards gated ports to +/// this address (`api/rpc/tor`), where the gate — not the app — listens. A +/// second loopback address rather than a second port number, so no app needs +/// a port it did not declare. +pub const GATE_TOR_UPSTREAM: IpAddr = IpAddr::V4(std::net::Ipv4Addr::new(127, 0, 0, 2)); + /// A port the gate should own but could not claim, and why. #[derive(Debug, Clone, serde::Serialize)] pub struct UnprotectedPort { @@ -192,6 +201,10 @@ async fn sweep( let mut claimed_any = false; let mut blocked = false; + // External addresses first, then the gate's Tor upstream. 127.0.0.2 + // deliberately does NOT count toward `claimed_any`: the warning below + // is about external exposure, and a port whose only claim is the Tor + // loopback is still wide open on the LAN. for &addr in &addresses { let key = (app.port, addr); if held.contains_key(&key) { @@ -214,6 +227,23 @@ async fn sweep( Err(_) => blocked = true, } } + let tor_key = (app.port, GATE_TOR_UPSTREAM); + if held.contains_key(&tor_key) { + claimed.push((app.port, GATE_TOR_UPSTREAM.to_string())); + } else { + match TcpListener::bind(SocketAddr::new(GATE_TOR_UPSTREAM, app.port)).await { + Ok(listener) => { + held.insert(tor_key, ()); + claimed.push((app.port, GATE_TOR_UPSTREAM.to_string())); + info!( + port = app.port, app = %app.app_id, + "app gate claimed the Tor upstream (127.0.0.2)" + ); + spawn_accept_loop(listener, gate.clone(), app.clone(), shutdown_rx.clone()); + } + Err(_) => blocked = true, + } + } if blocked && !claimed_any { warn!( From f08ed79b8a757521da139757c4644535d9922f71 Mon Sep 17 00:00:00 2001 From: archipelago Date: Tue, 4 Aug 2026 08:14:00 -0400 Subject: [PATCH 10/60] fix(security): FIPS v6 relay hands gated ports to the app gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mesh relay is a raw unauthenticated forward to the app's loopback, and whether it or the gate owned a fips0 ULA port was decided by a bind race — the dev box happened to be safe because the gate bound first. The relay now skips ports declared auth: gated and tears down any existing bridge for a port that became gated since it was bridged (catalog refresh), releasing the bind for the gate's next sweep. Co-Authored-By: Claude Fable 5 --- core/archipelago/src/server.rs | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/core/archipelago/src/server.rs b/core/archipelago/src/server.rs index a3c21ea0..8969b212 100644 --- a/core/archipelago/src/server.rs +++ b/core/archipelago/src/server.rs @@ -1145,16 +1145,37 @@ fn fips_app_relay_addr(ip: std::net::Ipv6Addr, port: u16) -> SocketAddr { /// without a daemon restart. Each relay binds to the fips0 ULA only and /// forwards raw TCP to the same port on IPv4 loopback. async fn app_port_v6_relay_loop(mut shutdown_rx: tokio::sync::watch::Receiver) { - use std::collections::HashSet; - let mut bridged: HashSet = HashSet::new(); + use std::collections::HashMap; + let mut bridged: HashMap> = HashMap::new(); let mut interval = tokio::time::interval(std::time::Duration::from_secs(60)); interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); loop { tokio::select! { _ = interval.tick() => { let Some(fips_ip) = crate::fips::iface::fips0_ula() else { continue }; + // Ports declared `auth: gated` belong to the app gate on the + // fips0 ULA. This relay is a raw unauthenticated forward to + // the app's loopback, so bridging a gated port would bypass + // the gate — and which of the two wins the bind used to be a + // race. Skip them here, and tear down any bridge for a port + // that became gated since it was bridged (catalog refresh), + // releasing the bind so the gate's next sweep claims it. + let gated: std::collections::HashSet = crate::appgate::identity::build_port_map() + .gated_ports() + .map(|g| g.port) + .collect(); for &port in crate::fips::app_ports::APP_LAUNCH_PORTS { - if bridged.contains(&port) { + if gated.contains(&port) { + if let Some(handle) = bridged.remove(&port) { + handle.abort(); + info!( + port, + "v6 relay released a bridge: port is now gate-owned" + ); + } + continue; + } + if bridged.contains_key(&port) { continue; } // ONLY bridge a port that a running app already answers on @@ -1181,10 +1202,9 @@ async fn app_port_v6_relay_loop(mut shutdown_rx: tokio::sync::watch::Receiver 127.0.0.1:{port}"); let mut rx = shutdown_rx.clone(); - tokio::spawn(async move { + let handle = tokio::spawn(async move { loop { tokio::select! { accepted = listener.accept() => { @@ -1205,6 +1225,7 @@ async fn app_port_v6_relay_loop(mut shutdown_rx: tokio::sync::watch::Receiver return, From e46af8cfe5548db512716ef2fcc7e31db6998241 Mon Sep 17 00:00:00 2001 From: archipelago Date: Tue, 4 Aug 2026 08:14:01 -0400 Subject: [PATCH 11/60] feat(security): self-heal legacy containers on declared bind drift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Legacy pre-quadlet containers kept publishing 0.0.0.0 after the catalog pinned their app to loopback, because host_port_bindings_drifted only compared host PORT numbers — closing them needed a manual package.update per app per node. The drift check now also compares the bind ADDRESS, but only when the manifest declares one: an empty bind never fires, since recreating a loopback-published container to wildcard on silence is exactly the v1.7.121 Bitcoin-RPC incident. With this, every node recreates its legacy containers to the declared state on its own after the OTA. Co-Authored-By: Claude Fable 5 --- .../src/container/prod_orchestrator.rs | 108 +++++++++++++++++- 1 file changed, 102 insertions(+), 6 deletions(-) diff --git a/core/archipelago/src/container/prod_orchestrator.rs b/core/archipelago/src/container/prod_orchestrator.rs index 6aea283d..0a31df14 100644 --- a/core/archipelago/src/container/prod_orchestrator.rs +++ b/core/archipelago/src/container/prod_orchestrator.rs @@ -595,10 +595,20 @@ async fn wait_for_manifest_host_ports( /// `podman inspect --format '{{json .HostConfig.PortBindings}}'` emits, e.g. /// `{"8080/tcp":[{"HostIp":"","HostPort":"18080"}]}`. Returns true only when a /// manifest container-port is positively published to a *different* host port -/// than the manifest now asks for. Absence of a binding is deliberately NOT -/// treated as drift here — that case is handled by the host-port repair/restart -/// path and by host-networked apps that publish nothing — so we never trigger a -/// destructive recreate on a false positive. +/// than the manifest now asks for — or, when the manifest DECLARES a bind +/// address, to a different host address. Absence of a binding is deliberately +/// NOT treated as drift here — that case is handled by the host-port +/// repair/restart path and by host-networked apps that publish nothing — so we +/// never trigger a destructive recreate on a false positive. +/// +/// The bind comparison is what lets a node self-heal after a catalog refresh +/// pins an app to loopback for the app gate: a legacy (pre-quadlet) container +/// still publishing `0.0.0.0:P` against a manifest that now declares +/// `bind: 127.0.0.1` is recreated to the declared state, exactly as +/// `package.update` would. An EMPTY manifest bind means "no instruction" and +/// never fires this — recreating a loopback-published container to wildcard on +/// silence is precisely the v1.7.121 incident class (Bitcoin RPC republished +/// on the LAN). fn host_port_bindings_drifted( port_bindings_json: &str, manifest_ports: &[archipelago_container::manifest::PortMapping], @@ -626,10 +636,26 @@ fn host_port_bindings_drifted( } let expected = port.host.to_string(); let matches_expected = bindings.iter().any(|b| { - b.get("HostPort") + let host_port_ok = b + .get("HostPort") .and_then(|h| h.as_str()) .map(|h| h == expected) - .unwrap_or(false) + .unwrap_or(false); + if !host_port_ok { + return false; + } + // Only a DECLARED bind participates; podman reports a wildcard + // publish as "" or "0.0.0.0". + if port.bind.is_empty() { + return true; + } + let actual_ip = b.get("HostIp").and_then(|h| h.as_str()).unwrap_or(""); + let actual = if actual_ip.is_empty() { + "0.0.0.0" + } else { + actual_ip + }; + actual == port.bind }); if !matches_expected { return true; @@ -4569,6 +4595,76 @@ mod tests { )); } + fn bound_port( + host: u16, + container: u16, + bind: &str, + ) -> archipelago_container::manifest::PortMapping { + archipelago_container::manifest::PortMapping { + bind: bind.to_string(), + ..port(host, container) + } + } + + #[test] + fn bind_drift_detected_when_declared_loopback_but_published_wildcard() { + // The legacy-container case: a pre-quadlet container still publishes + // 0.0.0.0 while the catalog-delivered manifest pins the app to + // loopback for the app gate. Must recreate, or the port stays open on + // every interface and the gate can never claim it. + for wildcard in [r#""""#, r#""0.0.0.0""#] { + let bindings = format!(r#"{{"80/tcp":[{{"HostIp":{wildcard},"HostPort":"8082"}}]}}"#); + assert!(host_port_bindings_drifted( + &bindings, + &[bound_port(8082, 80, "127.0.0.1")] + )); + } + } + + #[test] + fn no_bind_drift_when_declared_loopback_and_published_loopback() { + let bindings = r#"{"80/tcp":[{"HostIp":"127.0.0.1","HostPort":"8082"}]}"#; + assert!(!host_port_bindings_drifted( + bindings, + &[bound_port(8082, 80, "127.0.0.1")] + )); + } + + #[test] + fn no_bind_drift_on_undeclared_bind() { + // Silence is not consent (v1.7.121 incident class): an EMPTY manifest + // bind must never recreate a loopback-published container to + // wildcard — that is how Bitcoin's RPC got republished on the LAN. + let bindings = r#"{"8332/tcp":[{"HostIp":"127.0.0.1","HostPort":"8332"}]}"#; + assert!(!host_port_bindings_drifted(bindings, &[port(8332, 8332)])); + } + + #[test] + fn multi_bind_publish_satisfies_each_declared_entry() { + // Same host/container pair listed twice (loopback + archy-net + // gateway): both declared binds are present in the actual publish. + let bindings = r#"{"8332/tcp":[ + {"HostIp":"127.0.0.1","HostPort":"8332"}, + {"HostIp":"10.89.0.1","HostPort":"8332"} + ]}"#; + assert!(!host_port_bindings_drifted( + bindings, + &[ + bound_port(8332, 8332, "127.0.0.1"), + bound_port(8332, 8332, "10.89.0.1") + ] + )); + // And a wildcard-only publish drifts BOTH declared entries. + let wildcard = r#"{"8332/tcp":[{"HostIp":"","HostPort":"8332"}]}"#; + assert!(host_port_bindings_drifted( + wildcard, + &[ + bound_port(8332, 8332, "127.0.0.1"), + bound_port(8332, 8332, "10.89.0.1") + ] + )); + } + #[test] fn missing_secret_error_names_the_secret() { use archipelago_container::manifest::SecretsProvider; From d2e4b00789a161aca770147db484d4ab11c97e98 Mon Sep 17 00:00:00 2001 From: archipelago Date: Tue, 4 Aug 2026 08:50:46 -0400 Subject: [PATCH 12/60] fix(security): gate classifies from the catalog overlay and releases withdrawn claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dev-box verification of the Tor/FIPS fixes caught a pre-existing split brain: the orchestrator publishes containers from the signed catalog's embedded manifests (origin-wins), but the gate classified ports from the stale disk manifests — so it externally bound nbxplorer 32838, a port the catalog declares auth: local and pins to loopback. Reachable behind a login, but reachable where it deliberately was not. - build_port_map now consults the catalog overlay first, via the same parse/validate/image-only filter the orchestrator uses (moved to app_catalog::catalog_manifest_overlay so the two cannot diverge again). - GatedPort carries . The gated set still includes undeclared Session-default ports for challenge/audit, but every action that REDIRECTS traffic — the torrc 127.0.0.2 repoint, the FIPS relay stand-down, the Tor-upstream bind — now keys on the declaration. - The sweep releases held claims whose port left the gated set, so a catalog refresh that withdraws a port (gated → local/none) takes effect without a daemon restart. Co-Authored-By: Claude Fable 5 --- core/archipelago/src/api/rpc/tor/mod.rs | 1 + core/archipelago/src/appgate/identity.rs | 291 ++++++++++++------ core/archipelago/src/appgate/listener.rs | 78 +++-- core/archipelago/src/appgate/mod.rs | 1 + core/archipelago/src/container/app_catalog.rs | 40 +++ .../src/container/prod_orchestrator.rs | 25 +- core/archipelago/src/server.rs | 1 + 7 files changed, 293 insertions(+), 144 deletions(-) diff --git a/core/archipelago/src/api/rpc/tor/mod.rs b/core/archipelago/src/api/rpc/tor/mod.rs index 7b3cc5af..6153a0b0 100644 --- a/core/archipelago/src/api/rpc/tor/mod.rs +++ b/core/archipelago/src/api/rpc/tor/mod.rs @@ -231,6 +231,7 @@ pub(in crate::api::rpc) async fn regenerate_torrc(config: &ServicesConfig) -> Re // not an instruction (the v1.7.121 incident rule). let gated_ports: std::collections::HashSet = crate::appgate::identity::build_port_map() .gated_ports() + .filter(|g| g.declared) .map(|g| g.port) .collect(); diff --git a/core/archipelago/src/appgate/identity.rs b/core/archipelago/src/appgate/identity.rs index 26ef272c..aeee0b49 100644 --- a/core/archipelago/src/appgate/identity.rs +++ b/core/archipelago/src/appgate/identity.rs @@ -26,6 +26,15 @@ pub struct GatedPort { pub app_name: String, /// Manifest-declared icon path (`metadata.icon`), when present. pub icon: Option, + /// True only when the manifest says `auth: gated` in so many words. + /// + /// The gated set deliberately also carries undeclared Session-default + /// ports (so the gate challenges them wherever it can already stand, and + /// the audit reports them). But everything that CHANGES where traffic + /// goes — the torrc repoint to 127.0.0.2, the FIPS relay stand-down, the + /// Tor-upstream bind — must key on this flag: acting on an undeclared + /// port is the v1.7.121 incident class, whatever the action. + pub declared: bool, } /// A port deliberately left unauthenticated, and the manifest's stated reason. @@ -101,13 +110,35 @@ fn manifest_icon(manifest: &AppManifest) -> Option { /// Classify every published port across all installed manifests. /// -/// The first directory that yields a manifest for an app id wins, so a node's -/// `/opt/archipelago/apps` copy shadows a repo checkout rather than merging -/// with it — otherwise a stale checked-out manifest could re-open a port the -/// installed one gates. +/// The signed catalog's embedded manifests are consulted FIRST, because they +/// are what the orchestrator actually publishes containers from +/// (origin-wins; see `app_catalog::catalog_manifest_overlay`). Classifying +/// from disk alone made the gate act on policy the node was no longer +/// running: the catalog declared nbxplorer `auth: local` and pinned it to +/// loopback, the stale disk manifest declared nothing, and the gate +/// externally bound a deliberately host-local port (archi-dev-box +/// 2026-08-04). +/// +/// After the catalog, the first directory that yields a manifest for an app +/// id wins, so a node's `/opt/archipelago/apps` copy shadows a repo checkout +/// rather than merging with it — otherwise a stale checked-out manifest could +/// re-open a port the installed one gates. pub fn build_port_map() -> PortMap { let mut map = PortMap::default(); - let mut seen_apps: HashMap = HashMap::new(); + let mut seen_apps: std::collections::HashSet = std::collections::HashSet::new(); + + for (app_id, value) in crate::container::app_catalog::catalog_manifest_values() { + let Some(manifest) = + crate::container::app_catalog::catalog_manifest_overlay(&app_id, value) + else { + // Unparseable/invalid/build-source → the orchestrator falls back + // to disk for this app, so classification must too. + continue; + }; + if seen_apps.insert(app_id) { + classify_manifest(&manifest, &mut map); + } + } for dir in apps_dirs() { let Ok(entries) = std::fs::read_dir(&dir) else { @@ -124,100 +155,8 @@ pub fn build_port_map() -> PortMap { // would have published. continue; }; - let app_id = manifest.app.id.clone(); - if seen_apps.contains_key(&app_id) { - continue; - } - seen_apps.insert(app_id.clone(), path); - - let icon = manifest_icon(&manifest); - let app_name = if manifest.app.name.trim().is_empty() { - app_id.clone() - } else { - manifest.app.name.clone() - }; - - for port in &manifest.app.ports { - let protocol = if port.protocol.is_empty() { - "tcp" - } else { - port.protocol.as_str() - }; - match port.auth_policy() { - PortAuth::None => map.exempt.push(ExemptPort { - port: port.host, - app_id: app_id.clone(), - rationale: port - .auth_rationale - .clone() - .unwrap_or_else(|| "(no rationale recorded)".to_string()), - protocol: protocol.to_string(), - }), - // Declared host-local. Not gated and not reported as - // exposed, because it is neither — see PortAuth::Local - // for why this cannot be inferred from `bind`. - PortAuth::Local => {} - // Explicit opt-in: the app is on loopback and the daemon - // owns the external addresses. This is the ONLY way a - // port gets bound by the gate, regardless of `bind`. - PortAuth::Gated => { - map.gated.insert( - port.host, - GatedPort { - port: port.host, - app_id: app_id.clone(), - app_name: app_name.clone(), - icon: icon.clone(), - }, - ); - } - PortAuth::Session => { - // UDP cannot carry an HTTP challenge. Such a port has - // no business defaulting into the gated set where it - // would look protected without being protectable — - // surface it as an unrationalised exemption instead, - // which is honest and shows up in the audit list. - if protocol != "tcp" { - map.exempt.push(ExemptPort { - port: port.host, - app_id: app_id.clone(), - rationale: format!( - "{protocol} cannot carry an HTTP challenge; declare auth: none \ - with a rationale to record why this is safe" - ), - protocol: protocol.to_string(), - }); - continue; - } - // A loopback publish is skipped, and this is the - // safety property of the whole module: the gate must - // never be the reason a port becomes reachable - // somewhere it was not. `session` is the DEFAULT, so - // it is what every un-migrated manifest carries — - // and a node's installed manifests always lag the - // repo. Binding those externally published Bitcoin - // RPC across the LAN within seconds of deploy - // (archi-dev-box 2026-08-03). Taking over a port is - // opt-in only: `auth: gated`, shipped in the same - // manifest edit as the loopback pin. - if port - .bind - .parse::() - .is_ok_and(|ip| ip.is_loopback()) - { - continue; - } - map.gated.insert( - port.host, - GatedPort { - port: port.host, - app_id: app_id.clone(), - app_name: app_name.clone(), - icon: icon.clone(), - }, - ); - } - } + if seen_apps.insert(manifest.app.id.clone()) { + classify_manifest(&manifest, &mut map); } } } @@ -226,6 +165,103 @@ pub fn build_port_map() -> PortMap { map } +/// Classify one manifest's ports into the map. Split from [`build_port_map`] +/// so the catalog-overlay pass and the disk pass cannot diverge. +fn classify_manifest(manifest: &AppManifest, map: &mut PortMap) { + let app_id = manifest.app.id.clone(); + let icon = manifest_icon(manifest); + let app_name = if manifest.app.name.trim().is_empty() { + app_id.clone() + } else { + manifest.app.name.clone() + }; + + for port in &manifest.app.ports { + let protocol = if port.protocol.is_empty() { + "tcp" + } else { + port.protocol.as_str() + }; + match port.auth_policy() { + PortAuth::None => map.exempt.push(ExemptPort { + port: port.host, + app_id: app_id.clone(), + rationale: port + .auth_rationale + .clone() + .unwrap_or_else(|| "(no rationale recorded)".to_string()), + protocol: protocol.to_string(), + }), + // Declared host-local. Not gated and not reported as + // exposed, because it is neither — see PortAuth::Local + // for why this cannot be inferred from `bind`. + PortAuth::Local => {} + // Explicit opt-in: the app is on loopback and the daemon + // owns the external addresses. This is the ONLY way a + // port gets bound by the gate, regardless of `bind`. + PortAuth::Gated => { + map.gated.insert( + port.host, + GatedPort { + port: port.host, + app_id: app_id.clone(), + app_name: app_name.clone(), + icon: icon.clone(), + declared: true, + }, + ); + } + PortAuth::Session => { + // UDP cannot carry an HTTP challenge. Such a port has + // no business defaulting into the gated set where it + // would look protected without being protectable — + // surface it as an unrationalised exemption instead, + // which is honest and shows up in the audit list. + if protocol != "tcp" { + map.exempt.push(ExemptPort { + port: port.host, + app_id: app_id.clone(), + rationale: format!( + "{protocol} cannot carry an HTTP challenge; declare auth: none \ + with a rationale to record why this is safe" + ), + protocol: protocol.to_string(), + }); + continue; + } + // A loopback publish is skipped, and this is the + // safety property of the whole module: the gate must + // never be the reason a port becomes reachable + // somewhere it was not. `session` is the DEFAULT, so + // it is what every un-migrated manifest carries — + // and a node's installed manifests always lag the + // repo. Binding those externally published Bitcoin + // RPC across the LAN within seconds of deploy + // (archi-dev-box 2026-08-03). Taking over a port is + // opt-in only: `auth: gated`, shipped in the same + // manifest edit as the loopback pin. + if port + .bind + .parse::() + .is_ok_and(|ip| ip.is_loopback()) + { + continue; + } + map.gated.insert( + port.host, + GatedPort { + port: port.host, + app_id: app_id.clone(), + app_name: app_name.clone(), + icon: icon.clone(), + declared: false, + }, + ); + } + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -257,6 +293,63 @@ mod tests { } } + fn manifest(yaml: &str) -> AppManifest { + AppManifest::parse(yaml).expect("test manifest must parse") + } + + const BASE: &str = r#" +app: + id: testapp + name: Test App + version: "1.0" + container: + image: example.org/testapp:1.0 +"#; + + /// `auth: gated` is the only classification allowed to redirect traffic — + /// torrc repoints, relay stand-down, and the 127.0.0.2 bind all key on + /// `declared`. An undeclared Session port is challenged and audited but + /// must never be `declared`. + #[test] + fn declared_tracks_the_manifest_not_the_default() { + let mut map = PortMap::default(); + classify_manifest( + &manifest(&format!( + "{BASE} ports:\n - host: 8090\n container: 7777\n protocol: tcp\n bind: 127.0.0.1\n auth: gated\n" + )), + &mut map, + ); + assert!(map.gated(8090).expect("gated").declared); + + let mut map = PortMap::default(); + classify_manifest( + &manifest(&format!( + "{BASE} ports:\n - host: 9100\n container: 9100\n protocol: tcp\n" + )), + &mut map, + ); + let undeclared = map.gated(9100).expect("session default is challenged"); + assert!( + !undeclared.declared, + "an absent auth field must never read as an instruction" + ); + } + + /// `auth: local` keeps the gate's hands off entirely — the port is + /// neither gated nor exempt-reported. + #[test] + fn local_ports_are_untouched() { + let mut map = PortMap::default(); + classify_manifest( + &manifest(&format!( + "{BASE} ports:\n - host: 32838\n container: 32838\n protocol: tcp\n bind: 127.0.0.1\n auth: local\n" + )), + &mut map, + ); + assert!(map.gated(32838).is_none()); + assert!(map.exempt_ports().is_empty()); + } + /// Protocol ports that wallets dial directly must never end up gated — /// this is the constraint that decided the design (Zeus and electrum /// clients keep working untouched). diff --git a/core/archipelago/src/appgate/listener.rs b/core/archipelago/src/appgate/listener.rs index 4e1a198d..0a7844da 100644 --- a/core/archipelago/src/appgate/listener.rs +++ b/core/archipelago/src/appgate/listener.rs @@ -151,8 +151,12 @@ pub async fn run( mut shutdown_rx: tokio::sync::watch::Receiver, ) { // (port, addr) pairs already served, so a sweep does not rebind what it - // already holds. - let mut held: HashMap<(u16, IpAddr), ()> = HashMap::new(); + // already holds. The accept-loop handle is kept so a claim can be + // RELEASED when its port leaves the gated set — a catalog refresh + // declaring a port `local`/`none` must make the gate let go without a + // daemon restart, or the stale bind keeps republishing a port the + // catalog just withdrew (nbxplorer 32838, archi-dev-box 2026-08-04). + let mut held: HashMap<(u16, IpAddr), tokio::task::JoinHandle<()>> = HashMap::new(); let mut interval = tokio::time::interval(SWEEP_INTERVAL); interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); @@ -169,7 +173,7 @@ pub async fn run( async fn sweep( gate: &Arc, status: &Arc>, - held: &mut HashMap<(u16, IpAddr), ()>, + held: &mut HashMap<(u16, IpAddr), tokio::task::JoinHandle<()>>, shutdown_rx: &tokio::sync::watch::Receiver, ) { // Re-read the manifests every sweep rather than trusting the map built @@ -179,6 +183,23 @@ async fn sweep( // enforced while serving a brand-new app to anyone who asked. gate.refresh().await; let port_map = gate.port_map().await; + + // Release claims whose port left the gated set (or whose Tor-upstream + // claim lost its declaration). Aborting the accept loop drops the + // listener, freeing the address for whoever now legitimately owns it — + // the app itself, or nobody. + held.retain(|(port, addr), handle| { + let keep = match port_map.gated(*port) { + None => false, + Some(app) => *addr != GATE_TOR_UPSTREAM || app.declared, + }; + if !keep { + handle.abort(); + info!(port, %addr, "app gate released a claim: port is no longer gated here"); + } + keep + }); + let addresses = host_addresses().await; if addresses.is_empty() { debug!("app gate: no external addresses yet"); @@ -214,34 +235,46 @@ async fn sweep( } match TcpListener::bind(SocketAddr::new(addr, app.port)).await { Ok(listener) => { - held.insert(key, ()); + let handle = + spawn_accept_loop(listener, gate.clone(), app.clone(), shutdown_rx.clone()); + held.insert(key, handle); claimed.push((app.port, addr.to_string())); claimed_any = true; info!( port = app.port, %addr, app = %app.app_id, "app gate claimed an app port" ); - spawn_accept_loop(listener, gate.clone(), app.clone(), shutdown_rx.clone()); } // Almost always the app itself holding 0.0.0.0:. Err(_) => blocked = true, } } - let tor_key = (app.port, GATE_TOR_UPSTREAM); - if held.contains_key(&tor_key) { - claimed.push((app.port, GATE_TOR_UPSTREAM.to_string())); - } else { - match TcpListener::bind(SocketAddr::new(GATE_TOR_UPSTREAM, app.port)).await { - Ok(listener) => { - held.insert(tor_key, ()); - claimed.push((app.port, GATE_TOR_UPSTREAM.to_string())); - info!( - port = app.port, app = %app.app_id, - "app gate claimed the Tor upstream (127.0.0.2)" - ); - spawn_accept_loop(listener, gate.clone(), app.clone(), shutdown_rx.clone()); + // The Tor upstream is bound for DECLARED gated ports only: torrc only + // repoints an onion at 127.0.0.2 for a declared port, and standing a + // challenge on an undeclared port's would-be upstream would change + // where its traffic goes on nothing but a default. + if app.declared { + let tor_key = (app.port, GATE_TOR_UPSTREAM); + if held.contains_key(&tor_key) { + claimed.push((app.port, GATE_TOR_UPSTREAM.to_string())); + } else { + match TcpListener::bind(SocketAddr::new(GATE_TOR_UPSTREAM, app.port)).await { + Ok(listener) => { + let handle = spawn_accept_loop( + listener, + gate.clone(), + app.clone(), + shutdown_rx.clone(), + ); + held.insert(tor_key, handle); + claimed.push((app.port, GATE_TOR_UPSTREAM.to_string())); + info!( + port = app.port, app = %app.app_id, + "app gate claimed the Tor upstream (127.0.0.2)" + ); + } + Err(_) => blocked = true, } - Err(_) => blocked = true, } } @@ -281,12 +314,15 @@ async fn app_is_listening(port: u16) -> bool { .is_some() } +/// Returns the accept-loop task handle so the sweep can release the claim +/// (abort → listener drops → address freed) when the port leaves the gated +/// set. In-flight connections finish on their own tasks. fn spawn_accept_loop( listener: TcpListener, gate: Arc, app: GatedPort, mut shutdown_rx: tokio::sync::watch::Receiver, -) { +) -> tokio::task::JoinHandle<()> { tokio::spawn(async move { loop { tokio::select! { @@ -317,7 +353,7 @@ fn spawn_accept_loop( _ = shutdown_rx.changed() => break, } } - }); + }) } #[cfg(test)] diff --git a/core/archipelago/src/appgate/mod.rs b/core/archipelago/src/appgate/mod.rs index 8ac1ef43..dee3e7e0 100644 --- a/core/archipelago/src/appgate/mod.rs +++ b/core/archipelago/src/appgate/mod.rs @@ -578,6 +578,7 @@ mod tests { app_id: "strfry".to_string(), app_name: "Strfry Relay".to_string(), icon: None, + declared: true, } } diff --git a/core/archipelago/src/container/app_catalog.rs b/core/archipelago/src/container/app_catalog.rs index fccb23ba..63b46b30 100644 --- a/core/archipelago/src/container/app_catalog.rs +++ b/core/archipelago/src/container/app_catalog.rs @@ -216,6 +216,46 @@ pub fn catalog_manifest_values() -> Vec<(String, serde_json::Value)> { .collect() } +/// A catalog-embedded manifest as the node actually applies it: parsed, +/// id-checked, validated, and image-only (build-source manifests defer to +/// disk). `None` = the caller must fall back to the disk manifest. +/// +/// Shared between the orchestrator's load overlay and the app gate's port +/// classification so both answer "which manifest governs this app?" from the +/// same origin. They diverged once — the orchestrator published containers +/// from the catalog while the gate classified from stale disk manifests, and +/// the gate externally bound a port the catalog had declared `auth: local` +/// (nbxplorer 32838, archi-dev-box 2026-08-04). +pub fn catalog_manifest_overlay( + app_id: &str, + value: serde_json::Value, +) -> Option { + let m: archipelago_container::manifest::AppManifest = match serde_json::from_value(value) { + Ok(m) => m, + Err(e) => { + tracing::warn!(app = %app_id, error = %e, + "skipping unparseable catalog manifest; using disk fallback"); + return None; + } + }; + if m.app.id != app_id { + tracing::warn!(catalog_id = %app_id, manifest_id = %m.app.id, + "skipping catalog manifest: embedded app id mismatches catalog key"); + return None; + } + if let Err(e) = m.validate() { + tracing::warn!(app = %app_id, error = %e, + "skipping invalid catalog manifest; using disk fallback"); + return None; + } + if m.app.container.build.is_some() { + tracing::debug!(app = %app_id, + "catalog manifest has a build source; deferring to disk (phase 1 = image-only)"); + return None; + } + Some(m) +} + /// The catalog's default/latest version string for an app (the top-level /// `version` field), if covered. Used to decide whether an install-time /// selection should pin (older) or track-latest (default). diff --git a/core/archipelago/src/container/prod_orchestrator.rs b/core/archipelago/src/container/prod_orchestrator.rs index 0a31df14..a68c605a 100644 --- a/core/archipelago/src/container/prod_orchestrator.rs +++ b/core/archipelago/src/container/prod_orchestrator.rs @@ -1183,30 +1183,7 @@ struct LoadedManifest { /// source (build contexts aren't registry-distributed yet — phase 1 is /// image-only). See `docs/registry-manifest-design.md`. fn catalog_manifest_to_overlay(app_id: &str, value: serde_json::Value) -> Option { - let m: AppManifest = match serde_json::from_value(value) { - Ok(m) => m, - Err(e) => { - tracing::warn!(app = %app_id, error = %e, - "skipping unparseable catalog manifest; using disk fallback"); - return None; - } - }; - if m.app.id != app_id { - tracing::warn!(catalog_id = %app_id, manifest_id = %m.app.id, - "skipping catalog manifest: embedded app id mismatches catalog key"); - return None; - } - if let Err(e) = m.validate() { - tracing::warn!(app = %app_id, error = %e, - "skipping invalid catalog manifest; using disk fallback"); - return None; - } - if m.app.container.build.is_some() { - tracing::debug!(app = %app_id, - "catalog manifest has a build source; deferring to disk (phase 1 = image-only)"); - return None; - } - Some(m) + crate::container::app_catalog::catalog_manifest_overlay(app_id, value) } struct OrchestratorState { diff --git a/core/archipelago/src/server.rs b/core/archipelago/src/server.rs index 8969b212..6ee7c73a 100644 --- a/core/archipelago/src/server.rs +++ b/core/archipelago/src/server.rs @@ -1162,6 +1162,7 @@ async fn app_port_v6_relay_loop(mut shutdown_rx: tokio::sync::watch::Receiver = crate::appgate::identity::build_port_map() .gated_ports() + .filter(|g| g.declared) .map(|g| g.port) .collect(); for &port in crate::fips::app_ports::APP_LAUNCH_PORTS { From 0f21f598aa50d1a39184ce2b12c69587d40c308b Mon Sep 17 00:00:00 2001 From: archipelago Date: Tue, 4 Aug 2026 10:40:28 -0400 Subject: [PATCH 13/60] fix(security): FIPS mesh relay must not republish auth: local ports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Caught verifying the gate fixes on archi-dev-box: [fips0-ULA]:32838 answered HTTP 200 straight from nbxplorer with no credential. The catalog declares that port auth: local — host-local by intent, pinned to loopback, the gate deliberately keeps its hands off — but the mesh relay bridges a STATIC port list to 127.0.0.1, so it republished it to the whole mesh. Same bug class as the Tor onion gap: a transport that converges on the app loopback without consulting the declaration. PortMap now records declared-local ports and the relay withholds them (tearing down an existing bridge if a catalog refresh newly declares one), alongside the declared-gated withhold. Undeclared ports keep todays behaviour — silence is not an instruction in either direction. Co-Authored-By: Claude Fable 5 --- core/archipelago/src/appgate/identity.rs | 55 +++++++++++++++++++++--- core/archipelago/src/server.rs | 40 +++++++++++------ 2 files changed, 77 insertions(+), 18 deletions(-) diff --git a/core/archipelago/src/appgate/identity.rs b/core/archipelago/src/appgate/identity.rs index aeee0b49..f04da17f 100644 --- a/core/archipelago/src/appgate/identity.rs +++ b/core/archipelago/src/appgate/identity.rs @@ -57,6 +57,7 @@ pub struct ExemptPort { pub struct PortMap { gated: HashMap, exempt: Vec, + local: std::collections::HashSet, } impl PortMap { @@ -73,8 +74,21 @@ impl PortMap { &self.exempt } + /// Declared `auth: local` — host-local by intent, so NOTHING may make it + /// externally reachable. + /// + /// The gate honours this by keeping its hands off, but it is not the only + /// thing that can publish a port: the FIPS mesh relay bridges the fips0 + /// ULA to `127.0.0.1` for a static port list, and it forwarded nbxplorer + /// 32838 — declared `local` and pinned to loopback — to the mesh + /// unauthenticated (archi-dev-box 2026-08-04). Anything that republishes + /// a loopback port must consult this set first. + pub fn is_declared_local(&self, port: u16) -> bool { + self.local.contains(&port) + } + pub fn is_empty(&self) -> bool { - self.gated.is_empty() && self.exempt.is_empty() + self.gated.is_empty() && self.exempt.is_empty() && self.local.is_empty() } } @@ -194,8 +208,12 @@ fn classify_manifest(manifest: &AppManifest, map: &mut PortMap) { }), // Declared host-local. Not gated and not reported as // exposed, because it is neither — see PortAuth::Local - // for why this cannot be inferred from `bind`. - PortAuth::Local => {} + // for why this cannot be inferred from `bind`. Recorded so + // the mesh relay (and any future republisher) can refuse to + // expose it. + PortAuth::Local => { + map.local.insert(port.host); + } // Explicit opt-in: the app is on loopback and the daemon // owns the external addresses. This is the ONLY way a // port gets bound by the gate, regardless of `bind`. @@ -336,9 +354,10 @@ app: } /// `auth: local` keeps the gate's hands off entirely — the port is - /// neither gated nor exempt-reported. + /// neither gated nor exempt-reported — but it IS recorded, so the mesh + /// relay can refuse to republish a deliberately host-local port. #[test] - fn local_ports_are_untouched() { + fn local_ports_are_untouched_but_recorded() { let mut map = PortMap::default(); classify_manifest( &manifest(&format!( @@ -348,6 +367,32 @@ app: ); assert!(map.gated(32838).is_none()); assert!(map.exempt_ports().is_empty()); + assert!( + map.is_declared_local(32838), + "the mesh relay needs this to refuse bridging a host-local port" + ); + assert!(!map.is_declared_local(3000)); + } + + /// The real corpus: every port the FIPS relay can bridge must be safe to + /// bridge. A port that is declared `local` (host-local by intent) or + /// declared `gated` (the app gate owns its external addresses) must be + /// withheld by the relay — this asserts the two sets the relay consults + /// actually classify the live manifests, so a future manifest edit that + /// re-opens one is caught here rather than on a node. + #[test] + fn relay_port_list_respects_local_and_gated_declarations() { + let map = build_port_map(); + let relay_would_expose: Vec = crate::fips::app_ports::APP_LAUNCH_PORTS + .iter() + .copied() + .filter(|p| map.is_declared_local(*p)) + .collect(); + assert!( + !relay_would_expose.is_empty(), + "expected the corpus to contain at least one local port in the relay list \ + (32838/8999) — if this fails the guard is untested, not unnecessary" + ); } /// Protocol ports that wallets dial directly must never end up gated — diff --git a/core/archipelago/src/server.rs b/core/archipelago/src/server.rs index 6ee7c73a..dd2e0b33 100644 --- a/core/archipelago/src/server.rs +++ b/core/archipelago/src/server.rs @@ -1153,26 +1153,40 @@ async fn app_port_v6_relay_loop(mut shutdown_rx: tokio::sync::watch::Receiver { let Some(fips_ip) = crate::fips::iface::fips0_ula() else { continue }; - // Ports declared `auth: gated` belong to the app gate on the - // fips0 ULA. This relay is a raw unauthenticated forward to - // the app's loopback, so bridging a gated port would bypass - // the gate — and which of the two wins the bind used to be a - // race. Skip them here, and tear down any bridge for a port - // that became gated since it was bridged (catalog refresh), - // releasing the bind so the gate's next sweep claims it. - let gated: std::collections::HashSet = crate::appgate::identity::build_port_map() + // This relay is a raw unauthenticated forward from the mesh to + // the app's loopback, so it must refuse two classes of port: + // + // * `auth: gated` — the app gate owns the fips0 ULA for these, + // and bridging one would bypass the login page. Which of the + // two won the bind used to be a race. + // * `auth: local` — host-local BY INTENT. Bridging one makes a + // port reachable from the whole mesh that was deliberately + // never externally reachable: nbxplorer 32838 answered HTTP + // 200 over the mesh with no credential (archi-dev-box + // 2026-08-04) purely because it appeared in the static port + // list below. + // + // Undeclared ports keep today's behaviour — silence is not an + // instruction in either direction, and this relay predates the + // declarations. + let port_map = crate::appgate::identity::build_port_map(); + let gate_owned: std::collections::HashSet = port_map .gated_ports() .filter(|g| g.declared) .map(|g| g.port) .collect(); for &port in crate::fips::app_ports::APP_LAUNCH_PORTS { - if gated.contains(&port) { + let withhold = if gate_owned.contains(&port) { + Some("port is now gate-owned") + } else if port_map.is_declared_local(port) { + Some("port is declared auth: local (host-local by intent)") + } else { + None + }; + if let Some(reason) = withhold { if let Some(handle) = bridged.remove(&port) { handle.abort(); - info!( - port, - "v6 relay released a bridge: port is now gate-owned" - ); + info!(port, reason, "v6 relay released a bridge"); } continue; } From 3f4b5524b1efc613e87cbce3ea2e27ba605841ae Mon Sep 17 00:00:00 2001 From: archipelago Date: Tue, 4 Aug 2026 01:20:02 -0400 Subject: [PATCH 14/60] =?UTF-8?q?chore(trust):=20rotate=20the=20release=20?= =?UTF-8?q?root=20to=20z6Mkfu5LT=E2=80=A6DLWT?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DO NOT MERGE INTO A RELEASE SIGNED WITH THE NEW KEY. See below. The previous release root (z6Mkkid…q7ur, pinned 2026-07-02) was exposed in a chat transcript and is treated as compromised. It signs both OTA manifests and the app catalog, so anyone holding it could sign updates the fleet would install. Pins the new key in trust::anchor and moves EXPECTED_DID in all three signing/publishing scripts. ORDERING IS CRITICAL — nodes pin the OLD key: * The release CARRYING this commit must be signed with the OLD key. That is the only signature a node running the previous binary will accept, and it is what installs the binary pinning the new key. * Only the release AFTER that may be signed with the new key. * Signing this release with the new key makes every node reject it, ending OTA fleet-wide and requiring hands-on recovery per node. sign-catalog.sh moves in the same commit, so the app catalog must also be re-signed with the new key once this ships, or nodes accept the binary and reject the catalog. Key verified before pinning: the hex and the did:key are the same keypair, checked with a base58 decoder round-tripped against the previous known-good pair. An earlier candidate hex (cb830e13…) was rejected because it decoded to a different DID than the one supplied — pinning it would have made every node reject every future update. Co-Authored-By: Claude Opus 5 (1M context) --- core/archipelago/src/trust/anchor.rs | 21 ++++++++++++++++++--- scripts/create-release.sh | 2 +- scripts/publish-release-assets.sh | 2 +- scripts/sign-catalog.sh | 2 +- 4 files changed, 21 insertions(+), 6 deletions(-) diff --git a/core/archipelago/src/trust/anchor.rs b/core/archipelago/src/trust/anchor.rs index 8498532e..30bfe0a6 100644 --- a/core/archipelago/src/trust/anchor.rs +++ b/core/archipelago/src/trust/anchor.rs @@ -16,13 +16,28 @@ use ed25519_dalek::VerifyingKey; /// Hex of the pinned Ed25519 release-root public key (32 bytes / 64 hex chars). /// -/// Pinned 2026-07-02 from the release-root signing ceremony -/// (signer did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur). The +/// ROTATED 2026-08-04 to did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT. +/// +/// The previous root (z6Mkkid…q7ur, pinned 2026-07-02) was exposed in a chat +/// transcript and is treated as compromised. +/// +/// Rotation is ORDERING-CRITICAL. Nodes pin the OLD key, so the release that +/// carries this change must itself be signed with the OLD key — that is the +/// only signature a node running the previous binary will accept. Only the +/// release AFTER it may be signed with the new key. Signing the rotation +/// release with the new key makes every node reject it and ends OTA +/// fleet-wide, recoverable only by touching each node by hand. +/// +/// Verified before pinning: this hex and the did:key above are the same +/// keypair (the did:key encodes exactly these 32 bytes), checked with a +/// decoder round-tripped against the previous known-good pair. An earlier +/// candidate hex was rejected because it did not match the stated DID. +/// The /// corresponding mnemonic is held offline by the publisher — see /// `docs/workstream-b-signing-runbook.md`. Regenerate/verify with: /// `RELEASE_MASTER_MNEMONIC=… archipelago ceremony pubkey`. pub const RELEASE_ROOT_PUBKEY_HEX: Option<&str> = - Some("5d15cbee8a108f7dd288c02d29a1d9d71f198acc99186aad8008b4f28d469951"); + Some("1578adccf137024159dd936f44a56e8869ac7775785962f7e92e2faf2c034418"); const ENV_OVERRIDE: &str = "ARCHY_RELEASE_ROOT_PUBKEY"; diff --git a/scripts/create-release.sh b/scripts/create-release.sh index c35ddb11..d836ff9b 100755 --- a/scripts/create-release.sh +++ b/scripts/create-release.sh @@ -240,7 +240,7 @@ install -m 0644 "$FRONTEND_ARCHIVE" "$VERSION_DIR/archipelago-frontend-${VERSION # warning and falls through — and the commit then happened anyway. A release # commit carrying a manifest no node will accept has no valid use, so refuse # to create one rather than leave a tag that has to be re-cut. -EXPECTED_DID="did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur" +EXPECTED_DID="did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT" if ! grep -q '"signature":' "$PROJECT_ROOT/releases/manifest.json" \ || ! grep -q "\"signed_by\": \"$EXPECTED_DID\"" "$PROJECT_ROOT/releases/manifest.json"; then echo "" >&2 diff --git a/scripts/publish-release-assets.sh b/scripts/publish-release-assets.sh index 90a609eb..f5ba3d72 100755 --- a/scripts/publish-release-assets.sh +++ b/scripts/publish-release-assets.sh @@ -29,7 +29,7 @@ fail() { echo "Error: $*" >&2; exit 1; } # with the pinned release-root anchor refuse to auto-apply unsigned manifests, # and enforcement will tighten to hard-reject — an unsigned publish would # strand them. Grep proves presence; ceremony verify proves the crypto. -EXPECTED_DID="did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur" +EXPECTED_DID="did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT" grep -q '"signature":' "$PROJECT_ROOT/releases/manifest.json" \ && grep -q "\"signed_by\": \"$EXPECTED_DID\"" "$PROJECT_ROOT/releases/manifest.json" \ || fail "releases/manifest.json is not signed by the release root — run: bash scripts/sign-manifest.sh" diff --git a/scripts/sign-catalog.sh b/scripts/sign-catalog.sh index c9ea3727..71ea7021 100755 --- a/scripts/sign-catalog.sh +++ b/scripts/sign-catalog.sh @@ -11,7 +11,7 @@ set -euo pipefail REPO="/home/archipelago/Projects/archy" CATALOG="$REPO/releases/app-catalog.json" -EXPECTED_DID="did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur" +EXPECTED_DID="did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT" # Use ONLY the prebuilt signer. If it isn't ready, stop cleanly — never compile # here (compiling caused the earlier hangs). Claude builds it in the background. From c35e33d0a7abdebca15f4d27a0a92674a2868cee Mon Sep 17 00:00:00 2001 From: archipelago Date: Tue, 4 Aug 2026 16:33:03 -0400 Subject: [PATCH 15/60] docs(1.7.122): curate release notes and add the in-app What's New block Leads with what changes for the operator: app screens now require the node password across LAN, Tailscale, mesh and Tor; the wallet/protocol ports that must stay open stayed open; the mesh leak found during on-node verification; nodes repairing their own legacy containers; and the signing-key rotation. Known gaps disclosed, including the eleven still-undeclared ports and that non-browser clients will now meet the login page. The new block uses rather than the literal ** markers in earlier entries, which render as asterisks in the modal. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 11 +++++++++++ core/Cargo.lock | 2 +- core/archipelago/Cargo.toml | 2 +- neode-ui/package-lock.json | 4 ++-- neode-ui/package.json | 2 +- .../src/views/settings/AccountInfoSection.vue | 17 +++++++++++++++++ 6 files changed, 33 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f6d5c579..c7b8e079 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## v1.7.122-alpha (2026-08-04) + +- **Your apps now ask for your node password before they open — over your home network, Tailscale, the mesh and Tor alike.** Until now anyone who could reach your node could open Immich, Nextcloud, Vaultwarden, Jellyfin, Grafana and the rest simply by typing the address and port, with no login at all. Twenty app screens now sit behind the same login you use for the node, showing you which app you are opening, and honouring two-factor if you have it switched on. Logging in at an app address logs you into the dashboard too, so it is one password, not one per app. This completes the groundwork disclosed in v1.7.121. +- **The things that must stay open stayed open.** Zeus and other remote wallets still reach your Lightning node directly, Electrum wallets still connect, and Bitcoin still talks to its peers — those connections carry their own proof of identity and a login page would simply break them. Every one of these seventeen exceptions now has to state in writing why it is safe to leave open, so the list is something you can read rather than something you have to discover. +- **A private address on your node was answering the mesh without a password.** One app's port was marked as being for this machine only, and the part of the node that carries mesh traffic did not know that — it forwarded requests from the whole mesh straight to it. Found while verifying the work above on a real node, not in testing. That path now refuses anything marked machine-only, and the app is reachable only from the node itself, as intended. +- **Tor addresses no longer skip the login.** An app published as a .onion address was handed straight to the app, because a Tor visitor carries no session cookie to check. The login gate now takes those addresses first, closing the last of the four routes that went around it. +- Nodes fix themselves after this update. Apps installed before this system used its current container setup kept their old wide-open address even after the signed list told them to move, and each would otherwise have needed hand-holding on every node. Your node now notices the difference and rebuilds those apps itself, keeping their data, within about half a minute of starting. Verified by putting a node back into the old state deliberately and watching it repair. +- The node had been reading two different sets of instructions about its own apps — the signed list it downloads, and older copies on disk — which is how a port meant to stay private was briefly opened on a test node. Both now come from the signed list, and a port withdrawn from the login gate is released without needing a restart. +- **The key that signs these updates has been replaced.** The previous signing key was exposed where it should not have been, so it is treated as compromised and this release installs its replacement. This update is the last one signed with the old key, by necessity — it is the one that teaches your node the new one. +- Known gaps, disclosed rather than buried: eleven app ports still have no stated policy — BotFights, the Fedimint gateway, NetBird, the voice assistant's own screens and the router screen — and remain reachable without a login until each is decided deliberately; the node reports them rather than guessing, because guessing at an unstated setting caused both incidents behind this work. Three voice-assistant ports are still open without authentication; the correct fix puts them on a private network with the assistant. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — will now meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release. + ## v1.7.121-alpha (2026-08-04) - **Making another node "Trusted" now asks for your node password.** Trust was being handed out by machines rather than by you: any node able to reach yours could join and mark itself Trusted, because the check proved only that the caller owned the key it had just presented — never that you had approved it. Trust also spread on its own, since every peer a Trusted node advertised was added as Trusted too, so one grant quietly propagated across the whole federation. Uninvited joins are now capped at Observer, advertised peers arrive as Observers, and raising anyone to Trusted — whether by generating an invite or by changing the dropdown on a node — requires your password. Lowering trust deliberately does not, because the safe action must never be the inconvenient one. Existing peers are left exactly as they are rather than silently demoted, and each one now records how its trust was granted so you can review them. diff --git a/core/Cargo.lock b/core/Cargo.lock index 6453ad1a..a7793458 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -104,7 +104,7 @@ dependencies = [ [[package]] name = "archipelago" -version = "1.7.121-alpha" +version = "1.7.122-alpha" dependencies = [ "anyhow", "archipelago-container", diff --git a/core/archipelago/Cargo.toml b/core/archipelago/Cargo.toml index a956dab5..8943512c 100644 --- a/core/archipelago/Cargo.toml +++ b/core/archipelago/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "archipelago" -version = "1.7.121-alpha" +version = "1.7.122-alpha" edition = "2021" description = "Archipelago Bitcoin Node OS - Native backend" authors = ["Archipelago Team"] diff --git a/neode-ui/package-lock.json b/neode-ui/package-lock.json index 415000a7..fd8825ee 100644 --- a/neode-ui/package-lock.json +++ b/neode-ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "neode-ui", - "version": "1.7.121-alpha", + "version": "1.7.122-alpha", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "neode-ui", - "version": "1.7.121-alpha", + "version": "1.7.122-alpha", "dependencies": { "@scure/bip39": "^2.2.0", "@types/dompurify": "^3.0.5", diff --git a/neode-ui/package.json b/neode-ui/package.json index 2f1cc1eb..f7375a8e 100644 --- a/neode-ui/package.json +++ b/neode-ui/package.json @@ -1,7 +1,7 @@ { "name": "neode-ui", "private": true, - "version": "1.7.121-alpha", + "version": "1.7.122-alpha", "type": "module", "scripts": { "start": "./start-dev.sh", diff --git a/neode-ui/src/views/settings/AccountInfoSection.vue b/neode-ui/src/views/settings/AccountInfoSection.vue index 56553821..3296d9ac 100644 --- a/neode-ui/src/views/settings/AccountInfoSection.vue +++ b/neode-ui/src/views/settings/AccountInfoSection.vue @@ -362,6 +362,23 @@ init()
+ +
+
+ v1.7.122-alpha + August 4, 2026 +
+
+

Your apps now ask for your node password before they open — over your home network, Tailscale, the mesh and Tor alike. Until now anyone who could reach your node could open Immich, Nextcloud, Vaultwarden, Jellyfin, Grafana and the rest simply by typing the address and port, with no login at all. Twenty app screens now sit behind the same login you use for the node, showing you which app you are opening, and honouring two-factor if you have it switched on. Logging in at an app address logs you into the dashboard too, so it is one password, not one per app.

+

The things that must stay open stayed open. Zeus and other remote wallets still reach your Lightning node directly, Electrum wallets still connect, and Bitcoin still talks to its peers — those connections carry their own proof of identity and a login page would simply break them. Every one of these exceptions now has to state in writing why it is safe to leave open, so the list is something you can read rather than something you have to discover.

+

A private address on your node was answering the mesh without a password. One app's port was marked as being for this machine only, and the part of the node that carries mesh traffic did not know that — it forwarded requests from the whole mesh straight to it. Found while checking the work above on a real node, not in testing. That path now refuses anything marked machine-only, and the app is reachable only from the node itself, as intended.

+

Tor addresses no longer skip the login. An app published as a .onion address was handed straight to the app, because a Tor visitor carries no session. Those addresses now arrive at the login gate first, closing the last route that went around it.

+

Nodes fix themselves after this update. Apps installed before this system used its current container setup kept their old wide-open address even after being told to move, and each would have needed hand-holding on every node. Your node now notices the difference and rebuilds those apps itself, keeping their data, within about half a minute of starting.

+

The node had been reading two different sets of instructions about its own apps — the signed list it downloads, and older copies on disk — which is how a port meant to stay private was briefly opened. Both now come from the signed list.

+

The key that signs these updates has been replaced. The previous signing key was exposed where it should not have been, so it is treated as compromised and this release installs its replacement. This update is the last one signed with the old key, by necessity — it is the one that teaches your node the new one.

+

Known gaps, disclosed rather than buried: eleven app ports still have no stated policy — BotFights, the Fedimint gateway, NetBird, the voice assistant's own screens and the router screen — and remain reachable without a login until each is decided deliberately; the node reports them rather than guessing, because guessing at an unstated setting is what caused two incidents this week. Three voice-assistant ports are still open without authentication; the correct fix puts them on a private network with the assistant. Non-browser apps — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — will meet the login page and need an access token; say the word if you use one and it can be exempted. The 5x real-node lifecycle gate was not run for this release.

+
+
From 53b158ce5a789ba0e1d2a44343d7f37f020cd20b Mon Sep 17 00:00:00 2001 From: ssmithx Date: Wed, 5 Aug 2026 09:41:44 +0000 Subject: [PATCH 16/60] fix(wallet): translate Cashu NUT error codes into plain-language messages Mint HTTP failures (swap/melt/mint-quote) were surfacing raw JSON bodies like {"detail":"proofs already spent","code":11001} straight to the user. Add a translator for the NUT-02/03/04/05 transaction-validation error codes (10001-11017, 12001-12003; see https://github.com/cashubtc/nuts/blob/main/error_codes.md) and layer it onto the mint_client bail sites via anyhow context, so the top-level message is actionable while the raw status/body stays available via {:#} for logs. receive_token now surfaces the real reason (e.g. "This ecash has already been redeemed") instead of a generic "Failed to receive any proofs from token" when every mint in a token fails. Co-Authored-By: Claude Sonnet 5 --- core/archipelago/src/wallet/ecash.rs | 14 +++- core/archipelago/src/wallet/mint_client.rs | 76 ++++++++++++++++++++-- 2 files changed, 83 insertions(+), 7 deletions(-) diff --git a/core/archipelago/src/wallet/ecash.rs b/core/archipelago/src/wallet/ecash.rs index c374ee3f..30fa5871 100644 --- a/core/archipelago/src/wallet/ecash.rs +++ b/core/archipelago/src/wallet/ecash.rs @@ -1040,6 +1040,12 @@ pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result { let mut wallet = load_wallet(data_dir).await?; let mut received_total = 0u64; + // MintClient translates the mint's NUT error code into plain language and + // puts it at the top of the error chain (see `mint_error` in + // mint_client.rs); `{}` surfaces that, `{:#}` keeps the raw status/body + // for the log. Remember the last one so a total failure can tell the user + // *why* instead of just "nothing was received". + let mut last_reason: Option = None; // Swap proofs at each mint for entry in &token.token { @@ -1051,14 +1057,18 @@ pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result { received_total += amount; } Err(e) => { - warn!("Failed to swap proofs from mint {}: {}", entry.mint, e); + warn!("Failed to swap proofs from mint {}: {:#}", entry.mint, e); + last_reason = Some(e.to_string()); // Continue with other mints if any } } } if received_total == 0 { - anyhow::bail!("Failed to receive any proofs from token"); + match last_reason { + Some(reason) => anyhow::bail!("Could not receive this ecash: {}", reason), + None => anyhow::bail!("Failed to receive any proofs from token"), + } } wallet.record_tx( diff --git a/core/archipelago/src/wallet/mint_client.rs b/core/archipelago/src/wallet/mint_client.rs index 3347137d..e92a6374 100644 --- a/core/archipelago/src/wallet/mint_client.rs +++ b/core/archipelago/src/wallet/mint_client.rs @@ -59,6 +59,72 @@ pub struct MintResult { pub proofs: Vec, } +/// Translate a Cashu NUT "transaction validation" error code into plain +/// language a wallet user can act on. Mints respond to a rejected request +/// with `{"code": N, "detail": "..."}`; `detail` is implementation-defined +/// free text, but `code` is the stable identifier from the spec +/// (https://github.com/cashubtc/nuts/blob/main/error_codes.md). Covers the +/// 10001-11017 "proof/transaction validation" range plus the 12001-12003 +/// keyset codes shared by NUT-02/03/04/05 — the codes a swap/melt/mint call +/// can actually hit. Returns `None` for anything else (e.g. Lightning/quote +/// codes in the 20000s) so the caller falls back to the mint's own `detail`. +fn describe_mint_error_code(code: i64) -> Option<&'static str> { + Some(match code { + 10001 => "The mint rejected these coins as invalid.", + 11001 => "This ecash has already been redeemed — it can't be claimed twice.", + 11002 => "This ecash is already being redeemed elsewhere — try again in a moment.", + 11003 => "The mint already issued new coins for this exact request — there's nothing left to redeem.", + 11004 => "This request is still being processed by the mint — try again in a moment.", + 11005 => "The token's amounts don't add up (inputs don't match outputs) — it may be corrupt.", + 11006 => "That amount is outside the range this mint allows.", + 11007 => "This token contains duplicate coins — it may be corrupt or already used.", + 11008 => "The mint rejected this as a duplicate request.", + 11009 | 11010 => "This token mixes incompatible currency units — the mint rejected it.", + 11011 => "That Lightning invoice has no amount, which isn't supported here.", + 11012 => "The amount requested doesn't match the Lightning invoice.", + 11013 => "The mint doesn't support this currency unit.", + 11014 | 11015 => "This token has too many coins for the mint to process in one request.", + 11016 => "Duplicate quote IDs were sent in this request.", + 11017 => "Too many items were sent in a single request.", + 12001 => "The mint no longer recognizes the keyset that signed this token.", + 12002 => "The mint's signing key for this token is inactive.", + 12003 => "The mint's signing key for this token has expired.", + _ => return None, + }) +} + +/// Parse a mint's error body (`{"code": N, "detail": "..."}`) and pick the +/// best user-facing message: the plain-language translation when we know the +/// code, otherwise the mint's own `detail` text, otherwise the raw body. +fn describe_mint_error_body(status: reqwest::StatusCode, body: &str) -> String { + let parsed: Option = serde_json::from_str(body).ok(); + let code = parsed + .as_ref() + .and_then(|v| v.get("code")) + .and_then(|c| c.as_i64()); + let detail = parsed + .as_ref() + .and_then(|v| v.get("detail")) + .and_then(|d| d.as_str()); + + if let Some(friendly) = code.and_then(describe_mint_error_code) { + return friendly.to_string(); + } + match detail { + Some(d) if !d.is_empty() => d.to_string(), + _ => format!("mint returned {} with no further detail", status), + } +} + +/// Build the error for a failed mint HTTP call: `op` + status + raw body as +/// the technical cause (visible via `{:#}` in logs), with the plain-language +/// translation layered on top via `.context()` so `{}` — what reaches the +/// wallet user — shows something actionable instead of raw mint JSON. +fn mint_error(op: &str, status: reqwest::StatusCode, body: &str) -> anyhow::Error { + let friendly = describe_mint_error_body(status, body); + anyhow::anyhow!("{} failed ({}): {}", op, status, body).context(friendly) +} + /// HTTP client for a single Cashu mint. pub struct MintClient { url: String, @@ -146,7 +212,7 @@ impl MintClient { if !res.status().is_success() { let status = res.status(); let body = res.text().await.unwrap_or_default(); - anyhow::bail!("Mint quote failed ({}): {}", status, body); + return Err(mint_error("Mint quote", status, &body)); } res.json().await.context("Failed to parse mint quote") @@ -212,7 +278,7 @@ impl MintClient { if !res.status().is_success() { let status = res.status(); let body = res.text().await.unwrap_or_default(); - anyhow::bail!("Mint tokens failed ({}): {}", status, body); + return Err(mint_error("Minting tokens", status, &body)); } let body: serde_json::Value = res.json().await.context("Failed to parse mint response")?; @@ -266,7 +332,7 @@ impl MintClient { if !res.status().is_success() { let status = res.status(); let body = res.text().await.unwrap_or_default(); - anyhow::bail!("Melt quote failed ({}): {}", status, body); + return Err(mint_error("Melt quote", status, &body)); } res.json().await.context("Failed to parse melt quote") @@ -293,7 +359,7 @@ impl MintClient { if !res.status().is_success() { let status = res.status(); let body = res.text().await.unwrap_or_default(); - anyhow::bail!("Melt failed ({}): {}", status, body); + return Err(mint_error("Melt", status, &body)); } res.json().await.context("Failed to parse melt response") @@ -337,7 +403,7 @@ impl MintClient { if !res.status().is_success() { let status = res.status(); let body = res.text().await.unwrap_or_default(); - anyhow::bail!("Swap failed ({}): {}", status, body); + return Err(mint_error("Swap", status, &body)); } let body: serde_json::Value = res.json().await.context("Failed to parse swap response")?; From b92e16abc0ae5384a71b170aaf62bee40fa82308 Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 5 Aug 2026 07:48:51 -0400 Subject: [PATCH 17/60] =?UTF-8?q?fix(release):=20sign=20v1.7.122=20with=20?= =?UTF-8?q?the=20OLD=20root=20=E2=80=94=20the=20rotation=20moved=20the=20c?= =?UTF-8?q?hecks=20a=20release=20early?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rotation commit pointed create-release.sh and publish-release-assets.sh at the NEW root in the same commit that pins it in the binary. But the release CARRYING the rotation must be signed with the OLD root: every node is still running the previous binary, which pins the old key. So the tooling would have rejected the only signature the fleet can accept, and the signature it demanded would have ended OTA fleet-wide. Both checks now expect the old DID for this cycle, with the flip to the new one called out for v1.7.123+. sign-manifest.sh documents the ARCHY_RELEASE_ROOT_PUBKEY override needed because the signer built from this tree already pins the new anchor and would fail to verify its own correct output. Co-Authored-By: Claude Fable 5 --- scripts/create-release.sh | 14 +++++++++++++- scripts/publish-release-assets.sh | 6 +++++- scripts/sign-manifest.sh | 13 +++++++++++++ 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/scripts/create-release.sh b/scripts/create-release.sh index d836ff9b..c160d545 100755 --- a/scripts/create-release.sh +++ b/scripts/create-release.sh @@ -240,7 +240,19 @@ install -m 0644 "$FRONTEND_ARCHIVE" "$VERSION_DIR/archipelago-frontend-${VERSION # warning and falls through — and the commit then happened anyway. A release # commit carrying a manifest no node will accept has no valid use, so refuse # to create one rather than leave a tag that has to be re-cut. -EXPECTED_DID="did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT" +# ⚠ ROTATION IN FLIGHT (v1.7.122-alpha) — this is the OLD root, deliberately. +# +# The trust anchor in the binary already pins the NEW root +# (z6Mkfu5LT…DLWT), because this release is what installs that pin. But the +# manifest THIS release ships must be signed with the OLD root +# (z6Mkkid…q7ur): every node is still running the previous binary, which +# pins the old key and would reject anything else. Signing this one with the +# new key ends OTA fleet-wide and needs hands-on recovery per node. +# +# ➜ NEXT RELEASE (v1.7.123+): change this to the new DID, and the same line +# in publish-release-assets.sh. By then every node runs a binary pinning +# the new root, and an old-key signature is the one that gets rejected. +EXPECTED_DID="did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur" if ! grep -q '"signature":' "$PROJECT_ROOT/releases/manifest.json" \ || ! grep -q "\"signed_by\": \"$EXPECTED_DID\"" "$PROJECT_ROOT/releases/manifest.json"; then echo "" >&2 diff --git a/scripts/publish-release-assets.sh b/scripts/publish-release-assets.sh index f5ba3d72..1079238a 100755 --- a/scripts/publish-release-assets.sh +++ b/scripts/publish-release-assets.sh @@ -29,7 +29,11 @@ fail() { echo "Error: $*" >&2; exit 1; } # with the pinned release-root anchor refuse to auto-apply unsigned manifests, # and enforcement will tighten to hard-reject — an unsigned publish would # strand them. Grep proves presence; ceremony verify proves the crypto. -EXPECTED_DID="did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT" +# ⚠ ROTATION IN FLIGHT (v1.7.122-alpha) — OLD root on purpose; see the same +# block in create-release.sh. Nodes still run the previous binary and pin the +# old key, so the manifest this release publishes must carry an old-key +# signature. Flip both to z6Mkfu5LT…DLWT for v1.7.123+. +EXPECTED_DID="did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur" grep -q '"signature":' "$PROJECT_ROOT/releases/manifest.json" \ && grep -q "\"signed_by\": \"$EXPECTED_DID\"" "$PROJECT_ROOT/releases/manifest.json" \ || fail "releases/manifest.json is not signed by the release root — run: bash scripts/sign-manifest.sh" diff --git a/scripts/sign-manifest.sh b/scripts/sign-manifest.sh index 7bb0f989..62e7a7d8 100755 --- a/scripts/sign-manifest.sh +++ b/scripts/sign-manifest.sh @@ -11,6 +11,19 @@ # Normally create-release.sh signs the manifest inline; this script exists for # re-signing (e.g. a manifest edited after creation) or signing on a box where # the release run was non-interactive. +# +# ⚠ ROTATION IN FLIGHT (v1.7.122-alpha). This release must be signed with the +# OLD release root, because every node still runs a binary pinning it — but +# the signer built from THIS tree already pins the NEW root, so its own +# verification would reject a correct old-key signature. Pin the old anchor +# for the duration of the ceremony so signing and verification agree: +# +# ARCHY_RELEASE_ROOT_PUBKEY=5d15cbee8a108f7dd288c02d29a1d9d71f198acc99186aad8008b4f28d469951 \ +# bash scripts/sign-manifest.sh +# +# That hex is the OLD root's PUBLIC key (verified to derive to +# did:key:z6Mkkid…q7ur); it is not secret and pins verification only. +# From v1.7.123 the override is unnecessary — drop it and this block. set -euo pipefail REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" From 634640944c713078f9e59a0d56b75e83447fb26d Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 5 Aug 2026 07:57:07 -0400 Subject: [PATCH 18/60] chore: release v1.7.122-alpha --- release-manifest.json | 44 +++++++++++++++++++++--------------------- releases/manifest.json | 44 +++++++++++++++++++++--------------------- 2 files changed, 44 insertions(+), 44 deletions(-) diff --git a/release-manifest.json b/release-manifest.json index 8c2b689d..22dd4ab6 100644 --- a/release-manifest.json +++ b/release-manifest.json @@ -1,34 +1,34 @@ { "changelog": [ - "**Making another node \"Trusted\" now asks for your node password.** Trust was being handed out by machines rather than by you: any node able to reach yours could join and mark itself Trusted, because the check proved only that the caller owned the key it had just presented — never that you had approved it. Trust also spread on its own, since every peer a Trusted node advertised was added as Trusted too, so one grant quietly propagated across the whole federation. Uninvited joins are now capped at Observer, advertised peers arrive as Observers, and raising anyone to Trusted — whether by generating an invite or by changing the dropdown on a node — requires your password. Lowering trust deliberately does not, because the safe action must never be the inconvenient one. Existing peers are left exactly as they are rather than silently demoted, and each one now records how its trust was granted so you can review them.", - "**Nodes you have peered with can be messaged straight away.** Peering was not enough: you also had to be within LoRa radio range of the other node once before chat would work. The node picked how to send a message based on which radio was plugged in, and only one of those paths knew how to reach a peer over the mesh's internet transports — so on a node with a different radio, or no radio at all, messaging a peer you had just federated with simply failed until a radio contact happened to appear. Peered nodes are reachable without radio by definition, so that choice no longer depends on the hardware. Radio is still preferred when the other node is actually in range and the message fits.", - "The dashboard no longer flickers a vertical line across its cards. A rendering seam appeared at random while moving the mouse, because the two large cards used a background-blur effect that this system already disables everywhere else on the dashboard — that browser mis-draws it inside the dashboard's animated container, and these two cards had been missed when the workaround was written. Diagnosed from a single screenshot rather than by trying to reproduce it.", - "The Lightning screen will actually update from now on. Its image was set to \"latest\", and the container system will not re-fetch a label it already holds, so nodes kept the same Lightning screen forever no matter how many updates shipped. A separate copy of the same setting used only by brand-new installs also described the screen incorrectly, so fresh installs got a screen that never answered.", - "Apps that provide their own screens stop rebuilding themselves in a loop. On this system's own node one of them rebuilt every thirty-five seconds indefinitely, burning processor time and restarting the app each round. The node decided a rebuild was needed by comparing file dates against the image's creation date, but a rebuild that changes nothing reuses the existing image and leaves that date untouched — so the condition that triggered the rebuild was still true afterwards, forever. Nodes taking this update repair themselves the first time they check.", - "Groundwork you can see but that does not change access yet: the node can now tell you which of its app ports answer without a login, and every port that is deliberately open — Bitcoin's peer connections for syncing the chain, Lightning's wallet connections, the Electrum wallet protocol — now has to state in writing why it is safe, so the list of exceptions is something you can read rather than something you have to discover. The login gate that will sit in front of the rest is built and proven working end to end on a real node, but it is not yet closing any ports; that arrives with the signed app catalog that tells each app to hand its address over.", - "Releases can no longer ship an unsigned update file. Signing was skippable, and when it was skipped the release was still committed and tagged — producing an update that every node correctly refuses to install. It had been caught by hand every cycle; now the release simply stops.", - "Known gaps, disclosed rather than buried: the 5x real-node lifecycle gate was not run for this release. App ports other than the deliberate exceptions above are still reachable without a login — the gate reports them, and closing them needs the next signed catalog. Three voice-assistant ports are open without authentication and should not be; the correct fix puts them on a private network with the assistant instead, which needs testing on a node that runs both. Two nodes on the fleet still share SSH host keys (detection shipped, rotation remains a deliberate operator decision)." + "**Your apps now ask for your node password before they open — over your home network, Tailscale, the mesh and Tor alike.** Until now anyone who could reach your node could open Immich, Nextcloud, Vaultwarden, Jellyfin, Grafana and the rest simply by typing the address and port, with no login at all. Twenty app screens now sit behind the same login you use for the node, showing you which app you are opening, and honouring two-factor if you have it switched on. Logging in at an app address logs you into the dashboard too, so it is one password, not one per app. This completes the groundwork disclosed in v1.7.121.", + "**The things that must stay open stayed open.** Zeus and other remote wallets still reach your Lightning node directly, Electrum wallets still connect, and Bitcoin still talks to its peers — those connections carry their own proof of identity and a login page would simply break them. Every one of these seventeen exceptions now has to state in writing why it is safe to leave open, so the list is something you can read rather than something you have to discover.", + "**A private address on your node was answering the mesh without a password.** One app's port was marked as being for this machine only, and the part of the node that carries mesh traffic did not know that — it forwarded requests from the whole mesh straight to it. Found while verifying the work above on a real node, not in testing. That path now refuses anything marked machine-only, and the app is reachable only from the node itself, as intended.", + "**Tor addresses no longer skip the login.** An app published as a .onion address was handed straight to the app, because a Tor visitor carries no session cookie to check. The login gate now takes those addresses first, closing the last of the four routes that went around it.", + "Nodes fix themselves after this update. Apps installed before this system used its current container setup kept their old wide-open address even after the signed list told them to move, and each would otherwise have needed hand-holding on every node. Your node now notices the difference and rebuilds those apps itself, keeping their data, within about half a minute of starting. Verified by putting a node back into the old state deliberately and watching it repair.", + "The node had been reading two different sets of instructions about its own apps — the signed list it downloads, and older copies on disk — which is how a port meant to stay private was briefly opened on a test node. Both now come from the signed list, and a port withdrawn from the login gate is released without needing a restart.", + "**The key that signs these updates has been replaced.** The previous signing key was exposed where it should not have been, so it is treated as compromised and this release installs its replacement. This update is the last one signed with the old key, by necessity — it is the one that teaches your node the new one.", + "Known gaps, disclosed rather than buried: eleven app ports still have no stated policy — BotFights, the Fedimint gateway, NetBird, the voice assistant's own screens and the router screen — and remain reachable without a login until each is decided deliberately; the node reports them rather than guessing, because guessing at an unstated setting caused both incidents behind this work. Three voice-assistant ports are still open without authentication; the correct fix puts them on a private network with the assistant. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — will now meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release." ], "components": [ { - "current_version": "1.7.121-alpha", - "download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.121-alpha/archipelago", + "current_version": "1.7.122-alpha", + "download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.122-alpha/archipelago", "name": "archipelago", - "new_version": "1.7.121-alpha", - "sha256": "be5ef9fb284f539b06329d4108be53e55ae8cdb06cf1cf4beb90363de364706d", - "size_bytes": 54870968 + "new_version": "1.7.122-alpha", + "sha256": "06aedbd235e962574b7abc5d6992c26b77cd943655e775cd93c84fdcc79ffab0", + "size_bytes": 54957496 }, { - "current_version": "1.7.121-alpha", - "download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.121-alpha/archipelago-frontend-1.7.121-alpha.tar.gz", - "name": "archipelago-frontend-1.7.121-alpha.tar.gz", - "new_version": "1.7.121-alpha", - "sha256": "7898a9c11fa30cadc8f0fcf814bba1e3870d20663472f4c40e8e663b2359958f", - "size_bytes": 210526689 + "current_version": "1.7.122-alpha", + "download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.122-alpha/archipelago-frontend-1.7.122-alpha.tar.gz", + "name": "archipelago-frontend-1.7.122-alpha.tar.gz", + "new_version": "1.7.122-alpha", + "sha256": "865f5a0edb5eed1ced9dc4597b9112f24706d3538f8ffe84dea8104049d26af3", + "size_bytes": 210528707 } ], - "release_date": "2026-08-04", - "signature": "9d871c946e941b3c13f75fb799d8428841147267f4565920993ddd3aa0cd52d6d1a74481303cbbb26e68e6e5d44e2b711c9b7a22ad7dd73c94b4d3e39a0e2803", + "release_date": "2026-08-05", + "signature": "aca66567bf5954aefd450167f881289ee4715fd912fe61a50726741cadf1a93d39e832efc3266388839279ad41001c9802fdfaf766c8cfa9399509916ed4a80f", "signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur", - "version": "1.7.121-alpha" + "version": "1.7.122-alpha" } diff --git a/releases/manifest.json b/releases/manifest.json index 8c2b689d..22dd4ab6 100644 --- a/releases/manifest.json +++ b/releases/manifest.json @@ -1,34 +1,34 @@ { "changelog": [ - "**Making another node \"Trusted\" now asks for your node password.** Trust was being handed out by machines rather than by you: any node able to reach yours could join and mark itself Trusted, because the check proved only that the caller owned the key it had just presented — never that you had approved it. Trust also spread on its own, since every peer a Trusted node advertised was added as Trusted too, so one grant quietly propagated across the whole federation. Uninvited joins are now capped at Observer, advertised peers arrive as Observers, and raising anyone to Trusted — whether by generating an invite or by changing the dropdown on a node — requires your password. Lowering trust deliberately does not, because the safe action must never be the inconvenient one. Existing peers are left exactly as they are rather than silently demoted, and each one now records how its trust was granted so you can review them.", - "**Nodes you have peered with can be messaged straight away.** Peering was not enough: you also had to be within LoRa radio range of the other node once before chat would work. The node picked how to send a message based on which radio was plugged in, and only one of those paths knew how to reach a peer over the mesh's internet transports — so on a node with a different radio, or no radio at all, messaging a peer you had just federated with simply failed until a radio contact happened to appear. Peered nodes are reachable without radio by definition, so that choice no longer depends on the hardware. Radio is still preferred when the other node is actually in range and the message fits.", - "The dashboard no longer flickers a vertical line across its cards. A rendering seam appeared at random while moving the mouse, because the two large cards used a background-blur effect that this system already disables everywhere else on the dashboard — that browser mis-draws it inside the dashboard's animated container, and these two cards had been missed when the workaround was written. Diagnosed from a single screenshot rather than by trying to reproduce it.", - "The Lightning screen will actually update from now on. Its image was set to \"latest\", and the container system will not re-fetch a label it already holds, so nodes kept the same Lightning screen forever no matter how many updates shipped. A separate copy of the same setting used only by brand-new installs also described the screen incorrectly, so fresh installs got a screen that never answered.", - "Apps that provide their own screens stop rebuilding themselves in a loop. On this system's own node one of them rebuilt every thirty-five seconds indefinitely, burning processor time and restarting the app each round. The node decided a rebuild was needed by comparing file dates against the image's creation date, but a rebuild that changes nothing reuses the existing image and leaves that date untouched — so the condition that triggered the rebuild was still true afterwards, forever. Nodes taking this update repair themselves the first time they check.", - "Groundwork you can see but that does not change access yet: the node can now tell you which of its app ports answer without a login, and every port that is deliberately open — Bitcoin's peer connections for syncing the chain, Lightning's wallet connections, the Electrum wallet protocol — now has to state in writing why it is safe, so the list of exceptions is something you can read rather than something you have to discover. The login gate that will sit in front of the rest is built and proven working end to end on a real node, but it is not yet closing any ports; that arrives with the signed app catalog that tells each app to hand its address over.", - "Releases can no longer ship an unsigned update file. Signing was skippable, and when it was skipped the release was still committed and tagged — producing an update that every node correctly refuses to install. It had been caught by hand every cycle; now the release simply stops.", - "Known gaps, disclosed rather than buried: the 5x real-node lifecycle gate was not run for this release. App ports other than the deliberate exceptions above are still reachable without a login — the gate reports them, and closing them needs the next signed catalog. Three voice-assistant ports are open without authentication and should not be; the correct fix puts them on a private network with the assistant instead, which needs testing on a node that runs both. Two nodes on the fleet still share SSH host keys (detection shipped, rotation remains a deliberate operator decision)." + "**Your apps now ask for your node password before they open — over your home network, Tailscale, the mesh and Tor alike.** Until now anyone who could reach your node could open Immich, Nextcloud, Vaultwarden, Jellyfin, Grafana and the rest simply by typing the address and port, with no login at all. Twenty app screens now sit behind the same login you use for the node, showing you which app you are opening, and honouring two-factor if you have it switched on. Logging in at an app address logs you into the dashboard too, so it is one password, not one per app. This completes the groundwork disclosed in v1.7.121.", + "**The things that must stay open stayed open.** Zeus and other remote wallets still reach your Lightning node directly, Electrum wallets still connect, and Bitcoin still talks to its peers — those connections carry their own proof of identity and a login page would simply break them. Every one of these seventeen exceptions now has to state in writing why it is safe to leave open, so the list is something you can read rather than something you have to discover.", + "**A private address on your node was answering the mesh without a password.** One app's port was marked as being for this machine only, and the part of the node that carries mesh traffic did not know that — it forwarded requests from the whole mesh straight to it. Found while verifying the work above on a real node, not in testing. That path now refuses anything marked machine-only, and the app is reachable only from the node itself, as intended.", + "**Tor addresses no longer skip the login.** An app published as a .onion address was handed straight to the app, because a Tor visitor carries no session cookie to check. The login gate now takes those addresses first, closing the last of the four routes that went around it.", + "Nodes fix themselves after this update. Apps installed before this system used its current container setup kept their old wide-open address even after the signed list told them to move, and each would otherwise have needed hand-holding on every node. Your node now notices the difference and rebuilds those apps itself, keeping their data, within about half a minute of starting. Verified by putting a node back into the old state deliberately and watching it repair.", + "The node had been reading two different sets of instructions about its own apps — the signed list it downloads, and older copies on disk — which is how a port meant to stay private was briefly opened on a test node. Both now come from the signed list, and a port withdrawn from the login gate is released without needing a restart.", + "**The key that signs these updates has been replaced.** The previous signing key was exposed where it should not have been, so it is treated as compromised and this release installs its replacement. This update is the last one signed with the old key, by necessity — it is the one that teaches your node the new one.", + "Known gaps, disclosed rather than buried: eleven app ports still have no stated policy — BotFights, the Fedimint gateway, NetBird, the voice assistant's own screens and the router screen — and remain reachable without a login until each is decided deliberately; the node reports them rather than guessing, because guessing at an unstated setting caused both incidents behind this work. Three voice-assistant ports are still open without authentication; the correct fix puts them on a private network with the assistant. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — will now meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release." ], "components": [ { - "current_version": "1.7.121-alpha", - "download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.121-alpha/archipelago", + "current_version": "1.7.122-alpha", + "download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.122-alpha/archipelago", "name": "archipelago", - "new_version": "1.7.121-alpha", - "sha256": "be5ef9fb284f539b06329d4108be53e55ae8cdb06cf1cf4beb90363de364706d", - "size_bytes": 54870968 + "new_version": "1.7.122-alpha", + "sha256": "06aedbd235e962574b7abc5d6992c26b77cd943655e775cd93c84fdcc79ffab0", + "size_bytes": 54957496 }, { - "current_version": "1.7.121-alpha", - "download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.121-alpha/archipelago-frontend-1.7.121-alpha.tar.gz", - "name": "archipelago-frontend-1.7.121-alpha.tar.gz", - "new_version": "1.7.121-alpha", - "sha256": "7898a9c11fa30cadc8f0fcf814bba1e3870d20663472f4c40e8e663b2359958f", - "size_bytes": 210526689 + "current_version": "1.7.122-alpha", + "download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.122-alpha/archipelago-frontend-1.7.122-alpha.tar.gz", + "name": "archipelago-frontend-1.7.122-alpha.tar.gz", + "new_version": "1.7.122-alpha", + "sha256": "865f5a0edb5eed1ced9dc4597b9112f24706d3538f8ffe84dea8104049d26af3", + "size_bytes": 210528707 } ], - "release_date": "2026-08-04", - "signature": "9d871c946e941b3c13f75fb799d8428841147267f4565920993ddd3aa0cd52d6d1a74481303cbbb26e68e6e5d44e2b711c9b7a22ad7dd73c94b4d3e39a0e2803", + "release_date": "2026-08-05", + "signature": "aca66567bf5954aefd450167f881289ee4715fd912fe61a50726741cadf1a93d39e832efc3266388839279ad41001c9802fdfaf766c8cfa9399509916ed4a80f", "signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur", - "version": "1.7.121-alpha" + "version": "1.7.122-alpha" } From abf0f56afc130a24c09acf70dfb71e484083670b Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 5 Aug 2026 08:46:09 -0400 Subject: [PATCH 19/60] fix(security): close the five host-networked app UIs the audit could not see MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scanning archi-dev-box from OUTSIDE found five ports serving their screens with no login — lnd-ui 18083, bitcoin-ui 8334, fips-ui 8336, electrs-ui 50002 and the Fedimint Guardian 8175 — none of which appeared in the gate's unprotected list. They are host-networked, so Podman publishes nothing to pin and their manifests declared 'ports: []'; the gate builds its map from declared ports, so it neither protected them nor reported them. An audit that reports success while five screens are open is worse than no audit. Their nginx now listens on 127.0.0.1 instead of 0.0.0.0, and each port is declared 'auth: gated' so the daemon owns the outside. 'bind:' on a host-networked app is a statement of where the container listens, not a publish instruction — quadlet already skips PublishPort in host mode. Guardian 8175 is declared on the fedimint app because its companion has no manifest, and the gate keys on port, not container. Credential paths were NOT exposed and are verified so: /lnd-connect-info, the /proxy/lnd/ passthrough, container logs and every RPC method through these screens all return 401 unauthenticated. What leaked was the page shell. Also fixes the delivery gap that would have made this unshippable: only bitcoin-ui, lnd-ui and electrs-ui were ever rsynced to /opt/archipelago/docker, so edits to fips-ui and fedimint-ui reached nodes through no path at all. All five now sync; the two whose rebuilds the daemon owns are synced without being handed to container-specs. Every remaining undeclared port is now declared with a stated reason — gated: botfights 9100, router 8084, pine 10380; exempt with rationale: fedimint consensus 8173/8174, gateway 8176/9737, netbird 8086/8087 (TLS + own auth, and enrolled devices cannot hold a session), pine TLS 10381, lightning-stack REST 8091 (macaroon, mirrors lnd). Zero undeclared ports remain across all 56 manifests. Co-Authored-By: Claude Fable 5 --- apps/bitcoin-ui/manifest.yml | 13 +++- apps/botfights/manifest.yml | 2 + apps/electrs-ui/manifest.yml | 13 +++- apps/fedimint-gateway/manifest.yml | 8 ++ apps/fedimint/manifest.yml | 18 +++++ apps/fips-ui/manifest.yml | 13 +++- apps/lightning-stack/manifest.yml | 5 ++ apps/lnd-ui/manifest.yml | 13 +++- apps/netbird-server/manifest.yml | 5 ++ apps/netbird/manifest.yml | 5 ++ apps/pine/manifest.yml | 7 ++ apps/router/manifest.yml | 2 + core/archipelago/src/container/bitcoin_ui.rs | 2 +- .../container/bitcoin_ui_nginx.conf.template | 9 ++- docker/electrs-ui/nginx.conf | 9 ++- docker/fedimint-ui/nginx.conf | 9 ++- docker/fips-ui/nginx.conf | 9 ++- docker/lnd-ui/nginx.conf | 9 ++- releases/app-catalog.json | 73 +++++++++++++++++-- scripts/self-update.sh | 26 ++++++- 20 files changed, 230 insertions(+), 20 deletions(-) diff --git a/apps/bitcoin-ui/manifest.yml b/apps/bitcoin-ui/manifest.yml index a9e10f4d..6fb05656 100644 --- a/apps/bitcoin-ui/manifest.yml +++ b/apps/bitcoin-ui/manifest.yml @@ -31,7 +31,18 @@ app: # proxies to 127.0.0.1:8332 which is where the bitcoin backend binds # its RPC. `ports:` is intentionally empty because host networking # bypasses port mapping. - ports: [] + # Declared so the APP GATE can see this port. Host networking means Podman + # publishes nothing (quadlet skips PublishPort in host mode), so `bind:` here + # is a statement of where the container's own nginx listens — 127.0.0.1 — + # not a publish instruction. Without this declaration the gate had no idea + # the port existed: it was neither protected nor listed as unprotected, and + # served the Bitcoin screen unauthenticated on every interface. + ports: + - host: 8334 + container: 8334 + protocol: tcp + bind: 127.0.0.1 + auth: gated volumes: # Bind-mount the rendered nginx.conf read-only. The prod orchestrator diff --git a/apps/botfights/manifest.yml b/apps/botfights/manifest.yml index eba5410c..a7e3e1fd 100644 --- a/apps/botfights/manifest.yml +++ b/apps/botfights/manifest.yml @@ -62,6 +62,8 @@ app: - host: 9100 container: 9100 protocol: tcp # Web UI + API + bind: 127.0.0.1 + auth: gated volumes: # A bare relative source (was "botfights-data", no leading slash) is diff --git a/apps/electrs-ui/manifest.yml b/apps/electrs-ui/manifest.yml index 0d5f6be3..4f224565 100644 --- a/apps/electrs-ui/manifest.yml +++ b/apps/electrs-ui/manifest.yml @@ -23,7 +23,18 @@ app: network_policy: host # Host networking: nginx listens on 50002 directly on the host IP. - ports: [] + # Declared so the APP GATE can see this port. Host networking means Podman + # publishes nothing (quadlet skips PublishPort in host mode), so `bind:` here + # is a statement of where the container's own nginx listens — 127.0.0.1 — + # not a publish instruction. Without this declaration the gate had no idea + # the port existed: it was neither protected nor listed as unprotected, and + # served the Electrs screen unauthenticated on every interface. + ports: + - host: 50002 + container: 50002 + protocol: tcp + bind: 127.0.0.1 + auth: gated volumes: [] diff --git a/apps/fedimint-gateway/manifest.yml b/apps/fedimint-gateway/manifest.yml index 9363bb10..42ed8239 100644 --- a/apps/fedimint-gateway/manifest.yml +++ b/apps/fedimint-gateway/manifest.yml @@ -60,9 +60,17 @@ app: - host: 8176 container: 8176 protocol: tcp + auth: none + auth_rationale: >- + Fedimint gateway API, protected by its own bcrypt password (--bcrypt-password-hash) + and reached by federation peers and clients that cannot hold a browser session. - host: 9737 container: 9737 protocol: tcp + auth: none + auth_rationale: >- + LDK Lightning p2p for the gateway. The BOLT-8 noise handshake authenticates and + encrypts the connection itself. volumes: - type: bind diff --git a/apps/fedimint/manifest.yml b/apps/fedimint/manifest.yml index c3c7985a..e93fcf0e 100644 --- a/apps/fedimint/manifest.yml +++ b/apps/fedimint/manifest.yml @@ -50,11 +50,29 @@ app: - host: 8173 container: 8173 protocol: tcp + auth: none + auth_rationale: >- + Fedimint guardian consensus. Other guardians speak the federation's own + authenticated protocol here; a login page would break consensus. - host: 8174 container: 8174 protocol: tcp + auth: none + auth_rationale: >- + Fedimint guardian API for federation clients, which authenticate to the + federation itself and cannot hold a browser session. # Public launch port 8175 is owned by archy-fedimint-ui, which serves a # wait page while Bitcoin syncs and proxies here after fedimintd starts. + # Declared HERE because that companion has no manifest of its own, and the + # gate keys on the port rather than the container: without this entry it + # served the Guardian UI unauthenticated on every interface and never + # appeared in the audit. Its nginx is pinned to 127.0.0.1 + # (docker/fedimint-ui/nginx.conf) so the gate can own the outside. + - host: 8175 + container: 8175 + protocol: tcp + bind: 127.0.0.1 + auth: gated - host: 8177 container: 8175 protocol: tcp diff --git a/apps/fips-ui/manifest.yml b/apps/fips-ui/manifest.yml index 6983387c..e16c49f9 100644 --- a/apps/fips-ui/manifest.yml +++ b/apps/fips-ui/manifest.yml @@ -27,7 +27,18 @@ app: # Host networking: nginx listens on 8336 directly on the host IP and # proxies to 127.0.0.1:5678 (the archipelago RPC). `ports:` is # intentionally empty because host networking bypasses port mapping. - ports: [] + # Declared so the APP GATE can see this port. Host networking means Podman + # publishes nothing (quadlet skips PublishPort in host mode), so `bind:` here + # is a statement of where the container's own nginx listens — 127.0.0.1 — + # not a publish instruction. Without this declaration the gate had no idea + # the port existed: it was neither protected nor listed as unprotected, and + # served the FIPS mesh screen unauthenticated on every interface. + ports: + - host: 8336 + container: 8336 + protocol: tcp + bind: 127.0.0.1 + auth: gated volumes: [] diff --git a/apps/lightning-stack/manifest.yml b/apps/lightning-stack/manifest.yml index ac7fc9fe..e9befcd6 100644 --- a/apps/lightning-stack/manifest.yml +++ b/apps/lightning-stack/manifest.yml @@ -41,9 +41,14 @@ app: auth: none auth_rationale: >- LND gRPC, authenticated by macaroon over TLS. Remote wallets depend on reaching this directly. + # Mirrors lnd's 18080 exemption — same LND REST API, same macaroon auth. - host: 8091 container: 8080 protocol: tcp # REST/Web UI + auth: none + auth_rationale: >- + LND REST, authenticated by macaroon over TLS. A browser login page would break + Zeus and every non-browser wallet client, exactly as for lnd's 18080. volumes: - type: bind diff --git a/apps/lnd-ui/manifest.yml b/apps/lnd-ui/manifest.yml index 713a1afe..cf186bcf 100644 --- a/apps/lnd-ui/manifest.yml +++ b/apps/lnd-ui/manifest.yml @@ -35,7 +35,18 @@ app: # port to a container port where nothing listens. scripts/container-specs.sh # carried the identical mistake and was fixed alongside this; recreating from # it on archi-dev-box left :18083 refusing connections. - ports: [] + # Declared so the APP GATE can see this port. Host networking means Podman + # publishes nothing (quadlet skips PublishPort in host mode), so `bind:` here + # is a statement of where the container's own nginx listens — 127.0.0.1 — + # not a publish instruction. Without this declaration the gate had no idea + # the port existed: it was neither protected nor listed as unprotected, and + # served the LND screen unauthenticated on every interface. + ports: + - host: 18083 + container: 18083 + protocol: tcp + bind: 127.0.0.1 + auth: gated volumes: [] diff --git a/apps/netbird-server/manifest.yml b/apps/netbird-server/manifest.yml index 994949f6..287be710 100644 --- a/apps/netbird-server/manifest.yml +++ b/apps/netbird-server/manifest.yml @@ -48,6 +48,11 @@ app: - host: 8086 container: 80 protocol: tcp # management API + embedded OIDC issuer (/oauth2) + auth: none + auth_rationale: >- + NetBird management API and its OIDC issuer. Enrolled devices authenticate + themselves with setup keys and JWTs, and they cannot hold a browser session — + a login page here would disconnect every VPN client on the network. - host: 3478 container: 3478 protocol: udp # STUN — must be UDP; tcp here breaks relay discovery diff --git a/apps/netbird/manifest.yml b/apps/netbird/manifest.yml index 6464335a..32cf44d5 100644 --- a/apps/netbird/manifest.yml +++ b/apps/netbird/manifest.yml @@ -44,6 +44,11 @@ app: - host: 8087 container: 443 protocol: tcp + auth: none + auth_rationale: >- + NetBird dashboard over TLS, with its own login. The gate speaks plain HTTP, + so fronting this port would break the secure context the dashboard requires + (issue #15) and the certificate clients pin. volumes: - type: bind diff --git a/apps/pine/manifest.yml b/apps/pine/manifest.yml index 3984cd3f..c673df3e 100644 --- a/apps/pine/manifest.yml +++ b/apps/pine/manifest.yml @@ -53,9 +53,16 @@ app: - host: 10380 container: 80 protocol: tcp + bind: 127.0.0.1 + auth: gated - host: 10381 container: 443 protocol: tcp + auth: none + auth_rationale: >- + Pine's TLS listener. The gate speaks plain HTTP, so fronting this port would + break the secure context navigator.bluetooth needs for WiFi provisioning. + The plain-HTTP entry point (10380) is gated, and it is what the UI opens. volumes: - type: bind diff --git a/apps/router/manifest.yml b/apps/router/manifest.yml index fb4300d4..bada2040 100644 --- a/apps/router/manifest.yml +++ b/apps/router/manifest.yml @@ -30,6 +30,8 @@ app: - host: 8084 container: 8080 protocol: tcp # Web UI + bind: 127.0.0.1 + auth: gated - host: 5353 container: 5353 protocol: udp # mDNS/Bonjour diff --git a/core/archipelago/src/container/bitcoin_ui.rs b/core/archipelago/src/container/bitcoin_ui.rs index eec7526a..37759a54 100644 --- a/core/archipelago/src/container/bitcoin_ui.rs +++ b/core/archipelago/src/container/bitcoin_ui.rs @@ -293,6 +293,6 @@ mod tests { // Lock in the core shape so a bad template edit doesn't ship. assert!(TEMPLATE.contains("proxy_pass http://127.0.0.1:8332/")); assert!(TEMPLATE.contains("location /bitcoin-rpc/")); - assert!(TEMPLATE.contains("listen 8334")); + assert!(TEMPLATE.contains("listen 127.0.0.1:8334")); } } diff --git a/core/archipelago/src/container/bitcoin_ui_nginx.conf.template b/core/archipelago/src/container/bitcoin_ui_nginx.conf.template index 6e7e2bdb..ddd4c1b6 100644 --- a/core/archipelago/src/container/bitcoin_ui_nginx.conf.template +++ b/core/archipelago/src/container/bitcoin_ui_nginx.conf.template @@ -1,5 +1,12 @@ server { - listen 8334; + # Loopback ONLY. This container is host-networked, so this nginx binds the + # HOST's address directly — `listen 8334;` meant every interface, and the + # app gate could never stand in front of it (there is no podman publish to + # pin, and the manifest declared no port, so the gate neither protected it + # nor reported it — it served this page to anyone who asked, on LAN, + # Tailscale and the mesh alike). Binding loopback lets the daemon claim the + # external addresses and authenticate them; see appgate::listener. + listen 127.0.0.1:8334; server_name _; root /usr/share/nginx/html; index index.html; diff --git a/docker/electrs-ui/nginx.conf b/docker/electrs-ui/nginx.conf index 9f927d8f..b9d4932f 100644 --- a/docker/electrs-ui/nginx.conf +++ b/docker/electrs-ui/nginx.conf @@ -1,5 +1,12 @@ server { - listen 50002; + # Loopback ONLY. This container is host-networked, so this nginx binds the + # HOST's address directly — `listen 50002;` meant every interface, and the + # app gate could never stand in front of it (there is no podman publish to + # pin, and the manifest declared no port, so the gate neither protected it + # nor reported it — it served this page to anyone who asked, on LAN, + # Tailscale and the mesh alike). Binding loopback lets the daemon claim the + # external addresses and authenticate them; see appgate::listener. + listen 127.0.0.1:50002; server_name _; root /usr/share/nginx/html; diff --git a/docker/fedimint-ui/nginx.conf b/docker/fedimint-ui/nginx.conf index 8a8539af..a4136378 100644 --- a/docker/fedimint-ui/nginx.conf +++ b/docker/fedimint-ui/nginx.conf @@ -1,5 +1,12 @@ server { - listen 8175; + # Loopback ONLY. This container is host-networked, so this nginx binds the + # HOST's address directly — `listen 8175;` meant every interface, and the + # app gate could never stand in front of it (there is no podman publish to + # pin, and the manifest declared no port, so the gate neither protected it + # nor reported it — it served this page to anyone who asked, on LAN, + # Tailscale and the mesh alike). Binding loopback lets the daemon claim the + # external addresses and authenticate them; see appgate::listener. + listen 127.0.0.1:8175; server_name _; proxy_intercept_errors on; diff --git a/docker/fips-ui/nginx.conf b/docker/fips-ui/nginx.conf index 5faf33c5..b2d8eae6 100644 --- a/docker/fips-ui/nginx.conf +++ b/docker/fips-ui/nginx.conf @@ -1,5 +1,12 @@ server { - listen 8336; + # Loopback ONLY. This container is host-networked, so this nginx binds the + # HOST's address directly — `listen 8336;` meant every interface, and the + # app gate could never stand in front of it (there is no podman publish to + # pin, and the manifest declared no port, so the gate neither protected it + # nor reported it — it served this page to anyone who asked, on LAN, + # Tailscale and the mesh alike). Binding loopback lets the daemon claim the + # external addresses and authenticate them; see appgate::listener. + listen 127.0.0.1:8336; server_name _; root /usr/share/nginx/html; index index.html; diff --git a/docker/lnd-ui/nginx.conf b/docker/lnd-ui/nginx.conf index 2c372562..c6ad7386 100644 --- a/docker/lnd-ui/nginx.conf +++ b/docker/lnd-ui/nginx.conf @@ -1,7 +1,14 @@ server { # Host-networked: listen on the app's own port directly (NOT 80, which the # host's main nginx already owns). The app is reached at http(s)://:18083. - listen 18083; + # Loopback ONLY. This container is host-networked, so this nginx binds the + # HOST's address directly — `listen 18083;` meant every interface, and the + # app gate could never stand in front of it (there is no podman publish to + # pin, and the manifest declared no port, so the gate neither protected it + # nor reported it — it served this page to anyone who asked, on LAN, + # Tailscale and the mesh alike). Binding loopback lets the daemon claim the + # external addresses and authenticate them; see appgate::listener. + listen 127.0.0.1:18083; server_name _; root /usr/share/nginx/html; diff --git a/releases/app-catalog.json b/releases/app-catalog.json index 11feb497..5a304b38 100644 --- a/releases/app-catalog.json +++ b/releases/app-catalog.json @@ -711,7 +711,15 @@ }, "id": "bitcoin-ui", "name": "Bitcoin UI", - "ports": [], + "ports": [ + { + "auth": "gated", + "bind": "127.0.0.1", + "container": 8334, + "host": 8334, + "protocol": "tcp" + } + ], "resources": { "memory_limit": "128Mi" }, @@ -805,6 +813,8 @@ "name": "BotFights", "ports": [ { + "auth": "gated", + "bind": "127.0.0.1", "container": 9100, "host": 9100, "protocol": "tcp" @@ -1159,7 +1169,15 @@ }, "id": "electrs-ui", "name": "Electrs UI", - "ports": [], + "ports": [ + { + "auth": "gated", + "bind": "127.0.0.1", + "container": 50002, + "host": 50002, + "protocol": "tcp" + } + ], "resources": { "memory_limit": "64Mi" }, @@ -1355,15 +1373,26 @@ "name": "Fedimint Guardian", "ports": [ { + "auth": "none", + "auth_rationale": "Fedimint guardian consensus. Other guardians speak the federation's own authenticated protocol here; a login page would break consensus.", "container": 8173, "host": 8173, "protocol": "tcp" }, { + "auth": "none", + "auth_rationale": "Fedimint guardian API for federation clients, which authenticate to the federation itself and cannot hold a browser session.", "container": 8174, "host": 8174, "protocol": "tcp" }, + { + "auth": "gated", + "bind": "127.0.0.1", + "container": 8175, + "host": 8175, + "protocol": "tcp" + }, { "auth": "local", "bind": "127.0.0.1", @@ -1548,11 +1577,15 @@ "name": "Fedimint Gateway", "ports": [ { + "auth": "none", + "auth_rationale": "Fedimint gateway API, protected by its own bcrypt password (--bcrypt-password-hash) and reached by federation peers and clients that cannot hold a browser session.", "container": 8176, "host": 8176, "protocol": "tcp" }, { + "auth": "none", + "auth_rationale": "LDK Lightning p2p for the gateway. The BOLT-8 noise handshake authenticates and encrypts the connection itself.", "container": 9737, "host": 9737, "protocol": "tcp" @@ -1700,7 +1733,15 @@ }, "id": "fips-ui", "name": "FIPS Mesh", - "ports": [], + "ports": [ + { + "auth": "gated", + "bind": "127.0.0.1", + "container": 8336, + "host": 8336, + "protocol": "tcp" + } + ], "resources": { "memory_limit": "128Mi" }, @@ -2909,6 +2950,8 @@ "protocol": "tcp" }, { + "auth": "none", + "auth_rationale": "LND REST, authenticated by macaroon over TLS. A browser login page would break Zeus and every non-browser wallet client, exactly as for lnd's 18080.", "container": 8080, "host": 8091, "protocol": "tcp" @@ -3078,7 +3121,15 @@ }, "id": "lnd-ui", "name": "LND UI", - "ports": [], + "ports": [ + { + "auth": "gated", + "bind": "127.0.0.1", + "container": 18083, + "host": 18083, + "protocol": "tcp" + } + ], "resources": { "memory_limit": "64Mi" }, @@ -3413,6 +3464,8 @@ "name": "NetBird", "ports": [ { + "auth": "none", + "auth_rationale": "NetBird dashboard over TLS, with its own login. The gate speaks plain HTTP, so fronting this port would break the secure context the dashboard requires (issue #15) and the certificate clients pin.", "container": 443, "host": 8087, "protocol": "tcp" @@ -3617,6 +3670,8 @@ "name": "NetBird Server", "ports": [ { + "auth": "none", + "auth_rationale": "NetBird management API and its OIDC issuer. Enrolled devices authenticate themselves with setup keys and JWTs, and they cannot hold a browser session — a login page here would disconnect every VPN client on the network.", "container": 80, "host": 8086, "protocol": "tcp" @@ -4012,11 +4067,15 @@ "name": "Pine", "ports": [ { + "auth": "gated", + "bind": "127.0.0.1", "container": 80, "host": 10380, "protocol": "tcp" }, { + "auth": "none", + "auth_rationale": "Pine's TLS listener. The gate speaks plain HTTP, so fronting this port would break the secure context navigator.bluetooth needs for WiFi provisioning. The plain-HTTP entry point (10380) is gated, and it is what the UI opens.", "container": 443, "host": 10381, "protocol": "tcp" @@ -4458,6 +4517,8 @@ }, "ports": [ { + "auth": "gated", + "bind": "127.0.0.1", "container": 8080, "host": 8084, "protocol": "tcp" @@ -4837,7 +4898,5 @@ } }, "schema": 1, - "signature": "cc83d0be50ce6144e2b5693a7175d7743d4a19141f4ef9a46a3c88d2dadd848acda9c25063e7a8b5643cecb2ccde279762a00a9715a5d26c99a95b492bc82a05", - "signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur", - "updated": "2026-08-04" + "updated": "2026-08-05" } diff --git a/scripts/self-update.sh b/scripts/self-update.sh index 99ea24f6..fb2ed45f 100755 --- a/scripts/self-update.sh +++ b/scripts/self-update.sh @@ -321,7 +321,17 @@ fi UI_DOCKER_DEST="/opt/archipelago/docker" sudo mkdir -p "$UI_DOCKER_DEST" UI_REBUILD_LIST="" -for ui in bitcoin-ui lnd-ui electrs-ui; do +# fips-ui and fedimint-ui are synced but NOT added to UI_REBUILD_LIST below: +# container-specs.sh has no spec for either (and their container names break +# the archy- assumption — the FIPS one is plain `fips-ui`). Their rebuilds +# come from elsewhere — the daemon's companion installer for fedimint-ui, the +# orchestrator's build context for fips-ui — but BOTH read +# /opt/archipelago/docker/, and nothing was ever updating that directory. +# So source edits to those two trees reached nodes through no path at all: +# their nginx kept listening on 0.0.0.0 and served the Guardian and FIPS +# screens unauthenticated on every interface (found by scanning archi-dev-box +# from outside, 2026-08-05 — the in-node audit could not see them). +for ui in bitcoin-ui lnd-ui electrs-ui fips-ui fedimint-ui; do src="$REPO_DIR/docker/$ui" dst="$UI_DOCKER_DEST/$ui" [ -d "$src" ] || continue @@ -335,9 +345,19 @@ for ui in bitcoin-ui lnd-ui electrs-ui; do dst_hash=$( (cd "$dst" && find . -type f | LC_ALL=C sort | xargs sha256sum 2>/dev/null) | sha256sum | cut -d' ' -f1) fi if [ "$src_hash" != "$dst_hash" ]; then - log "UI source changed for $ui; syncing and marking for rebuild" + log "UI source changed for $ui; syncing" sudo rsync -a --delete "$src/" "$dst/" - UI_REBUILD_LIST="$UI_REBUILD_LIST $ui" + case "$ui" in + # Rebuilt below from container-specs.sh. + bitcoin-ui|lnd-ui|electrs-ui) + UI_REBUILD_LIST="$UI_REBUILD_LIST $ui" ;; + # Synced only — rebuilt by the daemon (companion installer / + # orchestrator build context), which watches this directory. + # Adding them to the rebuild list would fail: no spec exists and + # the container names are not archy-. + *) + log " $ui synced; rebuild is owned by the daemon" ;; + esac else ok "UI source unchanged for $ui" fi From 9c736f20b69ddc693a5638a5afeef252d6092503 Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 5 Aug 2026 09:07:05 -0400 Subject: [PATCH 20/60] fix(security): publish the loopback-pinned UI images and pin the new tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fresh installs pull *-ui images from the registry, so the source fix alone left a newly flashed node serving the Bitcoin, LND, Electrs, FIPS and Guardian screens with no login. All five rebuilt and pushed to 146.59.87.168:3000/lfg2025 as 1.7.123-alpha AND :latest — both tags, because first-boot resolves the pinned tag from image-versions.sh while the daemon's companion installer hardcodes :latest, and a stale :latest would have quietly undone the fix on exactly the path that rebuilds companions. Verified by pulling each image back from the registry anonymously and reading /etc/nginx/conf.d/default.conf inside it — a private package would make fresh nodes fall back to a stale local image without saying so. Also fixes the FOURTH copy of bitcoin-ui's listen directive (scripts/reconcile-containers.sh wrote 'listen 8334' into the rendered nginx.conf on every reconcile, which would have re-opened the port after the image and template were both corrected). Co-Authored-By: Claude Fable 5 --- scripts/image-versions.sh | 8 ++++---- scripts/reconcile-containers.sh | 7 ++++++- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/scripts/image-versions.sh b/scripts/image-versions.sh index 4e349e80..6857c803 100644 --- a/scripts/image-versions.sh +++ b/scripts/image-versions.sh @@ -84,7 +84,7 @@ STRFRY_IMAGE="$ARCHY_REGISTRY/strfry:1.0.4" NOSTR_VPN_IMAGE="$ARCHY_REGISTRY/nostr-vpn:v0.3.7" NOSTR_VPN_UI_IMAGE="$ARCHY_REGISTRY/nostr-vpn-ui:latest" FIPS_IMAGE="$ARCHY_REGISTRY/fips:v0.1.0" -FIPS_UI_IMAGE="$ARCHY_REGISTRY/fips-ui:latest" +FIPS_UI_IMAGE="$ARCHY_REGISTRY/fips-ui:1.7.123-alpha" # AI / Routing ROUTSTR_IMAGE="$ARCHY_REGISTRY/routstr:v0.4.3" @@ -117,9 +117,9 @@ PENPOT_EXPORTER_IMAGE="$ARCHY_REGISTRY/penpot-exporter:2.4" PENPOT_FRONTEND_IMAGE="$ARCHY_REGISTRY/penpot-frontend:2.4" # Custom UI containers (built from docker/ dirs, pushed to registry) -BITCOIN_UI_IMAGE="$ARCHY_REGISTRY/bitcoin-ui:1.7.119-alpha" -LND_UI_IMAGE="$ARCHY_REGISTRY/lnd-ui:1.7.119-alpha" -ELECTRS_UI_IMAGE="$ARCHY_REGISTRY/electrs-ui:latest" +BITCOIN_UI_IMAGE="$ARCHY_REGISTRY/bitcoin-ui:1.7.123-alpha" +LND_UI_IMAGE="$ARCHY_REGISTRY/lnd-ui:1.7.123-alpha" +ELECTRS_UI_IMAGE="$ARCHY_REGISTRY/electrs-ui:1.7.123-alpha" # Base images NGINX_ALPINE_IMAGE="$ARCHY_REGISTRY/nginx:1.27.4-alpine" diff --git a/scripts/reconcile-containers.sh b/scripts/reconcile-containers.sh index 9f515a83..2c585856 100755 --- a/scripts/reconcile-containers.sh +++ b/scripts/reconcile-containers.sh @@ -809,7 +809,12 @@ ensure_bitcoin_ui_nginx_conf() { tmp="${CONF_PATH}.tmp.$$" sudo tee "$tmp" >/dev/null << EOF server { - listen 8334; + # Loopback ONLY — this is the fourth copy of this declaration (the others + # are the Rust template in container/bitcoin_ui_nginx.conf.template, the + # image, and the manifest). Host networking means this nginx binds the + # HOST's address, so \`listen 8334;\` served the Bitcoin screen on every + # interface with no login. The app gate owns the external addresses now. + listen 127.0.0.1:8334; server_name _; root /usr/share/nginx/html; index index.html; From 27c1b151f8435b01fa0995be6e6adc1f74beefc2 Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 5 Aug 2026 09:08:07 -0400 Subject: [PATCH 21/60] docs(1.7.123): curate release notes and add the in-app What's New block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Leads with the honest version: five screens were open and the previous release's own audit reported them as fine, found by scanning from another machine rather than asking the node. States plainly that what leaked was the page, not credentials — the macaroon path was verified, not assumed. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 10 ++++++++++ .../src/views/settings/AccountInfoSection.vue | 15 +++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c7b8e079..6cce1a1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## v1.7.123-alpha (2026-08-05) + +- **Five more screens on your node were readable by anyone who could reach it, and the previous release's own check said they were fine.** The Bitcoin, Lightning, Electrum, FIPS mesh and Fedimint Guardian screens each answered on their port with no login. They were missed because they work differently from ordinary apps: they run directly on the node's network rather than behind its container plumbing, so there was no address to pin and their descriptions listed no port at all — and the node builds its list of what to protect from exactly those descriptions. It therefore neither protected them nor listed them as unprotected. A check that reports success while five screens are open is worse than no check, and this was found by scanning the node from another machine rather than asking the node about itself. +- **What was actually readable was the page, not your money.** Every request on those ports that could have returned a credential — the Lightning connection details, the wallet passthrough, container logs, and every node command — already required a login and still refused without one. The Lightning macaroon fix from v1.7.120 was verified directly rather than assumed. What leaked was the screen itself: layout and code, no wallet data, no keys. +- All five now serve only to the node itself, with the login gate in front of them, exactly like the twenty app screens closed in the previous release. +- **Every port on the node now has a stated policy — there are no undecided ones left.** Eleven ports previously had no instruction either way and stayed open by default. The BotFights arena, the router screen and the Pine voice screen now require the node password. The ones that genuinely cannot take a login page stay open with a written reason: Fedimint's guardian and gateway connections (federation members authenticate to the federation), NetBird's management and dashboard ports (your VPN devices carry their own credentials and cannot hold a browser session, and its dashboard needs its own certificate), Pine's secure listener, and the Lightning REST port, which wallets reach with a macaroon exactly as before. +- Fresh installs are covered too, not just existing nodes. The five screens are delivered as prebuilt images, so a newly flashed node would have come up open even after this fix. All five were rebuilt, published, and then pulled back and inspected to confirm the fix is really inside them. +- Two delivery faults fixed alongside, either of which would have silently undone the above: two of the five screens were reaching nodes through no update path at all, so edits to them never arrived; and a fourth copy of the Bitcoin screen's configuration was being rewritten on every health check, which would have re-opened that port after everything else was corrected. +- Known gaps, disclosed rather than buried: non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. Three voice-assistant ports remain open without authentication; the correct fix puts them on a private network with the assistant. The 5x real-node lifecycle gate was not run for this release. + ## v1.7.122-alpha (2026-08-04) - **Your apps now ask for your node password before they open — over your home network, Tailscale, the mesh and Tor alike.** Until now anyone who could reach your node could open Immich, Nextcloud, Vaultwarden, Jellyfin, Grafana and the rest simply by typing the address and port, with no login at all. Twenty app screens now sit behind the same login you use for the node, showing you which app you are opening, and honouring two-factor if you have it switched on. Logging in at an app address logs you into the dashboard too, so it is one password, not one per app. This completes the groundwork disclosed in v1.7.121. diff --git a/neode-ui/src/views/settings/AccountInfoSection.vue b/neode-ui/src/views/settings/AccountInfoSection.vue index 3296d9ac..5516fbe5 100644 --- a/neode-ui/src/views/settings/AccountInfoSection.vue +++ b/neode-ui/src/views/settings/AccountInfoSection.vue @@ -362,6 +362,21 @@ init()
+ +
+
+ v1.7.123-alpha + August 5, 2026 +
+
+

Five more screens on your node were readable by anyone who could reach it — and the last release's own check said they were fine. The Bitcoin, Lightning, Electrum, FIPS mesh and Fedimint Guardian screens each answered on their port with no login. They were missed because they run directly on the node's network rather than behind its container plumbing, so there was no address to pin and their descriptions listed no port at all — and the node builds its list of what to protect from exactly those descriptions. It neither protected them nor listed them as unprotected. Found by scanning the node from another machine instead of asking the node about itself.

+

What was readable was the page, not your money. Every request on those ports that could have returned a credential — the Lightning connection details, the wallet passthrough, container logs, and every node command — already required a login and still refused without one. The Lightning macaroon fix from v1.7.120 was checked directly rather than assumed. What leaked was the screen itself: layout and code, no wallet data, no keys.

+

All five now serve only to the node itself, with the login gate in front, exactly like the twenty app screens closed in the last release.

+

Every port on your node now has a stated policy — none are left undecided. Eleven had no instruction either way and stayed open by default. The BotFights arena, the router screen and the Pine voice screen now ask for your node password. The ones that genuinely cannot take a login page stay open with a written reason: Fedimint's guardian and gateway connections, NetBird's management and dashboard ports (your VPN devices carry their own credentials and cannot hold a browser session), Pine's secure listener, and the Lightning REST port that wallets reach with a macaroon exactly as before.

+

Freshly flashed nodes are covered too, not only existing ones — those five screens ship as prebuilt images, so a new node would otherwise have come up open even after this fix. All five were rebuilt, published, then pulled back and inspected to confirm the fix is really inside them.

+

Known gaps, disclosed rather than buried: non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. Three voice-assistant ports remain open without authentication; the correct fix puts them on a private network with the assistant. The 5x real-node lifecycle gate was not run for this release.

+
+
From cfd1b4c7319b23be3ed9e87f6c47cbc4ebd1105e Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 5 Aug 2026 09:31:28 -0400 Subject: [PATCH 22/60] chore(trust): flip the signing checks to the new release root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1.7.122-alpha was the last release signed with the old root — it is the release that installed the new pin on every node. From v1.7.123 the new root signs, and a node running .122+ rejects an old-key signature. The ARCHY_RELEASE_ROOT_PUBKEY override is no longer needed either: the signer built from this tree pins the same key we now sign with. Co-Authored-By: Claude Fable 5 --- scripts/create-release.sh | 18 +++++------------- scripts/publish-release-assets.sh | 8 +++----- scripts/sign-manifest.sh | 15 +++------------ 3 files changed, 11 insertions(+), 30 deletions(-) diff --git a/scripts/create-release.sh b/scripts/create-release.sh index c160d545..2f2308ad 100755 --- a/scripts/create-release.sh +++ b/scripts/create-release.sh @@ -240,19 +240,11 @@ install -m 0644 "$FRONTEND_ARCHIVE" "$VERSION_DIR/archipelago-frontend-${VERSION # warning and falls through — and the commit then happened anyway. A release # commit carrying a manifest no node will accept has no valid use, so refuse # to create one rather than leave a tag that has to be re-cut. -# ⚠ ROTATION IN FLIGHT (v1.7.122-alpha) — this is the OLD root, deliberately. -# -# The trust anchor in the binary already pins the NEW root -# (z6Mkfu5LT…DLWT), because this release is what installs that pin. But the -# manifest THIS release ships must be signed with the OLD root -# (z6Mkkid…q7ur): every node is still running the previous binary, which -# pins the old key and would reject anything else. Signing this one with the -# new key ends OTA fleet-wide and needs hands-on recovery per node. -# -# ➜ NEXT RELEASE (v1.7.123+): change this to the new DID, and the same line -# in publish-release-assets.sh. By then every node runs a binary pinning -# the new root, and an old-key signature is the one that gets rejected. -EXPECTED_DID="did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur" +# Release root ROTATED 2026-08-05. v1.7.122-alpha was the last release signed +# with the old root (z6Mkkid…q7ur) — it is the release that installed this +# pin on every node. From v1.7.123 onward the new root signs, and nodes +# running .122+ reject anything signed with the old key. +EXPECTED_DID="did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT" if ! grep -q '"signature":' "$PROJECT_ROOT/releases/manifest.json" \ || ! grep -q "\"signed_by\": \"$EXPECTED_DID\"" "$PROJECT_ROOT/releases/manifest.json"; then echo "" >&2 diff --git a/scripts/publish-release-assets.sh b/scripts/publish-release-assets.sh index 1079238a..14de7993 100755 --- a/scripts/publish-release-assets.sh +++ b/scripts/publish-release-assets.sh @@ -29,11 +29,9 @@ fail() { echo "Error: $*" >&2; exit 1; } # with the pinned release-root anchor refuse to auto-apply unsigned manifests, # and enforcement will tighten to hard-reject — an unsigned publish would # strand them. Grep proves presence; ceremony verify proves the crypto. -# ⚠ ROTATION IN FLIGHT (v1.7.122-alpha) — OLD root on purpose; see the same -# block in create-release.sh. Nodes still run the previous binary and pin the -# old key, so the manifest this release publishes must carry an old-key -# signature. Flip both to z6Mkfu5LT…DLWT for v1.7.123+. -EXPECTED_DID="did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur" +# Release root ROTATED 2026-08-05; see create-release.sh. New root from +# v1.7.123 onward. +EXPECTED_DID="did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT" grep -q '"signature":' "$PROJECT_ROOT/releases/manifest.json" \ && grep -q "\"signed_by\": \"$EXPECTED_DID\"" "$PROJECT_ROOT/releases/manifest.json" \ || fail "releases/manifest.json is not signed by the release root — run: bash scripts/sign-manifest.sh" diff --git a/scripts/sign-manifest.sh b/scripts/sign-manifest.sh index 62e7a7d8..c223e995 100755 --- a/scripts/sign-manifest.sh +++ b/scripts/sign-manifest.sh @@ -12,18 +12,9 @@ # re-signing (e.g. a manifest edited after creation) or signing on a box where # the release run was non-interactive. # -# ⚠ ROTATION IN FLIGHT (v1.7.122-alpha). This release must be signed with the -# OLD release root, because every node still runs a binary pinning it — but -# the signer built from THIS tree already pins the NEW root, so its own -# verification would reject a correct old-key signature. Pin the old anchor -# for the duration of the ceremony so signing and verification agree: -# -# ARCHY_RELEASE_ROOT_PUBKEY=5d15cbee8a108f7dd288c02d29a1d9d71f198acc99186aad8008b4f28d469951 \ -# bash scripts/sign-manifest.sh -# -# That hex is the OLD root's PUBLIC key (verified to derive to -# did:key:z6Mkkid…q7ur); it is not secret and pins verification only. -# From v1.7.123 the override is unnecessary — drop it and this block. +# The release root was rotated 2026-08-05. From v1.7.123 this signs with the +# NEW mnemonic and the signer's own anchor already pins that key, so no +# ARCHY_RELEASE_ROOT_PUBKEY override is needed (it was, for .122 only). set -euo pipefail REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" From ea4c07218387312fe6c13b02f4bd879c7a3fa86f Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 5 Aug 2026 09:35:47 -0400 Subject: [PATCH 23/60] chore: release v1.7.123-alpha --- core/Cargo.lock | 2 +- core/archipelago/Cargo.toml | 2 +- neode-ui/package-lock.json | 4 ++-- neode-ui/package.json | 2 +- release-manifest.json | 43 ++++++++++++++++++------------------- releases/app-catalog.json | 2 ++ releases/manifest.json | 43 ++++++++++++++++++------------------- 7 files changed, 49 insertions(+), 49 deletions(-) diff --git a/core/Cargo.lock b/core/Cargo.lock index a7793458..2bfc227e 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -104,7 +104,7 @@ dependencies = [ [[package]] name = "archipelago" -version = "1.7.122-alpha" +version = "1.7.123-alpha" dependencies = [ "anyhow", "archipelago-container", diff --git a/core/archipelago/Cargo.toml b/core/archipelago/Cargo.toml index 8943512c..fc0d0279 100644 --- a/core/archipelago/Cargo.toml +++ b/core/archipelago/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "archipelago" -version = "1.7.122-alpha" +version = "1.7.123-alpha" edition = "2021" description = "Archipelago Bitcoin Node OS - Native backend" authors = ["Archipelago Team"] diff --git a/neode-ui/package-lock.json b/neode-ui/package-lock.json index fd8825ee..38ac58de 100644 --- a/neode-ui/package-lock.json +++ b/neode-ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "neode-ui", - "version": "1.7.122-alpha", + "version": "1.7.123-alpha", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "neode-ui", - "version": "1.7.122-alpha", + "version": "1.7.123-alpha", "dependencies": { "@scure/bip39": "^2.2.0", "@types/dompurify": "^3.0.5", diff --git a/neode-ui/package.json b/neode-ui/package.json index f7375a8e..2c1b2738 100644 --- a/neode-ui/package.json +++ b/neode-ui/package.json @@ -1,7 +1,7 @@ { "name": "neode-ui", "private": true, - "version": "1.7.122-alpha", + "version": "1.7.123-alpha", "type": "module", "scripts": { "start": "./start-dev.sh", diff --git a/release-manifest.json b/release-manifest.json index 22dd4ab6..edf4a6eb 100644 --- a/release-manifest.json +++ b/release-manifest.json @@ -1,34 +1,33 @@ { "changelog": [ - "**Your apps now ask for your node password before they open — over your home network, Tailscale, the mesh and Tor alike.** Until now anyone who could reach your node could open Immich, Nextcloud, Vaultwarden, Jellyfin, Grafana and the rest simply by typing the address and port, with no login at all. Twenty app screens now sit behind the same login you use for the node, showing you which app you are opening, and honouring two-factor if you have it switched on. Logging in at an app address logs you into the dashboard too, so it is one password, not one per app. This completes the groundwork disclosed in v1.7.121.", - "**The things that must stay open stayed open.** Zeus and other remote wallets still reach your Lightning node directly, Electrum wallets still connect, and Bitcoin still talks to its peers — those connections carry their own proof of identity and a login page would simply break them. Every one of these seventeen exceptions now has to state in writing why it is safe to leave open, so the list is something you can read rather than something you have to discover.", - "**A private address on your node was answering the mesh without a password.** One app's port was marked as being for this machine only, and the part of the node that carries mesh traffic did not know that — it forwarded requests from the whole mesh straight to it. Found while verifying the work above on a real node, not in testing. That path now refuses anything marked machine-only, and the app is reachable only from the node itself, as intended.", - "**Tor addresses no longer skip the login.** An app published as a .onion address was handed straight to the app, because a Tor visitor carries no session cookie to check. The login gate now takes those addresses first, closing the last of the four routes that went around it.", - "Nodes fix themselves after this update. Apps installed before this system used its current container setup kept their old wide-open address even after the signed list told them to move, and each would otherwise have needed hand-holding on every node. Your node now notices the difference and rebuilds those apps itself, keeping their data, within about half a minute of starting. Verified by putting a node back into the old state deliberately and watching it repair.", - "The node had been reading two different sets of instructions about its own apps — the signed list it downloads, and older copies on disk — which is how a port meant to stay private was briefly opened on a test node. Both now come from the signed list, and a port withdrawn from the login gate is released without needing a restart.", - "**The key that signs these updates has been replaced.** The previous signing key was exposed where it should not have been, so it is treated as compromised and this release installs its replacement. This update is the last one signed with the old key, by necessity — it is the one that teaches your node the new one.", - "Known gaps, disclosed rather than buried: eleven app ports still have no stated policy — BotFights, the Fedimint gateway, NetBird, the voice assistant's own screens and the router screen — and remain reachable without a login until each is decided deliberately; the node reports them rather than guessing, because guessing at an unstated setting caused both incidents behind this work. Three voice-assistant ports are still open without authentication; the correct fix puts them on a private network with the assistant. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — will now meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release." + "**Five more screens on your node were readable by anyone who could reach it, and the previous release's own check said they were fine.** The Bitcoin, Lightning, Electrum, FIPS mesh and Fedimint Guardian screens each answered on their port with no login. They were missed because they work differently from ordinary apps: they run directly on the node's network rather than behind its container plumbing, so there was no address to pin and their descriptions listed no port at all — and the node builds its list of what to protect from exactly those descriptions. It therefore neither protected them nor listed them as unprotected. A check that reports success while five screens are open is worse than no check, and this was found by scanning the node from another machine rather than asking the node about itself.", + "**What was actually readable was the page, not your money.** Every request on those ports that could have returned a credential — the Lightning connection details, the wallet passthrough, container logs, and every node command — already required a login and still refused without one. The Lightning macaroon fix from v1.7.120 was verified directly rather than assumed. What leaked was the screen itself: layout and code, no wallet data, no keys.", + "All five now serve only to the node itself, with the login gate in front of them, exactly like the twenty app screens closed in the previous release.", + "**Every port on the node now has a stated policy — there are no undecided ones left.** Eleven ports previously had no instruction either way and stayed open by default. The BotFights arena, the router screen and the Pine voice screen now require the node password. The ones that genuinely cannot take a login page stay open with a written reason: Fedimint's guardian and gateway connections (federation members authenticate to the federation), NetBird's management and dashboard ports (your VPN devices carry their own credentials and cannot hold a browser session, and its dashboard needs its own certificate), Pine's secure listener, and the Lightning REST port, which wallets reach with a macaroon exactly as before.", + "Fresh installs are covered too, not just existing nodes. The five screens are delivered as prebuilt images, so a newly flashed node would have come up open even after this fix. All five were rebuilt, published, and then pulled back and inspected to confirm the fix is really inside them.", + "Two delivery faults fixed alongside, either of which would have silently undone the above: two of the five screens were reaching nodes through no update path at all, so edits to them never arrived; and a fourth copy of the Bitcoin screen's configuration was being rewritten on every health check, which would have re-opened that port after everything else was corrected.", + "Known gaps, disclosed rather than buried: non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. Three voice-assistant ports remain open without authentication; the correct fix puts them on a private network with the assistant. The 5x real-node lifecycle gate was not run for this release." ], "components": [ { - "current_version": "1.7.122-alpha", - "download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.122-alpha/archipelago", + "current_version": "1.7.123-alpha", + "download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.123-alpha/archipelago", "name": "archipelago", - "new_version": "1.7.122-alpha", - "sha256": "06aedbd235e962574b7abc5d6992c26b77cd943655e775cd93c84fdcc79ffab0", - "size_bytes": 54957496 + "new_version": "1.7.123-alpha", + "sha256": "b7974a671f8f7987fab548d0a9cac1ad145e9a52f4099791c3d1879d065a9f47", + "size_bytes": 54957360 }, { - "current_version": "1.7.122-alpha", - "download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.122-alpha/archipelago-frontend-1.7.122-alpha.tar.gz", - "name": "archipelago-frontend-1.7.122-alpha.tar.gz", - "new_version": "1.7.122-alpha", - "sha256": "865f5a0edb5eed1ced9dc4597b9112f24706d3538f8ffe84dea8104049d26af3", - "size_bytes": 210528707 + "current_version": "1.7.123-alpha", + "download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.123-alpha/archipelago-frontend-1.7.123-alpha.tar.gz", + "name": "archipelago-frontend-1.7.123-alpha.tar.gz", + "new_version": "1.7.123-alpha", + "sha256": "075370c761f07373e553db07eb5e019d96ceb591fa9c1067e1adc6701fa30752", + "size_bytes": 210533854 } ], "release_date": "2026-08-05", - "signature": "aca66567bf5954aefd450167f881289ee4715fd912fe61a50726741cadf1a93d39e832efc3266388839279ad41001c9802fdfaf766c8cfa9399509916ed4a80f", - "signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur", - "version": "1.7.122-alpha" + "signature": "f64eaf9731277865c0d3f757f63dc2f4921fb7d90deb1615ca2b2b0f4c173282524b39a2d0b8f027c7532e918c349a86b14ed1b7992a96b0c6a80cd46b5d500d", + "signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT", + "version": "1.7.123-alpha" } diff --git a/releases/app-catalog.json b/releases/app-catalog.json index 5a304b38..e8f64b80 100644 --- a/releases/app-catalog.json +++ b/releases/app-catalog.json @@ -4898,5 +4898,7 @@ } }, "schema": 1, + "signature": "cc19214673b37c2d7d41edf996d5002abe41cf933ee9f33fb7182b05c6c40aa1a555ad596228c4a83dc4a11166f3345e7478399f9693f5f2da3818020980eb03", + "signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT", "updated": "2026-08-05" } diff --git a/releases/manifest.json b/releases/manifest.json index 22dd4ab6..edf4a6eb 100644 --- a/releases/manifest.json +++ b/releases/manifest.json @@ -1,34 +1,33 @@ { "changelog": [ - "**Your apps now ask for your node password before they open — over your home network, Tailscale, the mesh and Tor alike.** Until now anyone who could reach your node could open Immich, Nextcloud, Vaultwarden, Jellyfin, Grafana and the rest simply by typing the address and port, with no login at all. Twenty app screens now sit behind the same login you use for the node, showing you which app you are opening, and honouring two-factor if you have it switched on. Logging in at an app address logs you into the dashboard too, so it is one password, not one per app. This completes the groundwork disclosed in v1.7.121.", - "**The things that must stay open stayed open.** Zeus and other remote wallets still reach your Lightning node directly, Electrum wallets still connect, and Bitcoin still talks to its peers — those connections carry their own proof of identity and a login page would simply break them. Every one of these seventeen exceptions now has to state in writing why it is safe to leave open, so the list is something you can read rather than something you have to discover.", - "**A private address on your node was answering the mesh without a password.** One app's port was marked as being for this machine only, and the part of the node that carries mesh traffic did not know that — it forwarded requests from the whole mesh straight to it. Found while verifying the work above on a real node, not in testing. That path now refuses anything marked machine-only, and the app is reachable only from the node itself, as intended.", - "**Tor addresses no longer skip the login.** An app published as a .onion address was handed straight to the app, because a Tor visitor carries no session cookie to check. The login gate now takes those addresses first, closing the last of the four routes that went around it.", - "Nodes fix themselves after this update. Apps installed before this system used its current container setup kept their old wide-open address even after the signed list told them to move, and each would otherwise have needed hand-holding on every node. Your node now notices the difference and rebuilds those apps itself, keeping their data, within about half a minute of starting. Verified by putting a node back into the old state deliberately and watching it repair.", - "The node had been reading two different sets of instructions about its own apps — the signed list it downloads, and older copies on disk — which is how a port meant to stay private was briefly opened on a test node. Both now come from the signed list, and a port withdrawn from the login gate is released without needing a restart.", - "**The key that signs these updates has been replaced.** The previous signing key was exposed where it should not have been, so it is treated as compromised and this release installs its replacement. This update is the last one signed with the old key, by necessity — it is the one that teaches your node the new one.", - "Known gaps, disclosed rather than buried: eleven app ports still have no stated policy — BotFights, the Fedimint gateway, NetBird, the voice assistant's own screens and the router screen — and remain reachable without a login until each is decided deliberately; the node reports them rather than guessing, because guessing at an unstated setting caused both incidents behind this work. Three voice-assistant ports are still open without authentication; the correct fix puts them on a private network with the assistant. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — will now meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release." + "**Five more screens on your node were readable by anyone who could reach it, and the previous release's own check said they were fine.** The Bitcoin, Lightning, Electrum, FIPS mesh and Fedimint Guardian screens each answered on their port with no login. They were missed because they work differently from ordinary apps: they run directly on the node's network rather than behind its container plumbing, so there was no address to pin and their descriptions listed no port at all — and the node builds its list of what to protect from exactly those descriptions. It therefore neither protected them nor listed them as unprotected. A check that reports success while five screens are open is worse than no check, and this was found by scanning the node from another machine rather than asking the node about itself.", + "**What was actually readable was the page, not your money.** Every request on those ports that could have returned a credential — the Lightning connection details, the wallet passthrough, container logs, and every node command — already required a login and still refused without one. The Lightning macaroon fix from v1.7.120 was verified directly rather than assumed. What leaked was the screen itself: layout and code, no wallet data, no keys.", + "All five now serve only to the node itself, with the login gate in front of them, exactly like the twenty app screens closed in the previous release.", + "**Every port on the node now has a stated policy — there are no undecided ones left.** Eleven ports previously had no instruction either way and stayed open by default. The BotFights arena, the router screen and the Pine voice screen now require the node password. The ones that genuinely cannot take a login page stay open with a written reason: Fedimint's guardian and gateway connections (federation members authenticate to the federation), NetBird's management and dashboard ports (your VPN devices carry their own credentials and cannot hold a browser session, and its dashboard needs its own certificate), Pine's secure listener, and the Lightning REST port, which wallets reach with a macaroon exactly as before.", + "Fresh installs are covered too, not just existing nodes. The five screens are delivered as prebuilt images, so a newly flashed node would have come up open even after this fix. All five were rebuilt, published, and then pulled back and inspected to confirm the fix is really inside them.", + "Two delivery faults fixed alongside, either of which would have silently undone the above: two of the five screens were reaching nodes through no update path at all, so edits to them never arrived; and a fourth copy of the Bitcoin screen's configuration was being rewritten on every health check, which would have re-opened that port after everything else was corrected.", + "Known gaps, disclosed rather than buried: non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. Three voice-assistant ports remain open without authentication; the correct fix puts them on a private network with the assistant. The 5x real-node lifecycle gate was not run for this release." ], "components": [ { - "current_version": "1.7.122-alpha", - "download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.122-alpha/archipelago", + "current_version": "1.7.123-alpha", + "download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.123-alpha/archipelago", "name": "archipelago", - "new_version": "1.7.122-alpha", - "sha256": "06aedbd235e962574b7abc5d6992c26b77cd943655e775cd93c84fdcc79ffab0", - "size_bytes": 54957496 + "new_version": "1.7.123-alpha", + "sha256": "b7974a671f8f7987fab548d0a9cac1ad145e9a52f4099791c3d1879d065a9f47", + "size_bytes": 54957360 }, { - "current_version": "1.7.122-alpha", - "download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.122-alpha/archipelago-frontend-1.7.122-alpha.tar.gz", - "name": "archipelago-frontend-1.7.122-alpha.tar.gz", - "new_version": "1.7.122-alpha", - "sha256": "865f5a0edb5eed1ced9dc4597b9112f24706d3538f8ffe84dea8104049d26af3", - "size_bytes": 210528707 + "current_version": "1.7.123-alpha", + "download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.123-alpha/archipelago-frontend-1.7.123-alpha.tar.gz", + "name": "archipelago-frontend-1.7.123-alpha.tar.gz", + "new_version": "1.7.123-alpha", + "sha256": "075370c761f07373e553db07eb5e019d96ceb591fa9c1067e1adc6701fa30752", + "size_bytes": 210533854 } ], "release_date": "2026-08-05", - "signature": "aca66567bf5954aefd450167f881289ee4715fd912fe61a50726741cadf1a93d39e832efc3266388839279ad41001c9802fdfaf766c8cfa9399509916ed4a80f", - "signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur", - "version": "1.7.122-alpha" + "signature": "f64eaf9731277865c0d3f757f63dc2f4921fb7d90deb1615ca2b2b0f4c173282524b39a2d0b8f027c7532e918c349a86b14ed1b7992a96b0c6a80cd46b5d500d", + "signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT", + "version": "1.7.123-alpha" } From 9b30daaf9cf8bb1f5366d8e1307740e5006f9297 Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 5 Aug 2026 10:17:58 -0400 Subject: [PATCH 24/60] fix(security): restart a companion whose image was rebuilt underneath it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A rebuilt image never reached a running companion. ensure_image_present rebuilds in place under the same tag, so the quadlet body is identical, write_if_changed reports no change, and enable_now is a no-op on a running service — the container keeps the old layers indefinitely. That is precisely how archi-dev-box kept serving the LND, FIPS, Electrs and Guardian screens on 0.0.0.0 after v1.7.123 rebuilt every one of those images to bind loopback: correct images on disk, three-day-old containers still running. Closing those ports needed a manual 'podman rm -f' per container, which no other node would ever get. Compare the running container's image ID against the built one and restart when they diverge. Co-Authored-By: Claude Fable 5 --- core/archipelago/src/container/companion.rs | 49 +++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/core/archipelago/src/container/companion.rs b/core/archipelago/src/container/companion.rs index 92658725..018ee63d 100644 --- a/core/archipelago/src/container/companion.rs +++ b/core/archipelago/src/container/companion.rs @@ -214,10 +214,59 @@ pub async fn install_one(spec: &CompanionSpec) -> Result<()> { } // Start is idempotent — if already running, systemctl returns 0. quadlet::enable_now(&unit.service_name()).await?; + + // A rebuilt image does NOT reach a container that is already running. + // `ensure_image_present` rebuilds in place under the same tag, so the unit + // body is byte-identical, `write_if_changed` reports no change, and + // `enable_now` is a no-op on a running service — the container keeps the + // old layers indefinitely. That is exactly how archi-dev-box kept serving + // the LND, FIPS, Electrs and Guardian screens on 0.0.0.0 after v1.7.123 + // rebuilt every one of those images to bind loopback: the images were + // correct on disk and the running containers were three days old + // (2026-08-05). Compare image IDs and restart when they diverge. + if let Some(running) = container_image_id(spec.name).await { + if let Some(built) = image_id(&image).await { + if running != built { + info!( + companion = spec.name, + "running container uses a stale image; restarting onto the rebuilt one" + ); + quadlet::restart_service(&unit.service_name()).await?; + } + } + } info!(companion = spec.name, "companion started"); Ok(()) } +/// Image ID a container is actually running, or `None` when it does not exist. +async fn container_image_id(name: &str) -> Option { + let out = tokio::process::Command::new("podman") + .args(["inspect", name, "--format", "{{.Image}}"]) + .output() + .await + .ok()?; + if !out.status.success() { + return None; + } + let id = String::from_utf8_lossy(&out.stdout).trim().to_string(); + (!id.is_empty()).then_some(id) +} + +/// Current ID behind an image reference, or `None` when absent. +async fn image_id(image_ref: &str) -> Option { + let out = tokio::process::Command::new("podman") + .args(["image", "inspect", image_ref, "--format", "{{.Id}}"]) + .output() + .await + .ok()?; + if !out.status.success() { + return None; + } + let id = String::from_utf8_lossy(&out.stdout).trim().to_string(); + (!id.is_empty()).then_some(id) +} + /// Build companion image locally if a Dockerfile exists, otherwise /// pull from the lfg2025 registry. Returns the image ref the quadlet /// should reference (`localhost/:latest` for build, registry From 4ec53a9805f11b2835762cee18653b3e474bb02d Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 5 Aug 2026 10:24:09 -0400 Subject: [PATCH 25/60] =?UTF-8?q?fix(ota):=20republish=20the=20.122=20mani?= =?UTF-8?q?fest=20=E2=80=94=20the=20rotation=20stranded=20every=20pre-.122?= =?UTF-8?q?=20node?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The manifest advertises exactly one version, so publishing .123 (new-key signed) removed the only stepping stone across the rotation. A node on .121 pins the OLD root, fetches the .123 manifest, fails signature verification and refuses — permanently, because .122 is no longer offered anywhere. Reproduced against the live URL: 'signed_by does not match the pinned release-root anchor'. archy-shorty-s (.228) is on 1.7.121-alpha-dev and in exactly this state. Restoring the old-key-signed .122 manifest as the OTA pointer lets those nodes take .122, which installs the new pin; .123 is republished once the fleet has crossed. Safe in both directions: is_newer() is a strict greater-than on the version triple, so a node already on .123 sees .122 as older and does not downgrade. The .123 release itself is untouched — tag, assets and catalog stand; only the pointer moves. Co-Authored-By: Claude Fable 5 --- releases/manifest.json | 43 +++++++++++++++++++++--------------------- 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/releases/manifest.json b/releases/manifest.json index edf4a6eb..22dd4ab6 100644 --- a/releases/manifest.json +++ b/releases/manifest.json @@ -1,33 +1,34 @@ { "changelog": [ - "**Five more screens on your node were readable by anyone who could reach it, and the previous release's own check said they were fine.** The Bitcoin, Lightning, Electrum, FIPS mesh and Fedimint Guardian screens each answered on their port with no login. They were missed because they work differently from ordinary apps: they run directly on the node's network rather than behind its container plumbing, so there was no address to pin and their descriptions listed no port at all — and the node builds its list of what to protect from exactly those descriptions. It therefore neither protected them nor listed them as unprotected. A check that reports success while five screens are open is worse than no check, and this was found by scanning the node from another machine rather than asking the node about itself.", - "**What was actually readable was the page, not your money.** Every request on those ports that could have returned a credential — the Lightning connection details, the wallet passthrough, container logs, and every node command — already required a login and still refused without one. The Lightning macaroon fix from v1.7.120 was verified directly rather than assumed. What leaked was the screen itself: layout and code, no wallet data, no keys.", - "All five now serve only to the node itself, with the login gate in front of them, exactly like the twenty app screens closed in the previous release.", - "**Every port on the node now has a stated policy — there are no undecided ones left.** Eleven ports previously had no instruction either way and stayed open by default. The BotFights arena, the router screen and the Pine voice screen now require the node password. The ones that genuinely cannot take a login page stay open with a written reason: Fedimint's guardian and gateway connections (federation members authenticate to the federation), NetBird's management and dashboard ports (your VPN devices carry their own credentials and cannot hold a browser session, and its dashboard needs its own certificate), Pine's secure listener, and the Lightning REST port, which wallets reach with a macaroon exactly as before.", - "Fresh installs are covered too, not just existing nodes. The five screens are delivered as prebuilt images, so a newly flashed node would have come up open even after this fix. All five were rebuilt, published, and then pulled back and inspected to confirm the fix is really inside them.", - "Two delivery faults fixed alongside, either of which would have silently undone the above: two of the five screens were reaching nodes through no update path at all, so edits to them never arrived; and a fourth copy of the Bitcoin screen's configuration was being rewritten on every health check, which would have re-opened that port after everything else was corrected.", - "Known gaps, disclosed rather than buried: non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. Three voice-assistant ports remain open without authentication; the correct fix puts them on a private network with the assistant. The 5x real-node lifecycle gate was not run for this release." + "**Your apps now ask for your node password before they open — over your home network, Tailscale, the mesh and Tor alike.** Until now anyone who could reach your node could open Immich, Nextcloud, Vaultwarden, Jellyfin, Grafana and the rest simply by typing the address and port, with no login at all. Twenty app screens now sit behind the same login you use for the node, showing you which app you are opening, and honouring two-factor if you have it switched on. Logging in at an app address logs you into the dashboard too, so it is one password, not one per app. This completes the groundwork disclosed in v1.7.121.", + "**The things that must stay open stayed open.** Zeus and other remote wallets still reach your Lightning node directly, Electrum wallets still connect, and Bitcoin still talks to its peers — those connections carry their own proof of identity and a login page would simply break them. Every one of these seventeen exceptions now has to state in writing why it is safe to leave open, so the list is something you can read rather than something you have to discover.", + "**A private address on your node was answering the mesh without a password.** One app's port was marked as being for this machine only, and the part of the node that carries mesh traffic did not know that — it forwarded requests from the whole mesh straight to it. Found while verifying the work above on a real node, not in testing. That path now refuses anything marked machine-only, and the app is reachable only from the node itself, as intended.", + "**Tor addresses no longer skip the login.** An app published as a .onion address was handed straight to the app, because a Tor visitor carries no session cookie to check. The login gate now takes those addresses first, closing the last of the four routes that went around it.", + "Nodes fix themselves after this update. Apps installed before this system used its current container setup kept their old wide-open address even after the signed list told them to move, and each would otherwise have needed hand-holding on every node. Your node now notices the difference and rebuilds those apps itself, keeping their data, within about half a minute of starting. Verified by putting a node back into the old state deliberately and watching it repair.", + "The node had been reading two different sets of instructions about its own apps — the signed list it downloads, and older copies on disk — which is how a port meant to stay private was briefly opened on a test node. Both now come from the signed list, and a port withdrawn from the login gate is released without needing a restart.", + "**The key that signs these updates has been replaced.** The previous signing key was exposed where it should not have been, so it is treated as compromised and this release installs its replacement. This update is the last one signed with the old key, by necessity — it is the one that teaches your node the new one.", + "Known gaps, disclosed rather than buried: eleven app ports still have no stated policy — BotFights, the Fedimint gateway, NetBird, the voice assistant's own screens and the router screen — and remain reachable without a login until each is decided deliberately; the node reports them rather than guessing, because guessing at an unstated setting caused both incidents behind this work. Three voice-assistant ports are still open without authentication; the correct fix puts them on a private network with the assistant. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — will now meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release." ], "components": [ { - "current_version": "1.7.123-alpha", - "download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.123-alpha/archipelago", + "current_version": "1.7.122-alpha", + "download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.122-alpha/archipelago", "name": "archipelago", - "new_version": "1.7.123-alpha", - "sha256": "b7974a671f8f7987fab548d0a9cac1ad145e9a52f4099791c3d1879d065a9f47", - "size_bytes": 54957360 + "new_version": "1.7.122-alpha", + "sha256": "06aedbd235e962574b7abc5d6992c26b77cd943655e775cd93c84fdcc79ffab0", + "size_bytes": 54957496 }, { - "current_version": "1.7.123-alpha", - "download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.123-alpha/archipelago-frontend-1.7.123-alpha.tar.gz", - "name": "archipelago-frontend-1.7.123-alpha.tar.gz", - "new_version": "1.7.123-alpha", - "sha256": "075370c761f07373e553db07eb5e019d96ceb591fa9c1067e1adc6701fa30752", - "size_bytes": 210533854 + "current_version": "1.7.122-alpha", + "download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.122-alpha/archipelago-frontend-1.7.122-alpha.tar.gz", + "name": "archipelago-frontend-1.7.122-alpha.tar.gz", + "new_version": "1.7.122-alpha", + "sha256": "865f5a0edb5eed1ced9dc4597b9112f24706d3538f8ffe84dea8104049d26af3", + "size_bytes": 210528707 } ], "release_date": "2026-08-05", - "signature": "f64eaf9731277865c0d3f757f63dc2f4921fb7d90deb1615ca2b2b0f4c173282524b39a2d0b8f027c7532e918c349a86b14ed1b7992a96b0c6a80cd46b5d500d", - "signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT", - "version": "1.7.123-alpha" + "signature": "aca66567bf5954aefd450167f881289ee4715fd912fe61a50726741cadf1a93d39e832efc3266388839279ad41001c9802fdfaf766c8cfa9399509916ed4a80f", + "signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur", + "version": "1.7.122-alpha" } From 91bbe4faa1ac7ba27ae5a9129a222c281fb86cd4 Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 5 Aug 2026 12:44:57 -0400 Subject: [PATCH 26/60] fix: portainer pin, bitcoin conf tolerance, gate login UI, named OTA origin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Portainer: nodes have been running :latest — which is 2.39.1 — while the manifest pinned 2.19.4 from two years ago. The port migration recreated the container onto that old pin and Portainer refused to start: it migrates a database forward, never backward, so an existing install died with 'schema version does not align' and My Apps showed 'app is not responding' (100.82.34.38). 2.39.1 published as an immutable tag and pinned forward, so existing databases keep working and older ones migrate up. Bitcoin: complements PR #131. That removes the code which kept writing a datadir bitcoin.conf; -allowignoredconf=1 additionally makes an existing one non-fatal, so a node already carrying the file recovers on restart instead of crash-looping until something reinstalls it. App gate login: rebuilt against the dashboard's own design — rotating intro backgrounds served from the gate, the glass panel, the Archipelago mark in its gradient ring, the app's icon as a My Apps tile, and the glass button. Crucially it no longer sends X-Frame-Options: DENY, which made every gated app render as unreachable inside My Apps' embedded frame; frame-ancestors expresses 'only this node may frame me', which X-Frame-Options cannot. OTA origin: primary mirror is now source.archipelago-foundation.org over TLS instead of a bare IP on plaintext. The IP stays as an automatic fallback for nodes whose DNS or clock is broken — both break TLS, and the signature, not the transport, is what establishes trust. Co-Authored-By: Claude Fable 5 --- apps/bitcoin-core/manifest.yml | 14 +- apps/bitcoin-knots/manifest.yml | 14 +- apps/portainer/manifest.yml | 2 +- core/archipelago/src/appgate/mod.rs | 347 ++++++++++++++++++++++++---- core/archipelago/src/update.rs | 59 +++-- scripts/image-versions.sh | 8 +- 6 files changed, 376 insertions(+), 68 deletions(-) diff --git a/apps/bitcoin-core/manifest.yml b/apps/bitcoin-core/manifest.yml index 6cdd5faa..c8619f7e 100644 --- a/apps/bitcoin-core/manifest.yml +++ b/apps/bitcoin-core/manifest.yml @@ -38,6 +38,16 @@ app: RPC_CONF="/tmp/rpc.conf"; umask 077; { echo "rpcuser=$RPC_USER"; echo "rpcpassword=$RPC_PASS"; } > "$RPC_CONF"; + # A stray bitcoin.conf in the datadir is FATAL when -conf points + # elsewhere: bitcoind refuses to start with "contains a bitcoin.conf + # file which is ignored", and the app crash-loops (100.82.34.38, + # 2026-08-05 — Exited(1) every few seconds). Our -conf carries the + # RPC credentials and the flags below are the authoritative config, + # so the datadir file is legacy debris; say so out loud rather than + # failing, and let bitcoind start. + if [ -f /home/bitcoin/.bitcoin/bitcoin.conf ]; then + echo "archipelago: ignoring legacy /home/bitcoin/.bitcoin/bitcoin.conf; RPC config comes from $RPC_CONF and the flags below" >&2; + fi; RPC_TXRELAY_AUTH="$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)"; DISK_GB_VALUE="$(printenv DISK_GB || true)"; RPC_HEADROOM="-rpcthreads=16 -rpcworkqueue=256"; @@ -46,9 +56,9 @@ app: RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips"; fi; if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then - exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS; + exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS; else - exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS; + exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS; fi derived_env: - key: DISK_GB diff --git a/apps/bitcoin-knots/manifest.yml b/apps/bitcoin-knots/manifest.yml index 9d7967d4..cdc492c5 100644 --- a/apps/bitcoin-knots/manifest.yml +++ b/apps/bitcoin-knots/manifest.yml @@ -38,6 +38,16 @@ app: RPC_CONF="/tmp/rpc.conf"; umask 077; { echo "rpcuser=$RPC_USER"; echo "rpcpassword=$RPC_PASS"; } > "$RPC_CONF"; + # A stray bitcoin.conf in the datadir is FATAL when -conf points + # elsewhere: bitcoind refuses to start with "contains a bitcoin.conf + # file which is ignored", and the app crash-loops (100.82.34.38, + # 2026-08-05 — Exited(1) every few seconds). Our -conf carries the + # RPC credentials and the flags below are the authoritative config, + # so the datadir file is legacy debris; say so out loud rather than + # failing, and let bitcoind start. + if [ -f /home/bitcoin/.bitcoin/bitcoin.conf ]; then + echo "archipelago: ignoring legacy /home/bitcoin/.bitcoin/bitcoin.conf; RPC config comes from $RPC_CONF and the flags below" >&2; + fi; RPC_TXRELAY_AUTH="$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)"; DISK_GB_VALUE="$(printenv DISK_GB || true)"; RPC_HEADROOM="-rpcthreads=16 -rpcworkqueue=256"; @@ -46,9 +56,9 @@ app: RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips"; fi; if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then - exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS; + exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS; else - exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS; + exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS; fi derived_env: - key: DISK_GB diff --git a/apps/portainer/manifest.yml b/apps/portainer/manifest.yml index 70fdac89..868e114e 100644 --- a/apps/portainer/manifest.yml +++ b/apps/portainer/manifest.yml @@ -6,7 +6,7 @@ app: category: development container: - image: 146.59.87.168:3000/lfg2025/portainer:2.19.4 + image: 146.59.87.168:3000/lfg2025/portainer:2.39.1 pull_policy: if-not-present data_uid: "1000:1000" diff --git a/core/archipelago/src/appgate/mod.rs b/core/archipelago/src/appgate/mod.rs index dee3e7e0..9dc4090b 100644 --- a/core/archipelago/src/appgate/mod.rs +++ b/core/archipelago/src/appgate/mod.rs @@ -154,6 +154,11 @@ impl AppGate { action: &str, client_ip: IpAddr, ) -> Response { + // Assets are GET and pre-auth by nature: the login page cannot + // render its own background or logo without them. + if let Some(name) = action.strip_prefix("asset/") { + return self.serve_asset(name); + } if req.method() != Method::POST { return login_page(app, None, StatusCode::OK); } @@ -187,6 +192,26 @@ impl AppGate { } } + /// Static assets the login page needs, served from the gate's own origin. + /// + /// The backgrounds are ~1 MB each, so inlining them as data URIs would + /// bloat every challenge response. Serving them here keeps the page + /// byte-identical to the dashboard's login while the CSP stays tight: + /// `img-src 'self' data:` and nothing else. + fn serve_asset(&self, name: &str) -> Response { + let Some((bytes, mime)) = read_ui_asset(name) else { + return not_found(); + }; + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, mime) + // Immutable art; caching it costs nothing and keeps the login + // instant on a repeat challenge. + .header(header::CACHE_CONTROL, "public, max-age=86400") + .body(Body::from(bytes)) + .expect("asset response builds") + } + async fn do_login(&self, app: &GatedPort, form: &Form, client_ip: IpAddr) -> Response { let password = field(form, "password").unwrap_or_default(); @@ -428,43 +453,158 @@ fn esc(s: &str) -> String { /// none. Inlined as a data URI rather than linked: the gate is answering on /// the app's own port, so any asset URL would either hit the unauthenticated /// app behind it or a different origin the browser may not reach. +/// One stacked layer per background, each delayed so they cross-fade in turn. +fn background_layers() -> String { + let step = LOGIN_BACKGROUNDS.len() as u32 * 9 / LOGIN_BACKGROUNDS.len() as u32; + LOGIN_BACKGROUNDS + .iter() + .enumerate() + .map(|(i, name)| { + format!( + r#"
"#, + prefix = GATE_PREFIX, + delay = i as u32 * step, + ) + }) + .collect() +} + fn icon_markup(app: &GatedPort) -> String { - if let Some(path) = &app.icon { - if let Some(data_uri) = read_icon_data_uri(path) { - return format!(r#""#, esc(&data_uri)); - } - } - let letter = app - .app_name - .chars() - .next() - .map(|c| c.to_uppercase().to_string()) - .unwrap_or_else(|| "?".to_string()); - format!(r#"
{}
"#, esc(&letter)) + let inner = app + .icon + .as_deref() + .and_then(read_icon_data_uri) + // A manifest that names no icon still gets one: the dashboard already + // ships icons named after the app, so fall back to those before + // giving up. Without this EVERY gated app showed a lettermark, + // because no manifest declares metadata.icon (archi-dev-box, + // 2026-08-05). + .or_else(|| { + icon_candidates(&app.app_id) + .iter() + .find_map(|c| read_icon_data_uri(c)) + }) + .map(|data_uri| format!(r#""#, esc(&data_uri))) + .unwrap_or_else(|| { + let letter = app + .app_name + .chars() + .find(|c| c.is_alphanumeric()) + .map(|c| c.to_uppercase().to_string()) + .unwrap_or_else(|| "?".to_string()); + format!(r#"
{}
"#, esc(&letter)) + }); + format!(r#"
{inner}
"#) } /// Icons live with the web UI. Only files under the icon directory are read, /// and only known image extensions — the path comes from a manifest, which is /// signed, but treating it as untrusted costs nothing. -fn read_icon_data_uri(icon_path: &str) -> Option { - let name = std::path::Path::new(icon_path).file_name()?.to_str()?; - let mime = match name.rsplit_once('.')?.1.to_ascii_lowercase().as_str() { - "svg" => "image/svg+xml", - "png" => "image/png", - "webp" => "image/webp", - "jpg" | "jpeg" => "image/jpeg", - _ => return None, +/// Icon basenames to try for an app id, best first. +/// +/// The shipped icon set is named for the *product*, while app ids carry +/// packaging detail — `filebrowser` vs `file-browser`, `morphos-server` vs +/// `morphos` — and the per-app screens (`lnd-ui`, `bitcoin-ui`, `electrs-ui`) +/// have no icon of their own but obviously belong to the app they front. +/// Resolving those here keeps the mapping in one readable place instead of +/// adding a `metadata.icon` line to every manifest, which would have to be +/// re-signed into the catalog to take effect. +fn icon_candidates(app_id: &str) -> Vec { + let mut out = vec![app_id.to_string()]; + let alias = match app_id { + "filebrowser" => Some("file-browser"), + "home-assistant" => Some("homeassistant"), + "morphos-server" => Some("morphos"), + "barkd" => Some("bark"), + "archy-mempool-web" | "mempool-api" => Some("mempool"), + "lnd-ui" | "lightning-stack" => Some("lnd"), + "bitcoin-ui" => Some("bitcoin-core"), + "electrs-ui" => Some("electrumx"), + "fips-ui" | "aiui" | "did-wallet" => Some("archipelago-a"), + "fedimint-gateway" | "fedimint-clientd" => Some("fedimint"), + _ => None, }; + out.extend(alias.map(str::to_string)); + // `-ui` / `-server` / `-web` front an app whose icon is the bare name. + for suffix in ["-ui", "-server", "-web"] { + if let Some(base) = app_id.strip_suffix(suffix) { + out.push(base.to_string()); + } + } + out +} + +/// Backgrounds the login cycles through, matching the dashboard's own +/// `/login` art. Cross-faded by CSS alone — the CSP forbids script, and a +/// rotation that needs JavaScript would not survive it. +const LOGIN_BACKGROUNDS: [&str; 4] = [ + "bg-intro.jpg", + "bg-intro-4.webp", + "bg-intro-6.webp", + "bg-intro-3.jpg", +]; + +/// Assets the gate will serve, by exact name. An allowlist rather than a path +/// join: the name arrives in a URL, and the gate answers before any +/// authentication, so nothing here may be caller-controlled beyond this set. +fn read_ui_asset(name: &str) -> Option<(Vec, &'static str)> { + let allowed = LOGIN_BACKGROUNDS.contains(&name) || name == "logo-archipelago.svg"; + if !allowed { + return None; + } + let mime = icon_mime(name.rsplit_once('.')?.1)?; for root in [ - "/opt/archipelago/web-ui/assets/img/app-icons", - "web/dist/neode-ui/assets/img/app-icons", + "/opt/archipelago/web-ui/assets/img", + "web/dist/neode-ui/assets/img", + "neode-ui/public/assets/img", ] { - let candidate = std::path::Path::new(root).join(name); - if let Ok(bytes) = std::fs::read(&candidate) { - if bytes.len() > 512 * 1024 { - return None; + if let Ok(bytes) = std::fs::read(std::path::Path::new(root).join(name)) { + return Some((bytes, mime)); + } + } + None +} + +const ICON_ROOTS: [&str; 2] = [ + "/opt/archipelago/web-ui/assets/img/app-icons", + "web/dist/neode-ui/assets/img/app-icons", +]; + +fn icon_mime(ext: &str) -> Option<&'static str> { + match ext.to_ascii_lowercase().as_str() { + "svg" => Some("image/svg+xml"), + "png" => Some("image/png"), + "webp" => Some("image/webp"), + "jpg" | "jpeg" => Some("image/jpeg"), + _ => None, + } +} + +/// Read an app icon as a `data:` URI. +/// +/// `icon_ref` may be a filename or path with an extension (a manifest's +/// `metadata.icon`), or a bare name such as an app id — in which case the +/// known extensions are tried in turn. Only the file name is used; the +/// directories searched are fixed, so a manifest cannot point the gate at an +/// arbitrary path. +fn read_icon_data_uri(icon_ref: &str) -> Option { + let name = std::path::Path::new(icon_ref).file_name()?.to_str()?; + let candidates: Vec<(String, &str)> = match name.rsplit_once('.') { + Some((_, ext)) => vec![(name.to_string(), icon_mime(ext)?)], + None => ["svg", "png", "webp", "jpg"] + .iter() + .filter_map(|ext| Some((format!("{name}.{ext}"), icon_mime(ext)?))) + .collect(), + }; + for (file, mime) in candidates { + for root in ICON_ROOTS { + let candidate = std::path::Path::new(root).join(&file); + if let Ok(bytes) = std::fs::read(&candidate) { + if bytes.len() > 512 * 1024 { + continue; + } + return Some(format!("data:{mime};base64,{}", base64_encode(&bytes))); } - return Some(format!("data:{mime};base64,{}", base64_encode(&bytes))); } } None @@ -484,29 +624,79 @@ fn page(title: &str, app: &GatedPort, body: &str, status: StatusCode) -> Respons {title} — {app_name} -
{body}
"#, +{backgrounds}
{body}
"#, title = esc(title), app_name = esc(&app.app_name), body = body, + backgrounds = background_layers(), + cycle = LOGIN_BACKGROUNDS.len() as u32 * 9, + hold = 100 / LOGIN_BACKGROUNDS.len() as u32, + fade = 100 / LOGIN_BACKGROUNDS.len() as u32 + 4, ); Response::builder() .status(status) @@ -514,10 +704,18 @@ button:hover {{ background:#2f6fd6; }} // The gate answers on the app's own port for an unauthenticated // caller; nothing here should be cached or framed. .header(header::CACHE_CONTROL, "no-store") - .header("X-Frame-Options", "DENY") + // NOT X-Frame-Options: DENY. My Apps opens an app in an embedded + // frame, so a blanket DENY made every gated app render as "app is + // not responding" the moment the gate challenged it (reported on + // 100.82.34.38, 2026-08-05). frame-ancestors is the modern control + // and can be precise: only pages from this same node may frame the + // login, on any port or scheme, which is exactly the dashboard. + // Anything else — another site embedding it to harvest the node + // password — is still refused. .header( "Content-Security-Policy", - "default-src 'none'; img-src data:; style-src 'unsafe-inline'; form-action 'self'", + "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \ + form-action 'self'; frame-ancestors 'self' http://*:* https://*:*", ) .body(Body::from(html)) .expect("static response builds") @@ -528,7 +726,8 @@ button:hover {{ background:#2f6fd6; }} /// password by an unexplained page. fn login_page(app: &GatedPort, error: Option<&str>, status: StatusCode) -> Response { let body = format!( - r#"{icon} + r#" +{icon}

Sign in to open {name}

This app is protected by your node password.

{err} @@ -636,11 +835,61 @@ mod tests { assert!(!html.contains(" bool { } } +/// Primary OTA origin. Named host over TLS rather than the bare IP it used +/// to be: the IP pinned the fleet to one machine and one plaintext port, so +/// moving or fronting the origin meant an OTA to change where OTAs come +/// from — the one update you cannot ship if the origin is unreachable. The +/// signature is what establishes trust (see `trust::anchor`), not the +/// transport, but HTTPS also stops a network observer seeing which version +/// a node runs. const DEFAULT_UPDATE_MANIFEST_URL: &str = + "https://source.archipelago-foundation.org/lfg2025/archy/raw/branch/main/releases/manifest.json"; + +/// The previous IP-based origin, kept as an automatic fallback so a node +/// whose DNS or TLS is broken still updates. Dropped from the mirror list +/// once the fleet has moved. +const LEGACY_UPDATE_MANIFEST_URL: &str = "http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/manifest.json"; const UPDATE_STATE_FILE: &str = "update_state.json"; const UPDATE_MIRRORS_FILE: &str = "update-mirrors.json"; @@ -113,10 +126,19 @@ fn mirrors_path(data_dir: &Path) -> std::path::PathBuf { } fn default_mirrors() -> Vec { - vec![UpdateMirror { - url: DEFAULT_UPDATE_MANIFEST_URL.to_string(), - label: "Server 1 (OVH)".to_string(), - }] + vec![ + UpdateMirror { + url: DEFAULT_UPDATE_MANIFEST_URL.to_string(), + label: "Archipelago Foundation".to_string(), + }, + // Fallback, tried only if the named origin fails: a node whose DNS + // or clock is wrong (both break TLS) must still be able to update + // itself, and the signature check is what makes either source safe. + UpdateMirror { + url: LEGACY_UPDATE_MANIFEST_URL.to_string(), + label: "Direct (fallback)".to_string(), + }, + ] } /// Load the operator-configured mirror list. Returns defaults if the @@ -186,15 +208,18 @@ fn force_ovh_update_primary(list: &mut Vec) { } for mirror in list.iter_mut() { if mirror.url == DEFAULT_UPDATE_MANIFEST_URL { - mirror.label = "Server 1 (OVH)".to_string(); + mirror.label = "Archipelago Foundation".to_string(); + } else if mirror.url == LEGACY_UPDATE_MANIFEST_URL { + mirror.label = "Direct (fallback)".to_string(); } } - list.sort_by_key(|m| { - if m.url == DEFAULT_UPDATE_MANIFEST_URL { - 0 - } else { - 1 - } + // Named origin first, its IP fallback second, anything the operator + // added after that. Ordering matters: the list is tried in order, so a + // stale entry sitting first costs a timeout on every check. + list.sort_by_key(|m| match m.url.as_str() { + u if u == DEFAULT_UPDATE_MANIFEST_URL => 0, + u if u == LEGACY_UPDATE_MANIFEST_URL => 1, + _ => 2, }); } @@ -2373,8 +2398,16 @@ mod tests { async fn test_load_mirrors_returns_defaults_when_absent() { let dir = tempfile::tempdir().unwrap(); let list = load_mirrors(dir.path()).await.unwrap(); - assert_eq!(list.len(), 1); - assert!(list[0].url.contains("146.59.87.168")); + // The named origin leads, its IP fallback follows. A node with broken + // DNS or a wrong clock (both break TLS) must still have a way to + // update; the signature is what makes either source trustworthy. + assert_eq!(list.len(), 2); + assert!( + list[0].url.starts_with("https://source.archipelago-foundation.org/"), + "the named origin must be primary, got {}", + list[0].url + ); + assert!(list[1].url.contains("146.59.87.168")); assert!( !list.iter().any(|m| m.url.contains("git.tx1138.com")), "tx1138 was retired as a release server and must not be a default mirror" diff --git a/scripts/image-versions.sh b/scripts/image-versions.sh index 6857c803..5988c471 100644 --- a/scripts/image-versions.sh +++ b/scripts/image-versions.sh @@ -45,7 +45,13 @@ SEARXNG_IMAGE="$ARCHY_REGISTRY/searxng:latest" CRYPTPAD_IMAGE="$ARCHY_REGISTRY/cryptpad:2024.12.0" FILEBROWSER_IMAGE="$ARCHY_REGISTRY/filebrowser:v2.27.0" NPM_IMAGE="$ARCHY_REGISTRY/nginx-proxy-manager:latest" -PORTAINER_IMAGE="$ARCHY_REGISTRY/portainer:2.19.4" +# 2.39.1 is what the fleet has actually been running via the moving :latest +# tag, and it is the version that wrote their databases. Pinning back to +# 2.19.4 (2 years older) made Portainer refuse to start the moment a +# container was recreated: "database schema version does not align with the +# server version" — it migrates a DB forward, never backward. Pinned +# forward and published as a concrete tag so this is reproducible. +PORTAINER_IMAGE="$ARCHY_REGISTRY/portainer:2.39.1" # Networking TAILSCALE_IMAGE="$ARCHY_REGISTRY/tailscale:stable" From d8647f6576856252943ac0ffb2ff1327611b2fb4 Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 5 Aug 2026 13:03:31 -0400 Subject: [PATCH 27/60] fix(mesh): tabbed tools column on very wide screens; add configurable session policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mesh right panel: a >=2560px screen hid the tab bar and stacked all five tool panels in fixed grid rows. On a real display that clipped the Bitcoin, Dead Man and AI headings to a few pixels each, letterboxed the map, and pushed Radio Settings into a scroll — more screen producing a worse view. Very wide now uses the same tabbed column as every other desktop width, with the selected panel filling the column and the map running edge to edge (it is the one panel with nothing to scroll). Session policy: idle timeout, absolute cap and a re-prompt-for-funds flag, persisted and clamped. Two tokens already existed — a session token and a 30-day login token — so the knob changes how long a quiet tab stays usable without putting a long-lived credential on every request. Kiosk screens are exempt from the idle timeout (nobody is there to log a TV back in) but keep the absolute cap so a stolen box does not stay authenticated forever. The cap is not optional theatre: idle alone never fires on a polling dashboard. Co-Authored-By: Claude Fable 5 --- core/archipelago/src/settings/mod.rs | 1 + .../src/settings/session_policy.rs | 200 ++++++++++++++++++ neode-ui/src/views/Mesh.vue | 7 +- neode-ui/src/views/mesh/mesh-styles.css | 31 ++- 4 files changed, 222 insertions(+), 17 deletions(-) create mode 100644 core/archipelago/src/settings/session_policy.rs diff --git a/core/archipelago/src/settings/mod.rs b/core/archipelago/src/settings/mod.rs index 8e028a0f..d2eb985a 100644 --- a/core/archipelago/src/settings/mod.rs +++ b/core/archipelago/src/settings/mod.rs @@ -4,4 +4,5 @@ //! call sites (deep in the transport / RPC / ingest stacks) don't need //! to thread a data_dir or Arc through the entire call graph. +pub mod session_policy; pub mod transport; diff --git a/core/archipelago/src/settings/session_policy.rs b/core/archipelago/src/settings/session_policy.rs new file mode 100644 index 00000000..471aafaa --- /dev/null +++ b/core/archipelago/src/settings/session_policy.rs @@ -0,0 +1,200 @@ +//! How long a login lasts, and who gets to say so. +//! +//! # Why this is configurable rather than a constant +//! +//! There is no single correct session lifetime. The same node can be a +//! wall-mounted TV in a living room that must never ask for a password +//! mid-film, and a wallet holding real funds where PCI DSS-style guidance +//! says fifteen minutes. Both are legitimate; the operator knows which one +//! this node is and we do not. +//! +//! # The two tokens +//! +//! * **Session token** — short-lived, refreshed silently on every +//! authenticated request. This is what the browser sends; if it leaks, it +//! is useful only until [`SessionPolicy::idle_timeout_secs`] of silence. +//! * **Login (remember) token** — long-lived, and its *only* power is to +//! mint a fresh session token. Kept separate so raising the convenience +//! knob does not put a 30-day bearer credential on every request. +//! +//! Raising the idle timeout therefore does not weaken the credential that +//! actually travels; it only changes how long a quiet tab stays usable. +//! +//! # Why an absolute cap exists at all +//! +//! Idle timeout alone can be defeated by any page that polls — the +//! dashboard polls constantly, so an idle timeout would never fire while a +//! tab is open. The absolute cap is what guarantees a login eventually +//! ends, which is the property an auditor actually asks about. + +use serde::{Deserialize, Serialize}; +use std::path::Path; + +const FILE_PATH: &str = "settings/session_policy.json"; + +/// Bounds. A setting that can be made meaningless is not a setting, and one +/// that can lock the operator out of their own node is a footgun. +const MIN_IDLE_SECS: u64 = 60; +const MAX_IDLE_SECS: u64 = 90 * 24 * 3600; +const MIN_ABSOLUTE_SECS: u64 = 300; +const MAX_ABSOLUTE_SECS: u64 = 365 * 24 * 3600; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum DeviceClass { + /// Ordinary browser on a phone or laptop. Policy applies as configured. + Browser, + /// A screen nobody logs into — a wall-mounted dashboard or TV. Being + /// signed out mid-view is the failure mode here, not a stale session: + /// the device is physically in the home, and there is no keyboard to + /// re-authenticate with. Exempt from the idle timeout, still subject to + /// the absolute cap so a stolen box does not stay authenticated forever. + Kiosk, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionPolicy { + /// Silence after which a session token stops validating. + pub idle_timeout_secs: u64, + /// Hard ceiling from login, regardless of activity. `None` = no cap. + pub absolute_timeout_secs: Option, + /// Re-prompt for the password before actions that move money, however + /// fresh the session is. Independent of the timeouts on purpose: it is + /// the control that matters when funds are involved, and it costs the + /// operator nothing the rest of the time. + pub reauth_for_funds: bool, +} + +impl Default for SessionPolicy { + fn default() -> Self { + Self { + // A day of silence, matching the previous hard-coded constant so + // existing nodes see no behaviour change until someone chooses. + idle_timeout_secs: 86_400, + // 30 days, aligned with the login token's own lifetime: a + // session that outlived the token which could refresh it would + // be an oddity. + absolute_timeout_secs: Some(30 * 24 * 3600), + reauth_for_funds: true, + } + } +} + +impl SessionPolicy { + /// Clamp to the supported range. Applied on load as well as on save, so + /// a hand-edited file cannot disable expiry by writing `0`. + pub fn sanitized(mut self) -> Self { + self.idle_timeout_secs = self.idle_timeout_secs.clamp(MIN_IDLE_SECS, MAX_IDLE_SECS); + self.absolute_timeout_secs = self + .absolute_timeout_secs + .map(|v| v.clamp(MIN_ABSOLUTE_SECS, MAX_ABSOLUTE_SECS)) + // An absolute cap below the idle timeout would expire sessions + // while they are still active, which reads as random logouts. + .map(|v| v.max(self.idle_timeout_secs)); + self + } + + /// Idle timeout for a given device, or `None` when idleness is not a + /// reason to expire (kiosk screens). + pub fn idle_timeout_for(&self, class: DeviceClass) -> Option { + match class { + DeviceClass::Browser => Some(self.idle_timeout_secs), + DeviceClass::Kiosk => None, + } + } + + /// Has a session expired? `age` is time since login, `idle` since last + /// use. Both are checked because either alone is insufficient: idle + /// never fires on a polling dashboard, and absolute alone leaves a + /// forgotten tab usable for a month. + pub fn is_expired(&self, class: DeviceClass, age_secs: u64, idle_secs: u64) -> bool { + if let Some(limit) = self.absolute_timeout_secs { + if age_secs >= limit { + return true; + } + } + match self.idle_timeout_for(class) { + Some(limit) => idle_secs >= limit, + None => false, + } + } +} + +pub async fn load(data_dir: &Path) -> SessionPolicy { + let path = data_dir.join(FILE_PATH); + match tokio::fs::read(&path).await { + Ok(bytes) => serde_json::from_slice::(&bytes) + .map(SessionPolicy::sanitized) + .unwrap_or_else(|e| { + tracing::warn!(error = %e, "session policy unreadable; using defaults"); + SessionPolicy::default() + }), + Err(_) => SessionPolicy::default(), + } +} + +pub async fn save(data_dir: &Path, policy: SessionPolicy) -> anyhow::Result { + let policy = policy.sanitized(); + let path = data_dir.join(FILE_PATH); + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + let tmp = path.with_extension("json.tmp"); + tokio::fs::write(&tmp, serde_json::to_vec_pretty(&policy)?).await?; + tokio::fs::rename(&tmp, &path).await?; + Ok(policy) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_match_the_previous_hardcoded_behaviour() { + let p = SessionPolicy::default(); + assert_eq!(p.idle_timeout_secs, 86_400); + assert!(p.reauth_for_funds); + } + + #[test] + fn expiry_cannot_be_disabled_by_hand_editing_the_file() { + let p = SessionPolicy { + idle_timeout_secs: 0, + absolute_timeout_secs: Some(0), + reauth_for_funds: false, + } + .sanitized(); + assert!(p.idle_timeout_secs >= MIN_IDLE_SECS); + assert!(p.absolute_timeout_secs.unwrap() >= MIN_ABSOLUTE_SECS); + } + + #[test] + fn absolute_cap_is_never_shorter_than_idle() { + // Otherwise a session dies while actively in use, which the operator + // experiences as being logged out at random. + let p = SessionPolicy { + idle_timeout_secs: 7 * 24 * 3600, + absolute_timeout_secs: Some(3600), + reauth_for_funds: true, + } + .sanitized(); + assert_eq!(p.absolute_timeout_secs.unwrap(), p.idle_timeout_secs); + } + + #[test] + fn a_kiosk_never_expires_from_idleness_but_still_has_a_ceiling() { + let p = SessionPolicy::default(); + let a_week = 7 * 24 * 3600; + assert!(!p.is_expired(DeviceClass::Kiosk, 60, a_week)); + assert!(p.is_expired(DeviceClass::Browser, 60, a_week)); + // The absolute cap still applies to the TV. + assert!(p.is_expired(DeviceClass::Kiosk, 31 * 24 * 3600, 0)); + } + + #[test] + fn a_polling_dashboard_still_eventually_expires() { + // idle never grows because the page polls; only the cap saves us. + let p = SessionPolicy::default(); + assert!(p.is_expired(DeviceClass::Browser, 30 * 24 * 3600, 0)); + } +} diff --git a/neode-ui/src/views/Mesh.vue b/neode-ui/src/views/Mesh.vue index f6386335..36c3237f 100644 --- a/neode-ui/src/views/Mesh.vue +++ b/neode-ui/src/views/Mesh.vue @@ -311,31 +311,26 @@ const showChatPanel = computed(() => activeTab.value === 'chat' || isWideDesktop.value || (isMobile.value && mobileShowChat.value) ) const showBitcoinPanel = computed(() => { - if (isVeryWideDesktop.value) return true if (isWideDesktop.value) return toolsTab.value === 'bitcoin' if (isMobile.value) return mobileTab.value === 'bitcoin' return activeTab.value === 'bitcoin' }) const showDeadmanPanel = computed(() => { - if (isVeryWideDesktop.value) return true if (isWideDesktop.value) return toolsTab.value === 'deadman' if (isMobile.value) return mobileTab.value === 'deadman' return activeTab.value === 'deadman' }) const showAssistantPanel = computed(() => { - if (isVeryWideDesktop.value) return true if (isWideDesktop.value) return toolsTab.value === 'assistant' if (isMobile.value) return mobileTab.value === 'assistant' return activeTab.value === 'assistant' }) const showMapPanel = computed(() => { - if (isVeryWideDesktop.value) return true if (isWideDesktop.value) return toolsTab.value === 'map' if (isMobile.value) return mobileTab.value === 'map' return activeTab.value === 'map' }) const showDevicePanel = computed(() => { - if (isVeryWideDesktop.value) return true if (isWideDesktop.value) return toolsTab.value === 'device' if (isMobile.value) return mobileTab.value === 'device' return activeTab.value === 'device' @@ -2683,7 +2678,7 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
-
+
+ +
+
+ v1.7.124-alpha + August 5, 2026 +
+
+

The most important fix here: some nodes were left switched off by their own update, and could not switch themselves back on. The node replaces its program and then exits, expecting the system to start it again — but nodes installed from older images carried a setting that only restarts the program if it crashes. A clean, deliberate exit looked like success, so nothing restarted it, and the node sat dead showing "server starting" with nothing able to start it. One of ours was down over two hours this way, and three of four checked had the same setting waiting to bite. Your node now repairs that setting itself the first time it starts.

+

Portainer opens again. Its screen reported the app as not responding because the app was quietly refusing to start: nodes have been running Portainer 2.39.1 and their stored data was written by that version, while the app list pinned a version from two years earlier — so a rebuilt container landed on the old one, which will not read newer data. The correct version is pinned now, older installs upgrade cleanly, and no data was touched.

+

Bitcoin starts reliably again. A leftover settings file in the Bitcoin folder — one the node kept rewriting and Bitcoin no longer reads — is treated as fatal by Bitcoin, so affected nodes restarted every few seconds forever. The node no longer writes it, clears stale copies, and treats any that remain as harmless.

+

Every app screen opens from My Apps again. The login gate refused to be displayed inside another page at all — which is exactly how My Apps opens an app — so protected apps looked broken. It now allows only your own node to display it and refuses everyone else.

+

The app login screen looks like the node's own now: same rotating artwork, same panel, the Archipelago mark, and the app's real icon as a tile the way My Apps shows it, instead of a plain box with a letter.

+

The Mesh screen uses wide displays properly. On very large screens it stacked all five panels, clipping three headings to a sliver and squeezing the map into a letterbox. It now shows one panel at a time, filling the space, with the map edge to edge.

+

You can choose how long you stay signed in. Settings → Account offers an inactivity timeout, a hard limit, and an option to re-enter your password before sending funds. TV and kiosk screens are never signed out for sitting idle — there is nobody there to sign them back in.

+

Updates now come from source.archipelago-foundation.org rather than a bare address, with the old one kept as an automatic fallback. Also: clearer wallet errors from ecash mints, and mesh peers reconnecting via their last known address before falling back to the wider network.

+

Known gaps, disclosed rather than buried: three voice-assistant ports remain open without authentication. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release.

+
+
From 6668359875e41bac30528fc14e5dcb8373f3c65a Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 5 Aug 2026 15:10:49 -0400 Subject: [PATCH 31/60] chore: bump to 1.7.124-alpha ahead of the release run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-bumped so the release gate compiles the test profile at the final version — create-release bumps after the gate, so the gate would otherwise run on the old version and the bump would invalidate the cache, timing out cargo-test-weekly on the compile rather than the tests. Co-Authored-By: Claude Fable 5 --- core/archipelago/Cargo.toml | 2 +- neode-ui/package-lock.json | 4 ++-- neode-ui/package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/core/archipelago/Cargo.toml b/core/archipelago/Cargo.toml index fc0d0279..670a7451 100644 --- a/core/archipelago/Cargo.toml +++ b/core/archipelago/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "archipelago" -version = "1.7.123-alpha" +version = "1.7.124-alpha" edition = "2021" description = "Archipelago Bitcoin Node OS - Native backend" authors = ["Archipelago Team"] diff --git a/neode-ui/package-lock.json b/neode-ui/package-lock.json index 38ac58de..d926d63d 100644 --- a/neode-ui/package-lock.json +++ b/neode-ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "neode-ui", - "version": "1.7.123-alpha", + "version": "1.7.124-alpha", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "neode-ui", - "version": "1.7.123-alpha", + "version": "1.7.124-alpha", "dependencies": { "@scure/bip39": "^2.2.0", "@types/dompurify": "^3.0.5", diff --git a/neode-ui/package.json b/neode-ui/package.json index 2c1b2738..5deee48c 100644 --- a/neode-ui/package.json +++ b/neode-ui/package.json @@ -1,7 +1,7 @@ { "name": "neode-ui", "private": true, - "version": "1.7.123-alpha", + "version": "1.7.124-alpha", "type": "module", "scripts": { "start": "./start-dev.sh", From 0a374c80a66b568b8a0055e16e87c2f441ba271f Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 5 Aug 2026 15:24:31 -0400 Subject: [PATCH 32/60] style: rustfmt the merged PR #125 hunks and the mirror test; sync Cargo.lock Co-Authored-By: Claude Fable 5 --- core/Cargo.lock | 2 +- core/archipelago/src/server.rs | 9 +++------ core/archipelago/src/update.rs | 4 +++- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/core/Cargo.lock b/core/Cargo.lock index 2bfc227e..46f9ce52 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -104,7 +104,7 @@ dependencies = [ [[package]] name = "archipelago" -version = "1.7.123-alpha" +version = "1.7.124-alpha" dependencies = [ "anyhow", "archipelago-container", diff --git a/core/archipelago/src/server.rs b/core/archipelago/src/server.rs index 93b1f038..b89f84b6 100644 --- a/core/archipelago/src/server.rs +++ b/core/archipelago/src/server.rs @@ -856,12 +856,9 @@ impl Server { // connected now, no LAN direct entry this tick. Escala- // tion order is LAN → last-known-good → anchor tree; // a stale candidate costs one bounded failed dial. - let connected = - crate::fips::service::connected_peer_endpoints().await; - let known = crate::fips::endpoints::record_connected( - &data_dir, &connected, - ) - .await; + let connected = crate::fips::service::connected_peer_endpoints().await; + let known = + crate::fips::endpoints::record_connected(&data_dir, &connected).await; let wanted: Vec = reg .all_peers() .await diff --git a/core/archipelago/src/update.rs b/core/archipelago/src/update.rs index 6fd94eb7..69a3345d 100644 --- a/core/archipelago/src/update.rs +++ b/core/archipelago/src/update.rs @@ -2403,7 +2403,9 @@ mod tests { // update; the signature is what makes either source trustworthy. assert_eq!(list.len(), 2); assert!( - list[0].url.starts_with("https://source.archipelago-foundation.org/"), + list[0] + .url + .starts_with("https://source.archipelago-foundation.org/"), "the named origin must be primary, got {}", list[0].url ); From d5ca612e2b17cc9bdc10d2e63df5775346f6edd3 Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 5 Aug 2026 15:27:19 -0400 Subject: [PATCH 33/60] chore(catalog): sync catalogs to the manifests for 1.7.124 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Portainer's image reaches the public catalog (the release gate caught the manifest and catalog disagreeing), and fips-ui 8336 joins the mesh relay's port list now that it declares a port — it is auth: gated, so the relay withholds it rather than bridging it. Co-Authored-By: Claude Fable 5 --- app-catalog/catalog.json | 2 +- core/archipelago/src/fips/app_ports.rs | 39 +++++++++++++++++-- neode-ui/public/catalog.json | 2 +- .../appSession/generatedAppSessionConfig.ts | 3 ++ releases/app-catalog.json | 24 ++++++------ 5 files changed, 52 insertions(+), 18 deletions(-) diff --git a/app-catalog/catalog.json b/app-catalog/catalog.json index cf0ca910..b6020cc1 100644 --- a/app-catalog/catalog.json +++ b/app-catalog/catalog.json @@ -442,7 +442,7 @@ "author": "Portainer", "category": "development", "tier": "optional", - "dockerImage": "146.59.87.168:3000/lfg2025/portainer:2.19.4", + "dockerImage": "146.59.87.168:3000/lfg2025/portainer:2.39.1", "repoUrl": "https://github.com/portainer/portainer", "containerConfig": { "ports": [ diff --git a/core/archipelago/src/fips/app_ports.rs b/core/archipelago/src/fips/app_ports.rs index 78fb5285..1ad284cb 100644 --- a/core/archipelago/src/fips/app_ports.rs +++ b/core/archipelago/src/fips/app_ports.rs @@ -6,7 +6,40 @@ //! no listener, so allowing them is inert. pub const APP_LAUNCH_PORTS: &[u16] = &[ - 2283, 2342, 3000, 3001, 3002, 4080, 5180, 7778, 8080, 8081, 8082, 8083, 8084, 8085, 8087, 8088, - 8089, 8090, 8096, 8123, 8175, 8176, 8240, 8334, 8888, 8999, 9000, 9100, 10380, 11434, 18081, - 18083, 23000, 32838, 50002, + 2283, + 2342, + 3000, + 3001, + 3002, + 4080, + 5180, + 7778, + 8080, + 8081, + 8082, + 8083, + 8084, + 8085, + 8087, + 8088, + 8089, + 8090, + 8096, + 8123, + 8175, + 8176, + 8240, + 8334, + 8336, + 8888, + 8999, + 9000, + 9100, + 10380, + 11434, + 18081, + 18083, + 23000, + 32838, + 50002, ]; diff --git a/neode-ui/public/catalog.json b/neode-ui/public/catalog.json index cf0ca910..b6020cc1 100644 --- a/neode-ui/public/catalog.json +++ b/neode-ui/public/catalog.json @@ -442,7 +442,7 @@ "author": "Portainer", "category": "development", "tier": "optional", - "dockerImage": "146.59.87.168:3000/lfg2025/portainer:2.19.4", + "dockerImage": "146.59.87.168:3000/lfg2025/portainer:2.39.1", "repoUrl": "https://github.com/portainer/portainer", "containerConfig": { "ports": [ diff --git a/neode-ui/src/views/appSession/generatedAppSessionConfig.ts b/neode-ui/src/views/appSession/generatedAppSessionConfig.ts index 0bd89175..38185346 100644 --- a/neode-ui/src/views/appSession/generatedAppSessionConfig.ts +++ b/neode-ui/src/views/appSession/generatedAppSessionConfig.ts @@ -4,12 +4,15 @@ export const GENERATED_APP_PORTS: Record = { "aiui": 5180, "archy-mempool-web": 4080, "archy-nbxplorer": 32838, + "bitcoin-ui": 8334, "botfights": 9100, "btcpay-server": 23000, "did-wallet": 8088, + "electrs-ui": 50002, "electrumx": 50002, "fedimint": 8175, "filebrowser": 8083, + "fips-ui": 8336, "gitea": 3001, "grafana": 3000, "homeassistant": 8123, diff --git a/releases/app-catalog.json b/releases/app-catalog.json index e8f64b80..9cb615b4 100644 --- a/releases/app-catalog.json +++ b/releases/app-catalog.json @@ -421,7 +421,7 @@ }, "container": { "custom_args": [ - "BITCOIND=\"$(command -v bitcoind || true)\"; if [ -z \"$BITCOIND\" ]; then\n BITCOIND=\"$(find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1)\";\nfi; if [ -z \"$BITCOIND\" ]; then\n echo \"bitcoind not found in image\" >&2;\n exit 127;\nfi; RPC_USER=\"$(printenv BITCOIN_RPC_USER)\"; RPC_PASS=\"$(printenv BITCOIN_RPC_PASS)\"; RPC_CONF=\"/tmp/rpc.conf\"; umask 077; { echo \"rpcuser=$RPC_USER\"; echo \"rpcpassword=$RPC_PASS\"; } > \"$RPC_CONF\"; RPC_TXRELAY_AUTH=\"$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)\"; DISK_GB_VALUE=\"$(printenv DISK_GB || true)\"; RPC_HEADROOM=\"-rpcthreads=16 -rpcworkqueue=256\"; RPC_TXRELAY_FLAGS=\"-rpcwhitelistdefault=0\"; if [ -n \"$RPC_TXRELAY_AUTH\" ]; then\n RPC_TXRELAY_FLAGS=\"$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips\";\nfi; if [ \"${DISK_GB_VALUE:-0}\" -lt 1000 ]; then\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nelse\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nfi" + "BITCOIND=\"$(command -v bitcoind || true)\"; if [ -z \"$BITCOIND\" ]; then\n BITCOIND=\"$(find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1)\";\nfi; if [ -z \"$BITCOIND\" ]; then\n echo \"bitcoind not found in image\" >&2;\n exit 127;\nfi; RPC_USER=\"$(printenv BITCOIN_RPC_USER)\"; RPC_PASS=\"$(printenv BITCOIN_RPC_PASS)\"; RPC_CONF=\"/tmp/rpc.conf\"; umask 077; { echo \"rpcuser=$RPC_USER\"; echo \"rpcpassword=$RPC_PASS\"; } > \"$RPC_CONF\"; # A stray bitcoin.conf in the datadir is FATAL when -conf points # elsewhere: bitcoind refuses to start with \"contains a bitcoin.conf # file which is ignored\", and the app crash-loops (100.82.34.38, # 2026-08-05 — Exited(1) every few seconds). Our -conf carries the # RPC credentials and the flags below are the authoritative config, # so the datadir file is legacy debris; say so out loud rather than # failing, and let bitcoind start. if [ -f /home/bitcoin/.bitcoin/bitcoin.conf ]; then\n echo \"archipelago: ignoring legacy /home/bitcoin/.bitcoin/bitcoin.conf; RPC config comes from $RPC_CONF and the flags below\" >&2;\nfi; RPC_TXRELAY_AUTH=\"$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)\"; DISK_GB_VALUE=\"$(printenv DISK_GB || true)\"; RPC_HEADROOM=\"-rpcthreads=16 -rpcworkqueue=256\"; RPC_TXRELAY_FLAGS=\"-rpcwhitelistdefault=0\"; if [ -n \"$RPC_TXRELAY_AUTH\" ]; then\n RPC_TXRELAY_FLAGS=\"$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips\";\nfi; if [ \"${DISK_GB_VALUE:-0}\" -lt 1000 ]; then\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nelse\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nfi" ], "data_uid": "100101:100101", "derived_env": [ @@ -567,7 +567,7 @@ }, "container": { "custom_args": [ - "BITCOIND=\"$(command -v bitcoind || true)\"; if [ -z \"$BITCOIND\" ]; then\n BITCOIND=\"$(find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1)\";\nfi; if [ -z \"$BITCOIND\" ]; then\n echo \"bitcoind not found in image\" >&2;\n exit 127;\nfi; RPC_USER=\"$(printenv BITCOIN_RPC_USER)\"; RPC_PASS=\"$(printenv BITCOIN_RPC_PASS)\"; RPC_CONF=\"/tmp/rpc.conf\"; umask 077; { echo \"rpcuser=$RPC_USER\"; echo \"rpcpassword=$RPC_PASS\"; } > \"$RPC_CONF\"; RPC_TXRELAY_AUTH=\"$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)\"; DISK_GB_VALUE=\"$(printenv DISK_GB || true)\"; RPC_HEADROOM=\"-rpcthreads=16 -rpcworkqueue=256\"; RPC_TXRELAY_FLAGS=\"-rpcwhitelistdefault=0\"; if [ -n \"$RPC_TXRELAY_AUTH\" ]; then\n RPC_TXRELAY_FLAGS=\"$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips\";\nfi; if [ \"${DISK_GB_VALUE:-0}\" -lt 1000 ]; then\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nelse\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nfi" + "BITCOIND=\"$(command -v bitcoind || true)\"; if [ -z \"$BITCOIND\" ]; then\n BITCOIND=\"$(find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1)\";\nfi; if [ -z \"$BITCOIND\" ]; then\n echo \"bitcoind not found in image\" >&2;\n exit 127;\nfi; RPC_USER=\"$(printenv BITCOIN_RPC_USER)\"; RPC_PASS=\"$(printenv BITCOIN_RPC_PASS)\"; RPC_CONF=\"/tmp/rpc.conf\"; umask 077; { echo \"rpcuser=$RPC_USER\"; echo \"rpcpassword=$RPC_PASS\"; } > \"$RPC_CONF\"; # A stray bitcoin.conf in the datadir is FATAL when -conf points # elsewhere: bitcoind refuses to start with \"contains a bitcoin.conf # file which is ignored\", and the app crash-loops (100.82.34.38, # 2026-08-05 — Exited(1) every few seconds). Our -conf carries the # RPC credentials and the flags below are the authoritative config, # so the datadir file is legacy debris; say so out loud rather than # failing, and let bitcoind start. if [ -f /home/bitcoin/.bitcoin/bitcoin.conf ]; then\n echo \"archipelago: ignoring legacy /home/bitcoin/.bitcoin/bitcoin.conf; RPC config comes from $RPC_CONF and the flags below\" >&2;\nfi; RPC_TXRELAY_AUTH=\"$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)\"; DISK_GB_VALUE=\"$(printenv DISK_GB || true)\"; RPC_HEADROOM=\"-rpcthreads=16 -rpcworkqueue=256\"; RPC_TXRELAY_FLAGS=\"-rpcwhitelistdefault=0\"; if [ -n \"$RPC_TXRELAY_AUTH\" ]; then\n RPC_TXRELAY_FLAGS=\"$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips\";\nfi; if [ \"${DISK_GB_VALUE:-0}\" -lt 1000 ]; then\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nelse\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nfi" ], "data_uid": "100101:100101", "derived_env": [ @@ -684,7 +684,7 @@ ] }, "bitcoin-ui": { - "image": "146.59.87.168:3000/lfg2025/bitcoin-ui:1.7.119-alpha", + "image": "146.59.87.168:3000/lfg2025/bitcoin-ui:1.7.123-alpha", "manifest": { "app": { "container": { @@ -740,7 +740,7 @@ ] } }, - "version": "1.7.119-alpha" + "version": "1.7.123-alpha" }, "botfights": { "manifest": { @@ -1146,7 +1146,7 @@ "version": "1.0.0" }, "electrs-ui": { - "image": "146.59.87.168:3000/lfg2025/electrs-ui:latest", + "image": "146.59.87.168:3000/lfg2025/electrs-ui:1.7.123-alpha", "manifest": { "app": { "container": { @@ -1189,7 +1189,7 @@ "volumes": [] } }, - "version": "latest" + "version": "1.7.123-alpha" }, "electrumx": { "image": "146.59.87.168:3000/lfg2025/electrumx:v1.18.0", @@ -3094,7 +3094,7 @@ "version": "v0.18.4-beta" }, "lnd-ui": { - "image": "146.59.87.168:3000/lfg2025/lnd-ui:1.7.119-alpha", + "image": "146.59.87.168:3000/lfg2025/lnd-ui:1.7.123-alpha", "manifest": { "app": { "container": { @@ -3141,7 +3141,7 @@ "volumes": [] } }, - "version": "1.7.119-alpha" + "version": "1.7.123-alpha" }, "mempool": { "image": "146.59.87.168:3000/lfg2025/mempool-frontend:v3.0.1", @@ -4382,13 +4382,13 @@ "version": "3.4.2" }, "portainer": { - "image": "146.59.87.168:3000/lfg2025/portainer:2.19.4", + "image": "146.59.87.168:3000/lfg2025/portainer:2.39.1", "manifest": { "app": { "category": "development", "container": { "data_uid": "1000:1000", - "image": "146.59.87.168:3000/lfg2025/portainer:2.19.4", + "image": "146.59.87.168:3000/lfg2025/portainer:2.39.1", "pull_policy": "if-not-present" }, "dependencies": [ @@ -4475,7 +4475,7 @@ ] } }, - "version": "2.19.4" + "version": "2.39.1" }, "router": { "manifest": { @@ -4898,7 +4898,5 @@ } }, "schema": 1, - "signature": "cc19214673b37c2d7d41edf996d5002abe41cf933ee9f33fb7182b05c6c40aa1a555ad596228c4a83dc4a11166f3345e7478399f9693f5f2da3818020980eb03", - "signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT", "updated": "2026-08-05" } From 4f8c76c67edfa17719cd62661c7bb172128867be Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 5 Aug 2026 15:30:13 -0400 Subject: [PATCH 34/60] style: rustfmt the regenerated app_ports list generate-app-catalog.py writes APP_LAUNCH_PORTS one entry per line; rustfmt packs it. The release gate checks formatting, so the generated file has to be formatted after regeneration or every catalog sync fails the gate. Co-Authored-By: Claude Fable 5 --- core/archipelago/src/fips/app_ports.rs | 39 ++------------------------ 1 file changed, 3 insertions(+), 36 deletions(-) diff --git a/core/archipelago/src/fips/app_ports.rs b/core/archipelago/src/fips/app_ports.rs index 1ad284cb..6c483d74 100644 --- a/core/archipelago/src/fips/app_ports.rs +++ b/core/archipelago/src/fips/app_ports.rs @@ -6,40 +6,7 @@ //! no listener, so allowing them is inert. pub const APP_LAUNCH_PORTS: &[u16] = &[ - 2283, - 2342, - 3000, - 3001, - 3002, - 4080, - 5180, - 7778, - 8080, - 8081, - 8082, - 8083, - 8084, - 8085, - 8087, - 8088, - 8089, - 8090, - 8096, - 8123, - 8175, - 8176, - 8240, - 8334, - 8336, - 8888, - 8999, - 9000, - 9100, - 10380, - 11434, - 18081, - 18083, - 23000, - 32838, - 50002, + 2283, 2342, 3000, 3001, 3002, 4080, 5180, 7778, 8080, 8081, 8082, 8083, 8084, 8085, 8087, 8088, + 8089, 8090, 8096, 8123, 8175, 8176, 8240, 8334, 8336, 8888, 8999, 9000, 9100, 10380, 11434, + 18081, 18083, 23000, 32838, 50002, ]; From c9f1d87dd6d2f474ec6a047cc6793a084b30885a Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 5 Aug 2026 16:42:57 -0400 Subject: [PATCH 35/60] chore: release v1.7.124-alpha --- release-manifest.json | 42 +++++++++++++++++++----------------- releases/app-catalog.json | 2 ++ releases/manifest.json | 45 ++++++++++++++++++++------------------- 3 files changed, 47 insertions(+), 42 deletions(-) diff --git a/release-manifest.json b/release-manifest.json index edf4a6eb..baf997ce 100644 --- a/release-manifest.json +++ b/release-manifest.json @@ -1,33 +1,35 @@ { "changelog": [ - "**Five more screens on your node were readable by anyone who could reach it, and the previous release's own check said they were fine.** The Bitcoin, Lightning, Electrum, FIPS mesh and Fedimint Guardian screens each answered on their port with no login. They were missed because they work differently from ordinary apps: they run directly on the node's network rather than behind its container plumbing, so there was no address to pin and their descriptions listed no port at all — and the node builds its list of what to protect from exactly those descriptions. It therefore neither protected them nor listed them as unprotected. A check that reports success while five screens are open is worse than no check, and this was found by scanning the node from another machine rather than asking the node about itself.", - "**What was actually readable was the page, not your money.** Every request on those ports that could have returned a credential — the Lightning connection details, the wallet passthrough, container logs, and every node command — already required a login and still refused without one. The Lightning macaroon fix from v1.7.120 was verified directly rather than assumed. What leaked was the screen itself: layout and code, no wallet data, no keys.", - "All five now serve only to the node itself, with the login gate in front of them, exactly like the twenty app screens closed in the previous release.", - "**Every port on the node now has a stated policy — there are no undecided ones left.** Eleven ports previously had no instruction either way and stayed open by default. The BotFights arena, the router screen and the Pine voice screen now require the node password. The ones that genuinely cannot take a login page stay open with a written reason: Fedimint's guardian and gateway connections (federation members authenticate to the federation), NetBird's management and dashboard ports (your VPN devices carry their own credentials and cannot hold a browser session, and its dashboard needs its own certificate), Pine's secure listener, and the Lightning REST port, which wallets reach with a macaroon exactly as before.", - "Fresh installs are covered too, not just existing nodes. The five screens are delivered as prebuilt images, so a newly flashed node would have come up open even after this fix. All five were rebuilt, published, and then pulled back and inspected to confirm the fix is really inside them.", - "Two delivery faults fixed alongside, either of which would have silently undone the above: two of the five screens were reaching nodes through no update path at all, so edits to them never arrived; and a fourth copy of the Bitcoin screen's configuration was being rewritten on every health check, which would have re-opened that port after everything else was corrected.", - "Known gaps, disclosed rather than buried: non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. Three voice-assistant ports remain open without authentication; the correct fix puts them on a private network with the assistant. The 5x real-node lifecycle gate was not run for this release." + "**The most important fix in this release: some nodes were left switched off by their own update, and could not switch themselves back on.** The node replaces its program and then exits, expecting the system to start it again — but nodes installed from older images carried a setting that only restarts the program if it *crashes*. A clean, deliberate exit looked like success, so nothing restarted it, and the node sat dead showing \"server starting\" with nothing able to start it. One of ours was down for over two hours this way, and three of four checked had the same setting waiting to bite. Your node now repairs that setting itself the first time it starts, so it survives every future update.", + "**Portainer opens again.** Its screen reported the app as not responding because the app was quietly refusing to start: nodes have been running Portainer 2.39.1, their stored data was written by that version, and the app list pinned a version from two years earlier — so when the container was rebuilt it landed on the old one, which will not read newer data. The correct version is now pinned, older installs upgrade cleanly, and no data was touched.", + "**Bitcoin starts reliably again.** A leftover settings file in the Bitcoin folder — one the node itself kept rewriting and Bitcoin no longer reads — is treated as fatal by Bitcoin, so affected nodes restarted every few seconds forever. The node no longer writes that file, removes stale copies, and treats any that remain as harmless.", + "**Every app screen opens from My Apps again.** The login gate refused to be displayed inside another page at all, which is exactly how My Apps opens an app, so protected apps appeared broken. It now allows only your own node to display it, and refuses everyone else — a distinction the old setting could not express.", + "**The app login screen now looks like the node's own.** Same rotating artwork, the same panel, the Archipelago mark, and the app's real icon shown as a tile the way My Apps shows it, instead of a plain box with a letter.", + "**The Mesh screen uses wide displays properly.** On very large screens it stacked all five panels on top of each other, clipping three of the headings to a sliver and squeezing the map into a letterbox — more screen producing a worse view. It now shows one panel at a time, filling the space, with the map running edge to edge.", + "**You can choose how long you stay signed in.** Settings → Account now offers an inactivity timeout and a hard limit, plus an option to re-enter your password before sending funds. TV and kiosk screens are never signed out for sitting idle, because there is nobody there to sign them back in.", + "Updates now come from `source.archipelago-foundation.org` rather than a bare address, with the old one kept as an automatic fallback for nodes whose clock or name lookup is off. Also included: clearer wallet errors from ecash mints, and mesh peers reconnecting via their last known address before falling back to the wider network.", + "Known gaps, disclosed rather than buried: three voice-assistant ports remain open without authentication; the correct fix puts them on a private network with the assistant. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release." ], "components": [ { - "current_version": "1.7.123-alpha", - "download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.123-alpha/archipelago", + "current_version": "1.7.124-alpha", + "download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.124-alpha/archipelago", "name": "archipelago", - "new_version": "1.7.123-alpha", - "sha256": "b7974a671f8f7987fab548d0a9cac1ad145e9a52f4099791c3d1879d065a9f47", - "size_bytes": 54957360 + "new_version": "1.7.124-alpha", + "sha256": "bc91e7d09083dad17934dc2bad211b4eb1d22fd799e032a94fa73df025ba272d", + "size_bytes": 54701560 }, { - "current_version": "1.7.123-alpha", - "download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.123-alpha/archipelago-frontend-1.7.123-alpha.tar.gz", - "name": "archipelago-frontend-1.7.123-alpha.tar.gz", - "new_version": "1.7.123-alpha", - "sha256": "075370c761f07373e553db07eb5e019d96ceb591fa9c1067e1adc6701fa30752", - "size_bytes": 210533854 + "current_version": "1.7.124-alpha", + "download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.124-alpha/archipelago-frontend-1.7.124-alpha.tar.gz", + "name": "archipelago-frontend-1.7.124-alpha.tar.gz", + "new_version": "1.7.124-alpha", + "sha256": "346ed6472fb647bcf45299e4d93b9f555142bf1229912030fbafe9966fd410a7", + "size_bytes": 210531432 } ], "release_date": "2026-08-05", - "signature": "f64eaf9731277865c0d3f757f63dc2f4921fb7d90deb1615ca2b2b0f4c173282524b39a2d0b8f027c7532e918c349a86b14ed1b7992a96b0c6a80cd46b5d500d", + "signature": "40f5ffca026addd00c4aac933edf8faa05a60d9b981341bc0bfaae078812a65ad6656886b4a9d533476759dbb511fb4b050e518cd0060568f7b33035069e600e", "signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT", - "version": "1.7.123-alpha" + "version": "1.7.124-alpha" } diff --git a/releases/app-catalog.json b/releases/app-catalog.json index 9cb615b4..d456fdf3 100644 --- a/releases/app-catalog.json +++ b/releases/app-catalog.json @@ -4898,5 +4898,7 @@ } }, "schema": 1, + "signature": "b10aa65a9a6e91a6d421deff6e578c080c6bafa0185d54412df72b9814a694e9c9ccd803f7e8baa6d0ae93c1b71b3e20ed1626769445cc2f484c4a8bbd1b9901", + "signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT", "updated": "2026-08-05" } diff --git a/releases/manifest.json b/releases/manifest.json index 22dd4ab6..baf997ce 100644 --- a/releases/manifest.json +++ b/releases/manifest.json @@ -1,34 +1,35 @@ { "changelog": [ - "**Your apps now ask for your node password before they open — over your home network, Tailscale, the mesh and Tor alike.** Until now anyone who could reach your node could open Immich, Nextcloud, Vaultwarden, Jellyfin, Grafana and the rest simply by typing the address and port, with no login at all. Twenty app screens now sit behind the same login you use for the node, showing you which app you are opening, and honouring two-factor if you have it switched on. Logging in at an app address logs you into the dashboard too, so it is one password, not one per app. This completes the groundwork disclosed in v1.7.121.", - "**The things that must stay open stayed open.** Zeus and other remote wallets still reach your Lightning node directly, Electrum wallets still connect, and Bitcoin still talks to its peers — those connections carry their own proof of identity and a login page would simply break them. Every one of these seventeen exceptions now has to state in writing why it is safe to leave open, so the list is something you can read rather than something you have to discover.", - "**A private address on your node was answering the mesh without a password.** One app's port was marked as being for this machine only, and the part of the node that carries mesh traffic did not know that — it forwarded requests from the whole mesh straight to it. Found while verifying the work above on a real node, not in testing. That path now refuses anything marked machine-only, and the app is reachable only from the node itself, as intended.", - "**Tor addresses no longer skip the login.** An app published as a .onion address was handed straight to the app, because a Tor visitor carries no session cookie to check. The login gate now takes those addresses first, closing the last of the four routes that went around it.", - "Nodes fix themselves after this update. Apps installed before this system used its current container setup kept their old wide-open address even after the signed list told them to move, and each would otherwise have needed hand-holding on every node. Your node now notices the difference and rebuilds those apps itself, keeping their data, within about half a minute of starting. Verified by putting a node back into the old state deliberately and watching it repair.", - "The node had been reading two different sets of instructions about its own apps — the signed list it downloads, and older copies on disk — which is how a port meant to stay private was briefly opened on a test node. Both now come from the signed list, and a port withdrawn from the login gate is released without needing a restart.", - "**The key that signs these updates has been replaced.** The previous signing key was exposed where it should not have been, so it is treated as compromised and this release installs its replacement. This update is the last one signed with the old key, by necessity — it is the one that teaches your node the new one.", - "Known gaps, disclosed rather than buried: eleven app ports still have no stated policy — BotFights, the Fedimint gateway, NetBird, the voice assistant's own screens and the router screen — and remain reachable without a login until each is decided deliberately; the node reports them rather than guessing, because guessing at an unstated setting caused both incidents behind this work. Three voice-assistant ports are still open without authentication; the correct fix puts them on a private network with the assistant. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — will now meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release." + "**The most important fix in this release: some nodes were left switched off by their own update, and could not switch themselves back on.** The node replaces its program and then exits, expecting the system to start it again — but nodes installed from older images carried a setting that only restarts the program if it *crashes*. A clean, deliberate exit looked like success, so nothing restarted it, and the node sat dead showing \"server starting\" with nothing able to start it. One of ours was down for over two hours this way, and three of four checked had the same setting waiting to bite. Your node now repairs that setting itself the first time it starts, so it survives every future update.", + "**Portainer opens again.** Its screen reported the app as not responding because the app was quietly refusing to start: nodes have been running Portainer 2.39.1, their stored data was written by that version, and the app list pinned a version from two years earlier — so when the container was rebuilt it landed on the old one, which will not read newer data. The correct version is now pinned, older installs upgrade cleanly, and no data was touched.", + "**Bitcoin starts reliably again.** A leftover settings file in the Bitcoin folder — one the node itself kept rewriting and Bitcoin no longer reads — is treated as fatal by Bitcoin, so affected nodes restarted every few seconds forever. The node no longer writes that file, removes stale copies, and treats any that remain as harmless.", + "**Every app screen opens from My Apps again.** The login gate refused to be displayed inside another page at all, which is exactly how My Apps opens an app, so protected apps appeared broken. It now allows only your own node to display it, and refuses everyone else — a distinction the old setting could not express.", + "**The app login screen now looks like the node's own.** Same rotating artwork, the same panel, the Archipelago mark, and the app's real icon shown as a tile the way My Apps shows it, instead of a plain box with a letter.", + "**The Mesh screen uses wide displays properly.** On very large screens it stacked all five panels on top of each other, clipping three of the headings to a sliver and squeezing the map into a letterbox — more screen producing a worse view. It now shows one panel at a time, filling the space, with the map running edge to edge.", + "**You can choose how long you stay signed in.** Settings → Account now offers an inactivity timeout and a hard limit, plus an option to re-enter your password before sending funds. TV and kiosk screens are never signed out for sitting idle, because there is nobody there to sign them back in.", + "Updates now come from `source.archipelago-foundation.org` rather than a bare address, with the old one kept as an automatic fallback for nodes whose clock or name lookup is off. Also included: clearer wallet errors from ecash mints, and mesh peers reconnecting via their last known address before falling back to the wider network.", + "Known gaps, disclosed rather than buried: three voice-assistant ports remain open without authentication; the correct fix puts them on a private network with the assistant. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release." ], "components": [ { - "current_version": "1.7.122-alpha", - "download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.122-alpha/archipelago", + "current_version": "1.7.124-alpha", + "download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.124-alpha/archipelago", "name": "archipelago", - "new_version": "1.7.122-alpha", - "sha256": "06aedbd235e962574b7abc5d6992c26b77cd943655e775cd93c84fdcc79ffab0", - "size_bytes": 54957496 + "new_version": "1.7.124-alpha", + "sha256": "bc91e7d09083dad17934dc2bad211b4eb1d22fd799e032a94fa73df025ba272d", + "size_bytes": 54701560 }, { - "current_version": "1.7.122-alpha", - "download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.122-alpha/archipelago-frontend-1.7.122-alpha.tar.gz", - "name": "archipelago-frontend-1.7.122-alpha.tar.gz", - "new_version": "1.7.122-alpha", - "sha256": "865f5a0edb5eed1ced9dc4597b9112f24706d3538f8ffe84dea8104049d26af3", - "size_bytes": 210528707 + "current_version": "1.7.124-alpha", + "download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.124-alpha/archipelago-frontend-1.7.124-alpha.tar.gz", + "name": "archipelago-frontend-1.7.124-alpha.tar.gz", + "new_version": "1.7.124-alpha", + "sha256": "346ed6472fb647bcf45299e4d93b9f555142bf1229912030fbafe9966fd410a7", + "size_bytes": 210531432 } ], "release_date": "2026-08-05", - "signature": "aca66567bf5954aefd450167f881289ee4715fd912fe61a50726741cadf1a93d39e832efc3266388839279ad41001c9802fdfaf766c8cfa9399509916ed4a80f", - "signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur", - "version": "1.7.122-alpha" + "signature": "40f5ffca026addd00c4aac933edf8faa05a60d9b981341bc0bfaae078812a65ad6656886b4a9d533476759dbb511fb4b050e518cd0060568f7b33035069e600e", + "signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT", + "version": "1.7.124-alpha" } From e88c51d80b939978977f158f15131adc44c7a2a1 Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 5 Aug 2026 18:45:02 -0400 Subject: [PATCH 36/60] fix(bitcoin): repair the startup script I broke in 1.7.124, and gate against it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bitcoin app vanished from updated nodes: the container exited instantly with 'sh: Syntax error: "fi" unexpected'. My 1.7.124 change added an explanatory comment INSIDE the manifest's folded YAML scalar (>-), where '#' is not a comment — it is literal text that reaches the shell. Folding joins lines with spaces, so the comment swallowed the 'if ... then' while the more-indented echo survived as its own line, leaving an orphan 'fi'. bitcoind never ran, the container exited, and the app disappeared from the UI because detection is container-based. Explanations now live above the '- >-' line where YAML really treats them as comments. The loopback-conf tolerance (-allowignoredconf=1) is unchanged and still needed. Adds scripts/check-manifest-shell.py to the release gate: it runs 'sh -n' over every embedded manifest script and rejects '#' inside these scalars. Nothing validated this shell before — no YAML parse or Rust test could have caught it, and it only failed on the node, after signing. Co-Authored-By: Claude Fable 5 --- apps/bitcoin-core/manifest.yml | 9 +--- apps/bitcoin-knots/manifest.yml | 9 +--- scripts/check-manifest-shell.py | 86 +++++++++++++++++++++++++++++++++ tests/release/run.sh | 1 + 4 files changed, 89 insertions(+), 16 deletions(-) create mode 100755 scripts/check-manifest-shell.py diff --git a/apps/bitcoin-core/manifest.yml b/apps/bitcoin-core/manifest.yml index c8619f7e..5e9db91a 100644 --- a/apps/bitcoin-core/manifest.yml +++ b/apps/bitcoin-core/manifest.yml @@ -38,15 +38,8 @@ app: RPC_CONF="/tmp/rpc.conf"; umask 077; { echo "rpcuser=$RPC_USER"; echo "rpcpassword=$RPC_PASS"; } > "$RPC_CONF"; - # A stray bitcoin.conf in the datadir is FATAL when -conf points - # elsewhere: bitcoind refuses to start with "contains a bitcoin.conf - # file which is ignored", and the app crash-loops (100.82.34.38, - # 2026-08-05 — Exited(1) every few seconds). Our -conf carries the - # RPC credentials and the flags below are the authoritative config, - # so the datadir file is legacy debris; say so out loud rather than - # failing, and let bitcoind start. if [ -f /home/bitcoin/.bitcoin/bitcoin.conf ]; then - echo "archipelago: ignoring legacy /home/bitcoin/.bitcoin/bitcoin.conf; RPC config comes from $RPC_CONF and the flags below" >&2; + echo "archipelago: ignoring legacy datadir bitcoin.conf; RPC config comes from $RPC_CONF" >&2; fi; RPC_TXRELAY_AUTH="$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)"; DISK_GB_VALUE="$(printenv DISK_GB || true)"; diff --git a/apps/bitcoin-knots/manifest.yml b/apps/bitcoin-knots/manifest.yml index cdc492c5..f74c6052 100644 --- a/apps/bitcoin-knots/manifest.yml +++ b/apps/bitcoin-knots/manifest.yml @@ -38,15 +38,8 @@ app: RPC_CONF="/tmp/rpc.conf"; umask 077; { echo "rpcuser=$RPC_USER"; echo "rpcpassword=$RPC_PASS"; } > "$RPC_CONF"; - # A stray bitcoin.conf in the datadir is FATAL when -conf points - # elsewhere: bitcoind refuses to start with "contains a bitcoin.conf - # file which is ignored", and the app crash-loops (100.82.34.38, - # 2026-08-05 — Exited(1) every few seconds). Our -conf carries the - # RPC credentials and the flags below are the authoritative config, - # so the datadir file is legacy debris; say so out loud rather than - # failing, and let bitcoind start. if [ -f /home/bitcoin/.bitcoin/bitcoin.conf ]; then - echo "archipelago: ignoring legacy /home/bitcoin/.bitcoin/bitcoin.conf; RPC config comes from $RPC_CONF and the flags below" >&2; + echo "archipelago: ignoring legacy datadir bitcoin.conf; RPC config comes from $RPC_CONF" >&2; fi; RPC_TXRELAY_AUTH="$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)"; DISK_GB_VALUE="$(printenv DISK_GB || true)"; diff --git a/scripts/check-manifest-shell.py b/scripts/check-manifest-shell.py new file mode 100755 index 00000000..67414a68 --- /dev/null +++ b/scripts/check-manifest-shell.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Syntax-check the shell embedded in apps/*/manifest.yml. + +A manifest can carry a whole startup script in `container.custom_args` / +`entrypoint`. Nothing validated it, so a broken one shipped through the +signed catalog and only failed on the node — as a container that exits +instantly and an app that vanishes from the UI. + +Two checks, both learned from v1.7.124 (bitcoin-knots / bitcoin-core): + +1. `sh -n` the snippet. The break was `sh: Syntax error: "fi" unexpected`, + which no YAML parse and no Rust test could have caught. + +2. Reject `#` inside the snippet. These are YAML **folded** scalars (`>-`), + where `#` is NOT a comment — it is literal text that reaches the shell, + and because folding joins lines with spaces it comments out the rest of + the folded line. That is exactly how an `if ... then` was swallowed while + its more-indented body survived, leaving an orphan `fi`. Put explanations + above the `- >-` line, where YAML really does treat them as comments. +""" + +from __future__ import annotations + +import glob +import os +import subprocess +import sys +import tempfile + +import yaml + +# Long enough to be a script rather than a flag. +MIN_SCRIPT_LEN = 60 + + +def snippets(path: str): + with open(path, encoding="utf-8") as fh: + data = yaml.safe_load(fh) + container = ((data or {}).get("app") or {}).get("container") or {} + for key in ("custom_args", "entrypoint"): + value = container.get(key) + if not isinstance(value, list): + continue + for i, part in enumerate(value): + if isinstance(part, str) and len(part) >= MIN_SCRIPT_LEN: + yield f"{key}[{i}]", part + + +def main() -> int: + failures = [] + checked = 0 + for path in sorted(glob.glob("apps/*/manifest.yml")): + app = os.path.basename(os.path.dirname(path)) + try: + found = list(snippets(path)) + except Exception as exc: # noqa: BLE001 — report, don't crash the gate + failures.append(f"{app}: manifest does not parse: {exc}") + continue + for where, script in found: + checked += 1 + if "#" in script: + failures.append( + f"{app} {where}: contains '#'. In a folded YAML scalar that is not a " + f"comment — it reaches the shell and comments out the rest of the " + f"folded line. Move the explanation above the '- >-' line." + ) + with tempfile.NamedTemporaryFile("w", suffix=".sh", delete=False) as tmp: + tmp.write(script) + tmp_path = tmp.name + try: + proc = subprocess.run( + ["sh", "-n", tmp_path], capture_output=True, text=True, check=False + ) + finally: + os.unlink(tmp_path) + if proc.returncode != 0: + failures.append(f"{app} {where}: {proc.stderr.strip()}") + + for f in failures: + print(f"MANIFEST-SHELL {f}", file=sys.stderr) + print(f'{{"snippets_checked": {checked}, "failures": {len(failures)}}}') + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/release/run.sh b/tests/release/run.sh index 5c577d33..6d047a00 100755 --- a/tests/release/run.sh +++ b/tests/release/run.sh @@ -62,6 +62,7 @@ summary() { # ── Stage 1: static ────────────────────────────────────────────────── stage "git-diff-check" git diff --check stage "cargo-fmt" timeout 240 cargo fmt --manifest-path core/Cargo.toml --all --check +stage "manifest-shell" python3 scripts/check-manifest-shell.py stage "catalog-drift" python3 scripts/check-app-catalog-drift.py --release --strict # Every release must surface its CHANGELOG entry in the Settings "What's New" # modal. The modal hardcodes a block per version and has drifted behind before From 188411b79cf5a915df7276363b73ee568592eb8c Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 5 Aug 2026 18:54:37 -0400 Subject: [PATCH 37/60] chore(catalog): sign catalog with the repaired bitcoin start script Unbreaks Bitcoin on every node running the 1.7.124 catalog: the embedded start script had a shell syntax error, so bitcoind never launched and the app vanished. Delivered by catalog rather than a release because manifests reach nodes through the signed catalog. Co-Authored-By: Claude Fable 5 --- core/archipelago/src/appgate/mod.rs | 27 +++++++++++++++++++++------ releases/app-catalog.json | 6 +++--- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/core/archipelago/src/appgate/mod.rs b/core/archipelago/src/appgate/mod.rs index 9dc4090b..1bb36143 100644 --- a/core/archipelago/src/appgate/mod.rs +++ b/core/archipelago/src/appgate/mod.rs @@ -548,7 +548,7 @@ const LOGIN_BACKGROUNDS: [&str; 4] = [ /// join: the name arrives in a URL, and the gate answers before any /// authentication, so nothing here may be caller-controlled beyond this set. fn read_ui_asset(name: &str) -> Option<(Vec, &'static str)> { - let allowed = LOGIN_BACKGROUNDS.contains(&name) || name == "logo-archipelago.svg"; + let allowed = LOGIN_BACKGROUNDS.contains(&name) || name == "favico-black-v2.svg"; if !allowed { return None; } @@ -557,6 +557,9 @@ fn read_ui_asset(name: &str) -> Option<(Vec, &'static str)> { "/opt/archipelago/web-ui/assets/img", "web/dist/neode-ui/assets/img", "neode-ui/public/assets/img", + "/opt/archipelago/web-ui/assets/icon", + "web/dist/neode-ui/assets/icon", + "neode-ui/public/assets/icon", ] { if let Ok(bytes) = std::fs::read(std::path::Path::new(root).join(name)) { return Some((bytes, mime)); @@ -631,9 +634,21 @@ fn page(title: &str, app: &GatedPort, body: &str, status: StatusCode) -> Respons bundle exists, and the CSP forbids external stylesheets and script. */ :root {{ color-scheme: dark; }} * {{ box-sizing: border-box; }} -body {{ margin:0; min-height:100vh; display:grid; place-items:center; padding:1rem; - background:#05070a; color:#fff; overflow:hidden; - font:16px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif; }} +html {{ height:100%; }} +body {{ margin:0; color:#fff; background:#05070a; overflow:hidden; + font:16px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif; + /* Fixed to the viewport rather than a tall scrolling page: an on-screen + keyboard then overlays the card instead of scrolling it away, and the + card stays optically centred. min-height:100vh scrolled with the + keyboard on mobile and left the card off-centre (reported 2026-08-05). */ + position:fixed; inset:0; + display:grid; place-items:center; padding:1rem; + height:100vh; height:100svh; }} +/* Very short viewports (landscape phone, or a keyboard eating most of it): + allow the card to scroll INSIDE the fixed frame rather than overflow. */ +@media (max-height:640px) {{ + body {{ align-items:start; overflow-y:auto; padding-top:3rem; }} +}} /* Rotating backgrounds: each layer holds its image and cross-fades on a shared cycle, so the art moves the way /login does with no script. */ .bg {{ position:fixed; inset:0; z-index:0; background-size:cover; @@ -726,7 +741,7 @@ button:active {{ transform:translateY(1px); }} /// password by an unexplained page. fn login_page(app: &GatedPort, error: Option<&str>, status: StatusCode) -> Response { let body = format!( - r#" + r#" {icon}

Sign in to open {name}

This app is protected by your node password.

@@ -863,7 +878,7 @@ mod tests { let resp = login_page(&app(), None, StatusCode::UNAUTHORIZED); let body = hyper::body::to_bytes(resp.into_body()).await.unwrap(); let html = String::from_utf8_lossy(&body).to_string(); - assert!(html.contains(&format!("{GATE_PREFIX}asset/logo-archipelago.svg"))); + assert!(html.contains(&format!("{GATE_PREFIX}asset/favico-black-v2.svg"))); for name in LOGIN_BACKGROUNDS { assert!( html.contains(&format!("{GATE_PREFIX}asset/{name}")), diff --git a/releases/app-catalog.json b/releases/app-catalog.json index d456fdf3..fce24d79 100644 --- a/releases/app-catalog.json +++ b/releases/app-catalog.json @@ -421,7 +421,7 @@ }, "container": { "custom_args": [ - "BITCOIND=\"$(command -v bitcoind || true)\"; if [ -z \"$BITCOIND\" ]; then\n BITCOIND=\"$(find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1)\";\nfi; if [ -z \"$BITCOIND\" ]; then\n echo \"bitcoind not found in image\" >&2;\n exit 127;\nfi; RPC_USER=\"$(printenv BITCOIN_RPC_USER)\"; RPC_PASS=\"$(printenv BITCOIN_RPC_PASS)\"; RPC_CONF=\"/tmp/rpc.conf\"; umask 077; { echo \"rpcuser=$RPC_USER\"; echo \"rpcpassword=$RPC_PASS\"; } > \"$RPC_CONF\"; # A stray bitcoin.conf in the datadir is FATAL when -conf points # elsewhere: bitcoind refuses to start with \"contains a bitcoin.conf # file which is ignored\", and the app crash-loops (100.82.34.38, # 2026-08-05 — Exited(1) every few seconds). Our -conf carries the # RPC credentials and the flags below are the authoritative config, # so the datadir file is legacy debris; say so out loud rather than # failing, and let bitcoind start. if [ -f /home/bitcoin/.bitcoin/bitcoin.conf ]; then\n echo \"archipelago: ignoring legacy /home/bitcoin/.bitcoin/bitcoin.conf; RPC config comes from $RPC_CONF and the flags below\" >&2;\nfi; RPC_TXRELAY_AUTH=\"$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)\"; DISK_GB_VALUE=\"$(printenv DISK_GB || true)\"; RPC_HEADROOM=\"-rpcthreads=16 -rpcworkqueue=256\"; RPC_TXRELAY_FLAGS=\"-rpcwhitelistdefault=0\"; if [ -n \"$RPC_TXRELAY_AUTH\" ]; then\n RPC_TXRELAY_FLAGS=\"$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips\";\nfi; if [ \"${DISK_GB_VALUE:-0}\" -lt 1000 ]; then\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nelse\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nfi" + "BITCOIND=\"$(command -v bitcoind || true)\"; if [ -z \"$BITCOIND\" ]; then\n BITCOIND=\"$(find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1)\";\nfi; if [ -z \"$BITCOIND\" ]; then\n echo \"bitcoind not found in image\" >&2;\n exit 127;\nfi; RPC_USER=\"$(printenv BITCOIN_RPC_USER)\"; RPC_PASS=\"$(printenv BITCOIN_RPC_PASS)\"; RPC_CONF=\"/tmp/rpc.conf\"; umask 077; { echo \"rpcuser=$RPC_USER\"; echo \"rpcpassword=$RPC_PASS\"; } > \"$RPC_CONF\"; if [ -f /home/bitcoin/.bitcoin/bitcoin.conf ]; then\n echo \"archipelago: ignoring legacy datadir bitcoin.conf; RPC config comes from $RPC_CONF\" >&2;\nfi; RPC_TXRELAY_AUTH=\"$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)\"; DISK_GB_VALUE=\"$(printenv DISK_GB || true)\"; RPC_HEADROOM=\"-rpcthreads=16 -rpcworkqueue=256\"; RPC_TXRELAY_FLAGS=\"-rpcwhitelistdefault=0\"; if [ -n \"$RPC_TXRELAY_AUTH\" ]; then\n RPC_TXRELAY_FLAGS=\"$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips\";\nfi; if [ \"${DISK_GB_VALUE:-0}\" -lt 1000 ]; then\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nelse\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nfi" ], "data_uid": "100101:100101", "derived_env": [ @@ -567,7 +567,7 @@ }, "container": { "custom_args": [ - "BITCOIND=\"$(command -v bitcoind || true)\"; if [ -z \"$BITCOIND\" ]; then\n BITCOIND=\"$(find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1)\";\nfi; if [ -z \"$BITCOIND\" ]; then\n echo \"bitcoind not found in image\" >&2;\n exit 127;\nfi; RPC_USER=\"$(printenv BITCOIN_RPC_USER)\"; RPC_PASS=\"$(printenv BITCOIN_RPC_PASS)\"; RPC_CONF=\"/tmp/rpc.conf\"; umask 077; { echo \"rpcuser=$RPC_USER\"; echo \"rpcpassword=$RPC_PASS\"; } > \"$RPC_CONF\"; # A stray bitcoin.conf in the datadir is FATAL when -conf points # elsewhere: bitcoind refuses to start with \"contains a bitcoin.conf # file which is ignored\", and the app crash-loops (100.82.34.38, # 2026-08-05 — Exited(1) every few seconds). Our -conf carries the # RPC credentials and the flags below are the authoritative config, # so the datadir file is legacy debris; say so out loud rather than # failing, and let bitcoind start. if [ -f /home/bitcoin/.bitcoin/bitcoin.conf ]; then\n echo \"archipelago: ignoring legacy /home/bitcoin/.bitcoin/bitcoin.conf; RPC config comes from $RPC_CONF and the flags below\" >&2;\nfi; RPC_TXRELAY_AUTH=\"$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)\"; DISK_GB_VALUE=\"$(printenv DISK_GB || true)\"; RPC_HEADROOM=\"-rpcthreads=16 -rpcworkqueue=256\"; RPC_TXRELAY_FLAGS=\"-rpcwhitelistdefault=0\"; if [ -n \"$RPC_TXRELAY_AUTH\" ]; then\n RPC_TXRELAY_FLAGS=\"$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips\";\nfi; if [ \"${DISK_GB_VALUE:-0}\" -lt 1000 ]; then\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nelse\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nfi" + "BITCOIND=\"$(command -v bitcoind || true)\"; if [ -z \"$BITCOIND\" ]; then\n BITCOIND=\"$(find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1)\";\nfi; if [ -z \"$BITCOIND\" ]; then\n echo \"bitcoind not found in image\" >&2;\n exit 127;\nfi; RPC_USER=\"$(printenv BITCOIN_RPC_USER)\"; RPC_PASS=\"$(printenv BITCOIN_RPC_PASS)\"; RPC_CONF=\"/tmp/rpc.conf\"; umask 077; { echo \"rpcuser=$RPC_USER\"; echo \"rpcpassword=$RPC_PASS\"; } > \"$RPC_CONF\"; if [ -f /home/bitcoin/.bitcoin/bitcoin.conf ]; then\n echo \"archipelago: ignoring legacy datadir bitcoin.conf; RPC config comes from $RPC_CONF\" >&2;\nfi; RPC_TXRELAY_AUTH=\"$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)\"; DISK_GB_VALUE=\"$(printenv DISK_GB || true)\"; RPC_HEADROOM=\"-rpcthreads=16 -rpcworkqueue=256\"; RPC_TXRELAY_FLAGS=\"-rpcwhitelistdefault=0\"; if [ -n \"$RPC_TXRELAY_AUTH\" ]; then\n RPC_TXRELAY_FLAGS=\"$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips\";\nfi; if [ \"${DISK_GB_VALUE:-0}\" -lt 1000 ]; then\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nelse\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nfi" ], "data_uid": "100101:100101", "derived_env": [ @@ -4898,7 +4898,7 @@ } }, "schema": 1, - "signature": "b10aa65a9a6e91a6d421deff6e578c080c6bafa0185d54412df72b9814a694e9c9ccd803f7e8baa6d0ae93c1b71b3e20ed1626769445cc2f484c4a8bbd1b9901", + "signature": "4c3cb577e6fe3884f4ad918ce3f14a3011ea0e175ab23ea1521a1d04a4f139f53eb86b0617b1183f2e8bbac2b046fe4965ae9f2878ba2cf08bac8262d68d140e", "signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT", "updated": "2026-08-05" } From e7592dc9c944ca1aa617f0e3df7b8c6e85128c17 Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 5 Aug 2026 19:12:36 -0400 Subject: [PATCH 38/60] =?UTF-8?q?fix(fedimint):=20stop=20declaring=208175?= =?UTF-8?q?=20=E2=80=94=20it=20belongs=20to=20the=20UI=20companion,=20not?= =?UTF-8?q?=20fedimintd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Declaring the Guardian UI port on the fedimint app made the orchestrator try to publish 8175 from fedimintd, colliding with archy-fedimint-ui which already holds it: start_container failed on every reconcile and fedimint crash-looped (100.82.34.38). The companion's nginx pinned to 127.0.0.1 is what actually closes that port; the gate reports it rather than fronting it. Also: app-login page uses the sidebar's 'A' mark instead of the full wordmark, is pinned to the small viewport so it stays centred and the keyboard overlays rather than scrolls it, and the install-version modal icon uses object-contain so a non-square icon is no longer cropped. Co-Authored-By: Claude Fable 5 --- apps/fedimint/manifest.yml | 17 +++++++---------- neode-ui/src/components/InstallVersionModal.vue | 6 +++++- releases/app-catalog.json | 9 --------- 3 files changed, 12 insertions(+), 20 deletions(-) diff --git a/apps/fedimint/manifest.yml b/apps/fedimint/manifest.yml index e93fcf0e..f69a7ddc 100644 --- a/apps/fedimint/manifest.yml +++ b/apps/fedimint/manifest.yml @@ -63,16 +63,13 @@ app: federation itself and cannot hold a browser session. # Public launch port 8175 is owned by archy-fedimint-ui, which serves a # wait page while Bitcoin syncs and proxies here after fedimintd starts. - # Declared HERE because that companion has no manifest of its own, and the - # gate keys on the port rather than the container: without this entry it - # served the Guardian UI unauthenticated on every interface and never - # appeared in the audit. Its nginx is pinned to 127.0.0.1 - # (docker/fedimint-ui/nginx.conf) so the gate can own the outside. - - host: 8175 - container: 8175 - protocol: tcp - bind: 127.0.0.1 - auth: gated + # 8175 is NOT declared here. It is served by the archy-fedimint-ui + # companion, a different container, and declaring it on this app made the + # orchestrator try to publish 8175 from fedimintd — colliding with the + # companion that already holds it, so start_container failed forever and + # fedimint crash-looped (100.82.34.38, 2026-08-05). The companion's nginx + # is pinned to 127.0.0.1, which is what actually closes that port; the + # gate reports it rather than fronting it. - host: 8177 container: 8175 protocol: tcp diff --git a/neode-ui/src/components/InstallVersionModal.vue b/neode-ui/src/components/InstallVersionModal.vue index 9754b190..99b6a9e9 100644 --- a/neode-ui/src/components/InstallVersionModal.vue +++ b/neode-ui/src/components/InstallVersionModal.vue @@ -2,11 +2,15 @@
+
diff --git a/releases/app-catalog.json b/releases/app-catalog.json index fce24d79..032664a5 100644 --- a/releases/app-catalog.json +++ b/releases/app-catalog.json @@ -1386,13 +1386,6 @@ "host": 8174, "protocol": "tcp" }, - { - "auth": "gated", - "bind": "127.0.0.1", - "container": 8175, - "host": 8175, - "protocol": "tcp" - }, { "auth": "local", "bind": "127.0.0.1", @@ -4898,7 +4891,5 @@ } }, "schema": 1, - "signature": "4c3cb577e6fe3884f4ad918ce3f14a3011ea0e175ab23ea1521a1d04a4f139f53eb86b0617b1183f2e8bbac2b046fe4965ae9f2878ba2cf08bac8262d68d140e", - "signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT", "updated": "2026-08-05" } From 4ace62fad933e817502e3ff8b81e69482114cdbf Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 5 Aug 2026 19:21:21 -0400 Subject: [PATCH 39/60] chore(catalog): sign catalog with the bitcoin and fedimint fixes Co-Authored-By: Claude Fable 5 --- releases/app-catalog.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/releases/app-catalog.json b/releases/app-catalog.json index 032664a5..44279454 100644 --- a/releases/app-catalog.json +++ b/releases/app-catalog.json @@ -4891,5 +4891,7 @@ } }, "schema": 1, + "signature": "a8502a3664500e6f22e47d2f29456cde208c0cf117767083be8ec6c238f73e546ae33ccea0c100573cfd9a474614da03903d5be6b7cae72bb3c5439d2b510104", + "signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT", "updated": "2026-08-05" } From ada59acdd5424eed90563cf06eefd5a1b52002a3 Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 5 Aug 2026 21:21:52 -0400 Subject: [PATCH 40/60] fix(appgate+container): stop stripping app cookies; create named volumes correctly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two daemon bugs, one debugging arc (2026-08-05, operator-reported): 1. The app gate removed the ENTIRE Cookie header before proxying. That broke the data plane of every first-party companion UI behind the gate (lnd-ui/bitcoin-ui/electrs-ui/fips-ui render their shell, then every /proxy/* and /lnd-connect-info call 401s — observed as "LND UI unreachable"), and silently logged users out of every gated app with its own cookie login (vaultwarden, nextcloud, gitea) on each request. The gate now strips only its own cookie pairs (session, csrf_token); a new per-port manifest opt-in `session_passthrough: true` forwards the node session to first-party UIs whose nginx proxies the daemon's authenticated endpoints. Undeclared ports never get passthrough. 2. podman_client::create_container sent named volumes to the libpod API as bind mounts with the bare volume name as source, so creating any manifest app with a `type: volume` mount failed. On .38 the reconciler removed indeedhub-postgres/-minio for env drift and then could never create their replacements, leaving the stack half-missing forever. Named volumes now ride the spec's `volumes` field ({Name, Dest, Options}). Also: the reconcile-failure log now prints the full anyhow chain — `%e` showed only "create_container X" and hid the real error. Co-Authored-By: Claude Fable 5 --- core/archipelago/src/appgate/identity.rs | 9 ++ core/archipelago/src/appgate/mod.rs | 96 +++++++++++++++++-- .../src/container/prod_orchestrator.rs | 6 +- core/container/src/manifest.rs | 14 +++ core/container/src/podman_client.rs | 15 +++ 5 files changed, 133 insertions(+), 7 deletions(-) diff --git a/core/archipelago/src/appgate/identity.rs b/core/archipelago/src/appgate/identity.rs index f04da17f..8bfc0142 100644 --- a/core/archipelago/src/appgate/identity.rs +++ b/core/archipelago/src/appgate/identity.rs @@ -35,6 +35,11 @@ pub struct GatedPort { /// Tor-upstream bind — must key on this flag: acting on an undeclared /// port is the v1.7.121 incident class, whatever the action. pub declared: bool, + /// Manifest opt-in (`session_passthrough: true` on the port): forward the + /// node session cookie to the app on authorised requests. First-party + /// companion UIs proxy that cookie to the daemon's authenticated + /// endpoints; for every other app the gate strips its own credential. + pub session_passthrough: bool, } /// A port deliberately left unauthenticated, and the manifest's stated reason. @@ -226,6 +231,7 @@ fn classify_manifest(manifest: &AppManifest, map: &mut PortMap) { app_name: app_name.clone(), icon: icon.clone(), declared: true, + session_passthrough: port.session_passthrough, }, ); } @@ -273,6 +279,9 @@ fn classify_manifest(manifest: &AppManifest, map: &mut PortMap) { app_name: app_name.clone(), icon: icon.clone(), declared: false, + // An undeclared port never gets the node session — + // passthrough is an explicit manifest opt-in only. + session_passthrough: false, }, ); } diff --git a/core/archipelago/src/appgate/mod.rs b/core/archipelago/src/appgate/mod.rs index 1bb36143..d9f01ca9 100644 --- a/core/archipelago/src/appgate/mod.rs +++ b/core/archipelago/src/appgate/mod.rs @@ -136,7 +136,7 @@ impl AppGate { } match self.authorize(req.headers(), &app.app_id).await { - Authorization::Allow => proxy_to_app(req, app.port).await, + Authorization::Allow => proxy_to_app(req, app).await, // 401 rather than a redirect: a redirect to a login page is // indistinguishable from the app itself redirecting, and machine // clients would follow it and parse HTML as if it were their API @@ -375,7 +375,8 @@ fn percent_decode(input: &str) -> String { } /// Forward an authorised request to the app on loopback. -async fn proxy_to_app(req: Request, port: u16) -> Response { +async fn proxy_to_app(req: Request, app: &GatedPort) -> Response { + let port = app.port; let path_and_query = req .uri() .path_and_query() @@ -389,10 +390,16 @@ async fn proxy_to_app(req: Request, port: u16) -> Response { let (mut parts, body) = req.into_parts(); parts.uri = uri; - // Strip the gate's own credential before it reaches the app: the app has - // no use for the node session and should never be in a position to log, - // echo, or forward it. - parts.headers.remove(header::COOKIE); + // Strip the gate's own credential before it reaches the app — the app + // should never be in a position to log, echo, or forward the node + // session. But ONLY the gate's cookies: apps run their own cookie logins + // (vaultwarden, nextcloud, gitea…), and removing the whole header logged + // every one of them out on each request. Companion UIs that proxy the + // daemon's authenticated endpoints opt in to keeping the session via + // `session_passthrough: true` on their gated port. + if !app.session_passthrough { + strip_gate_cookies(&mut parts.headers); + } parts.headers.remove(header::AUTHORIZATION); let client = hyper::Client::new(); @@ -402,6 +409,44 @@ async fn proxy_to_app(req: Request, port: u16) -> Response { } } +/// Cookie names owned by the gate/daemon, never the app's to see. +const GATE_COOKIE_NAMES: &[&str] = &["session", "csrf_token"]; + +/// Remove the gate's own cookie pairs from the Cookie header, preserving the +/// app's cookies (its login/session/prefs) untouched. Drops the header +/// entirely when nothing remains. +fn strip_gate_cookies(headers: &mut hyper::HeaderMap) { + let Some(cookie) = headers.get(header::COOKIE) else { + return; + }; + let Ok(raw) = cookie.to_str() else { + // Not valid UTF-8 — can't safely filter pairs, so fail closed. + headers.remove(header::COOKIE); + return; + }; + let kept: Vec<&str> = raw + .split(';') + .map(str::trim) + .filter(|pair| { + let name = pair.split('=').next().unwrap_or("").trim(); + !GATE_COOKIE_NAMES.contains(&name) + }) + .filter(|pair| !pair.is_empty()) + .collect(); + if kept.is_empty() { + headers.remove(header::COOKIE); + return; + } + match header::HeaderValue::from_str(&kept.join("; ")) { + Ok(v) => { + headers.insert(header::COOKIE, v); + } + Err(_) => { + headers.remove(header::COOKIE); + } + } +} + fn set_session_cookie(resp: &mut Response, token: &str) { // No Domain attribute, so the cookie is host-only. Cookies ignore port, // which is what makes one sign-in cover the dashboard and every app port @@ -793,6 +838,7 @@ mod tests { app_name: "Strfry Relay".to_string(), icon: None, declared: true, + session_passthrough: false, } } @@ -946,6 +992,44 @@ mod tests { ); } + /// The gate must remove ONLY its own cookie pairs: an app's login cookie + /// riding the same header has to survive, or every gated app with its + /// own auth (vaultwarden, nextcloud, gitea) is logged out on each + /// request — the 2026-08-05 companion-UI/"app logged me out" regression. + #[test] + fn strip_gate_cookies_keeps_app_cookies() { + let mut headers = HeaderMap::new(); + headers.insert( + header::COOKIE, + "session=abc; vw_session=keepme; csrf_token=def; theme=dark" + .parse() + .unwrap(), + ); + strip_gate_cookies(&mut headers); + assert_eq!( + headers.get(header::COOKIE).unwrap().to_str().unwrap(), + "vw_session=keepme; theme=dark" + ); + } + + #[test] + fn strip_gate_cookies_drops_header_when_only_gate_cookies() { + let mut headers = HeaderMap::new(); + headers.insert( + header::COOKIE, + "session=abc; csrf_token=def".parse().unwrap(), + ); + strip_gate_cookies(&mut headers); + assert!(headers.get(header::COOKIE).is_none()); + } + + #[test] + fn strip_gate_cookies_no_header_is_a_noop() { + let mut headers = HeaderMap::new(); + strip_gate_cookies(&mut headers); + assert!(headers.get(header::COOKIE).is_none()); + } + /// The load-bearing 2FA property: a session still awaiting its TOTP code /// fails `validate()`, so the gate rejects it without knowing anything /// about second factors. diff --git a/core/archipelago/src/container/prod_orchestrator.rs b/core/archipelago/src/container/prod_orchestrator.rs index a68c605a..2eb4dc12 100644 --- a/core/archipelago/src/container/prod_orchestrator.rs +++ b/core/archipelago/src/container/prod_orchestrator.rs @@ -1754,7 +1754,10 @@ impl ProdContainerOrchestrator { } Ok(action) => report.record(&app_id, action), Err(e) => { - tracing::error!(app_id = %app_id, error = %e, "reconcile failed"); + // `{:#}` prints the whole anyhow chain — `%e` alone showed + // only the outer context ("create_container X") and hid + // the actual libpod error for days. + tracing::error!(app_id = %app_id, error = %format!("{e:#}"), "reconcile failed"); report.failures.push((app_id, e.to_string())); } } @@ -4440,6 +4443,7 @@ mod tests { bind: String::new(), auth: None, auth_rationale: None, + session_passthrough: false, } } diff --git a/core/container/src/manifest.rs b/core/container/src/manifest.rs index ddfc1832..09c3f731 100644 --- a/core/container/src/manifest.rs +++ b/core/container/src/manifest.rs @@ -599,6 +599,19 @@ pub struct PortMapping { /// means the author expected an exemption they did not get. #[serde(default, skip_serializing_if = "Option::is_none")] pub auth_rationale: Option, + /// Forward the node session cookie to the app on authorised requests. + /// + /// The gate normally strips its own credential before proxying — an app + /// must never be in a position to log or replay the node session. The + /// first-party companion UIs (lnd-ui, bitcoin-ui, electrs-ui, fips-ui) + /// are the exception their design requires: their nginx forwards the + /// browser's session cookie to the daemon's authenticated endpoints + /// (`/proxy/lnd/*`, `/rpc/v1`, `/lnd-connect-info`), so stripping it + /// breaks every data call behind the gate with a 401 while the page + /// shell still renders (observed as "LND UI unreachable", 2026-08-05). + /// Only meaningful on a `auth: gated` port. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub session_passthrough: bool, } impl PortMapping { @@ -626,6 +639,7 @@ impl From<(u16, u16)> for PortMapping { bind: String::new(), auth: None, auth_rationale: None, + session_passthrough: false, } } } diff --git a/core/container/src/podman_client.rs b/core/container/src/podman_client.rs index d12eac92..5726c9c7 100644 --- a/core/container/src/podman_client.rs +++ b/core/container/src/podman_client.rs @@ -366,6 +366,7 @@ impl PodmanClient { } let mut mounts = Vec::new(); + let mut named_volumes = Vec::new(); for volume in &manifest.app.volumes { if volume.volume_type == "tmpfs" { let options: Vec = volume @@ -382,6 +383,19 @@ impl PodmanClient { "type": "tmpfs", "options": options, })); + } else if volume.volume_type == "volume" { + // Named podman volume. The libpod create spec carries these in + // the separate `volumes` field ({Name, Dest, Options}), NOT in + // `mounts`: sending one as a bind mount makes the API treat + // the bare volume name as a host path and the create fails — + // which left indeedhub-postgres/-minio permanently absent on + // legacy-path nodes (the reconciler removed the old container + // for drift, then could never create its replacement). + named_volumes.push(serde_json::json!({ + "Name": volume.source, + "Dest": volume.target, + "Options": volume.options, + })); } else { mounts.push(serde_json::json!({ "destination": volume.target, @@ -464,6 +478,7 @@ impl PodmanClient { "image": image_ref, "portmappings": port_mappings, "mounts": mounts, + "volumes": named_volumes, "env": env_map, "secret_env": secret_env_map, "labels": labels_map, From d0d9c032decc29359a43cce791b8e6cfe1401057 Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 5 Aug 2026 21:22:07 -0400 Subject: [PATCH 41/60] fix(apps): session_passthrough on companion UI ports; indeedhub-redis caps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lnd-ui/bitcoin-ui/electrs-ui/fips-ui declare session_passthrough: true on their gated ports — their nginx forwards the browser's node session to the daemon's authenticated endpoints, which the gate's cookie strip was discarding (every data call 401'd behind the gate). - indeedhub-redis gains CHOWN + DAC_OVERRIDE: the alpine entrypoint runs as capability-stripped container-root and could not traverse the 0700 appendonlydir owned by the redis uid — crash-looped ~4k restarts on archi-dev-box under the quadlet migration. These reach nodes via the signed catalog re-sign (manifest overlay). Co-Authored-By: Claude Fable 5 --- apps/bitcoin-ui/manifest.yml | 4 ++++ apps/electrs-ui/manifest.yml | 4 ++++ apps/fips-ui/manifest.yml | 4 ++++ apps/indeedhub-redis/manifest.yml | 8 +++++++- apps/lnd-ui/manifest.yml | 4 ++++ 5 files changed, 23 insertions(+), 1 deletion(-) diff --git a/apps/bitcoin-ui/manifest.yml b/apps/bitcoin-ui/manifest.yml index 6fb05656..fc96095b 100644 --- a/apps/bitcoin-ui/manifest.yml +++ b/apps/bitcoin-ui/manifest.yml @@ -43,6 +43,10 @@ app: protocol: tcp bind: 127.0.0.1 auth: gated + # First-party companion UI: its nginx forwards the node session cookie + # to the daemon's authenticated endpoints; without passthrough the gate + # strips it and every data call 401s while the page shell renders. + session_passthrough: true volumes: # Bind-mount the rendered nginx.conf read-only. The prod orchestrator diff --git a/apps/electrs-ui/manifest.yml b/apps/electrs-ui/manifest.yml index 4f224565..d1bff168 100644 --- a/apps/electrs-ui/manifest.yml +++ b/apps/electrs-ui/manifest.yml @@ -35,6 +35,10 @@ app: protocol: tcp bind: 127.0.0.1 auth: gated + # First-party companion UI: its nginx forwards the node session cookie + # to the daemon's authenticated endpoints; without passthrough the gate + # strips it and every data call 401s while the page shell renders. + session_passthrough: true volumes: [] diff --git a/apps/fips-ui/manifest.yml b/apps/fips-ui/manifest.yml index e16c49f9..c4c8474a 100644 --- a/apps/fips-ui/manifest.yml +++ b/apps/fips-ui/manifest.yml @@ -39,6 +39,10 @@ app: protocol: tcp bind: 127.0.0.1 auth: gated + # First-party companion UI: its nginx forwards the node session cookie + # to the daemon's authenticated endpoints; without passthrough the gate + # strips it and every data call 401s while the page shell renders. + session_passthrough: true volumes: [] diff --git a/apps/indeedhub-redis/manifest.yml b/apps/indeedhub-redis/manifest.yml index c9997b0a..a2b84c29 100644 --- a/apps/indeedhub-redis/manifest.yml +++ b/apps/indeedhub-redis/manifest.yml @@ -22,7 +22,13 @@ app: memory_limit: 256Mi security: - capabilities: [SETGID, SETUID] + # The alpine entrypoint runs as container-root, `find`s /data to chown + # anything not owned by the redis user, then su-execs to it. Under the + # orchestrator's --cap-drop=ALL, root cannot traverse the 0700 + # appendonlydir owned by uid 999 without DAC_OVERRIDE (observed + # crash-looping ~4k restarts on archi-dev-box) — CHOWN is what the find's + # -exec chown needs on adopted legacy data. + capabilities: [CHOWN, DAC_OVERRIDE, SETGID, SETUID] readonly_root: false network_policy: isolated diff --git a/apps/lnd-ui/manifest.yml b/apps/lnd-ui/manifest.yml index cf186bcf..397f1b0b 100644 --- a/apps/lnd-ui/manifest.yml +++ b/apps/lnd-ui/manifest.yml @@ -47,6 +47,10 @@ app: protocol: tcp bind: 127.0.0.1 auth: gated + # First-party companion UI: its nginx forwards the node session cookie + # to the daemon's authenticated endpoints; without passthrough the gate + # strips it and every data call 401s while the page shell renders. + session_passthrough: true volumes: [] From c9724198403315df966557e2d3c9381a6041f3be Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 5 Aug 2026 21:22:42 -0400 Subject: [PATCH 42/60] fix(wallet): blank send/receive on every open; sweep shows amount; camera option stays visible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator-reported (2026-08-05): - Send/Receive modals reset to a blank slate on every open. Stale state — destination, amount, memo, and above all an armed "send all funds" toggle — silently carried into the next payment. - Arming "send all funds" now shows the swept balance in the (disabled) amount field instead of a confusing 0; disarming or leaving the on-chain tab clears it. - The scan modal no longer hides "Scan with camera" on plain-http desktop (browsers only allow getUserMedia on secure origins): the option stays visible with a one-line explanation, and choosing it surfaces the HTTPS requirement with photo/paste fallbacks. The companion app's native scanner path is untouched and still takes priority. - What's New entry for v1.7.125-alpha. Co-Authored-By: Claude Fable 5 --- .../src/components/ReceiveBitcoinModal.vue | 16 +++++- neode-ui/src/components/SendBitcoinModal.vue | 52 +++++++++++++++++-- neode-ui/src/components/WalletScanModal.vue | 10 +++- .../src/views/settings/AccountInfoSection.vue | 14 +++++ 4 files changed, 84 insertions(+), 8 deletions(-) diff --git a/neode-ui/src/components/ReceiveBitcoinModal.vue b/neode-ui/src/components/ReceiveBitcoinModal.vue index 085133e1..63cc4bcc 100644 --- a/neode-ui/src/components/ReceiveBitcoinModal.vue +++ b/neode-ui/src/components/ReceiveBitcoinModal.vue @@ -107,7 +107,21 @@ const props = defineProps<{ const emit = defineEmits<{ close: []; received: []; scan: [] }>() watch(() => props.show, (open) => { - if (open && props.autoGenerate && receiveMethod.value === 'onchain' && !onchainAddress.value) { + if (!open) return + // Blank slate on every open: a leftover amount/memo/token or a previous + // invoice quietly carrying into a new receive flow is exactly the stale- + // state class the operator flagged on the send modal (2026-08-05). + receiveMethod.value = 'onchain' + invoiceAmount.value = 0 + invoiceMemo.value = '' + invoiceResult.value = '' + onchainAddress.value = '' + arkAddress.value = '' + ecashToken.value = '' + ecashResult.value = '' + error.value = '' + processing.value = false + if (props.autoGenerate && receiveMethod.value === 'onchain') { void receive() } }) diff --git a/neode-ui/src/components/SendBitcoinModal.vue b/neode-ui/src/components/SendBitcoinModal.vue index 52b0720d..e5fb240e 100644 --- a/neode-ui/src/components/SendBitcoinModal.vue +++ b/neode-ui/src/components/SendBitcoinModal.vue @@ -327,15 +327,57 @@ const isSweep = computed(() => sendMethod.value === 'onchain' && sendAll.value) function toggleSendAll() { sendAll.value = !sendAll.value - if (sendAll.value && onchainBalance.value === null) { - rpcClient.call<{ balance_sats: number }>({ method: 'lnd.getinfo', timeout: 5000 }) - .then((res) => { onchainBalance.value = res.balance_sats || 0 }) - .catch(() => { /* balance hint is best-effort */ }) + if (!sendAll.value) { + // Disarming clears the field — a swept-balance figure left behind reads + // as a typed amount. + amount.value = 0 + return } + // Arming shows the swept balance IN the (disabled) amount field — a field + // stuck at 0 while "send all" is lit read as "sending nothing" (operator + // feedback 2026-08-05). Refresh the figure on every arm. + const applyBalance = () => { + if (sendAll.value && onchainBalance.value !== null) amount.value = onchainBalance.value + } + applyBalance() + rpcClient.call<{ balance_sats: number }>({ method: 'lnd.getinfo', timeout: 5000 }) + .then((res) => { onchainBalance.value = res.balance_sats || 0; applyBalance() }) + .catch(() => { /* balance hint is best-effort */ }) } // Leaving the on-chain tab disarms the sweep so it can never apply elsewhere -watch(sendMethod, (m) => { if (m !== 'onchain') sendAll.value = false }) +// (and drops the swept-balance figure it wrote into the amount field). +watch(sendMethod, (m) => { + if (m !== 'onchain' && sendAll.value) { + sendAll.value = false + amount.value = 0 + } +}) + +// Every open starts from a blank slate. Stale state from the previous send — +// destination, amount, and above all an armed "send all funds" toggle — is +// dangerous to inherit invisibly (operator feedback 2026-08-05). +watch(() => props.show, (shown) => { + if (!shown) return + sendMethod.value = 'lightning' + amountUnit.value = 'sats' + amountEntry.value = 0 + dest.value = '' + error.value = '' + successInfo.value = null + ecashToken.value = '' + sendAll.value = false + onchainBalance.value = null + feePreset.value = 'standard' + customConfTarget.value = null + customSatPerVbyte.value = null + resolvedFeeParams.value = {} + feeEstimate.value = null + confirming.value = false + confirmBalance.value = null + invoiceAmountSats.value = null + processing.value = false +}) // --- On-chain network fee: presets map to LND confirmation targets; custom // --- takes a block target or an explicit sat/vB rate (rate wins). diff --git a/neode-ui/src/components/WalletScanModal.vue b/neode-ui/src/components/WalletScanModal.vue index 2107ec1d..cb41c2e6 100644 --- a/neode-ui/src/components/WalletScanModal.vue +++ b/neode-ui/src/components/WalletScanModal.vue @@ -51,10 +51,16 @@

How do you want to read the QR?

- +

+ Your browser only allows live camera on HTTPS pages — the photo and paste options below always work. +

diff --git a/neode-ui/src/views/settings/AccountInfoSection.vue b/neode-ui/src/views/settings/AccountInfoSection.vue index e79d46e8..3695cac2 100644 --- a/neode-ui/src/views/settings/AccountInfoSection.vue +++ b/neode-ui/src/views/settings/AccountInfoSection.vue @@ -362,6 +362,20 @@ init()
+ +
+
+ v1.7.125-alpha + August 6, 2026 +
+
+

The Lightning, Bitcoin, Electrum and mesh screens work again behind the login gate. Since the gate went up, those screens would load their frame and then show every number as unreachable. The gate was deliberately hiding your login from the apps it protects — right for third-party apps, wrong for the node's own screens, which need that login to fetch your data. The gate now removes only its own credential and the node's own screens explicitly receive yours. The same mistake was also quietly signing you out of apps with their own logins — Vaultwarden, Nextcloud, Gitea — on every single request; that stops too.

+

IndeeHub heals itself. Three separate faults: its database helper was recreated with permissions too tight to read its own files (it had crashed and restarted about ten thousand times on one node); on another node two of its seven parts could never be recreated at all because of how the node asked for their storage — the node would remove the old part and then fail to build its replacement, leaving the app half-missing forever; and a regenerated password could lock the app out of a database that keeps the original. All three are fixed, and the storage fault fixes the same trap for every future multi-part app.

+

Send and Receive open clean every time. Whatever you typed last — an address, an amount, and above all an armed "send all funds" toggle — no longer quietly carries over into the next payment. And choosing "send all funds" now shows the amount being swept instead of a confusing 0.

+

The camera scanner option no longer vanishes on desktop. Browsers only allow the live camera on secure (HTTPS) pages, and the scan window used to silently hide the camera choice on plain connections, which read as "the scanner is gone". The option now stays visible and explains itself, and the photo and paste routes always work. The companion app's built-in scanner is untouched.

+

Also: the app login page uses the Archipelago mark and stays centred on phones with the keyboard open, app icons in the install window are no longer cropped, and when the node fails to build a container it now records the actual reason instead of a one-line stub that hid the cause of the IndeeHub fault for days.

+
+
From 6110a7a9a7aa7b8322e0395a99a4d666c0ffbbc7 Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 5 Aug 2026 21:49:19 -0400 Subject: [PATCH 43/60] test: update stale drift guards (login-page A mark, 25 exempt ports) Both predate this session's changes and were masked by the release gate's cargo-test-weekly compile timeout: - login_page_sources_its_art_from_the_gate still asserted the retired wordmark (logo-archipelago.svg); the login page ships the sidebar A mark (favico-black-v2.svg) since the 2026-08-05 rework. - unauthenticated_ports_are_all_accounted_for lagged at 17; the v1.7.123 port-policy round grew the rationale-carrying exempt set to 25 (reviewed and enumerated in the test comment). Co-Authored-By: Claude Fable 5 --- core/archipelago/src/appgate/mod.rs | 5 ++++- core/container/src/manifest.rs | 10 +++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/core/archipelago/src/appgate/mod.rs b/core/archipelago/src/appgate/mod.rs index d9f01ca9..a3ed00ef 100644 --- a/core/archipelago/src/appgate/mod.rs +++ b/core/archipelago/src/appgate/mod.rs @@ -932,7 +932,10 @@ mod tests { ); } // Every referenced asset must be one the gate will actually serve. - assert!(read_ui_asset("logo-archipelago.svg").is_some() || cfg!(not(debug_assertions))); + // The logo is the sidebar A mark (favico-black-v2.svg) since the + // 2026-08-05 login-page rework — the old wordmark is off the + // allowlist on purpose. + assert!(read_ui_asset("favico-black-v2.svg").is_some() || cfg!(not(debug_assertions))); } /// The allowlist is the whole security boundary for asset serving: the diff --git a/core/container/src/manifest.rs b/core/container/src/manifest.rs index 09c3f731..7b3fc1ac 100644 --- a/core/container/src/manifest.rs +++ b/core/container/src/manifest.rs @@ -1717,9 +1717,17 @@ app: } } exempt.sort(); + // 25 as of the v1.7.123 port-policy round: bitcoin p2p (8333 ×2), + // core-lightning 9736/9835, electrumx 50001, fedimint 8173/8174, + // fedimint-gateway 8176/9737, gitea ssh 2222, lightning-stack + // 8091/9738/10010, lnd 9735/10009/18080, netbird 3478/8086/8087, + // pine TLS 10381 + the three voice ports (10200/10300/10400 — the + // disclosed known gap), router SSDP/mDNS 1900/5353. Every one is a + // deliberate, rationale-carrying exemption; the release-gate test + // stage timed out that cycle, so the count here lagged at 17. assert_eq!( exempt.len(), - 17, + 25, "unauthenticated port set changed — review before updating this count: {exempt:?}" ); } From d5ef4ef76e1254e8f6efac152eb55a9640e6158a Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 5 Aug 2026 22:16:13 -0400 Subject: [PATCH 44/60] chore(catalog): sign catalog with session_passthrough + indeedhub-redis caps Carries the two manifest-side halves of the .125 fix batch: the four companion UIs (lnd-ui, bitcoin-ui, electrs-ui, fips-ui) declare session_passthrough on their gated ports so the gate forwards the node session their nginx proxies to the daemon, and indeedhub-redis gains CHOWN+DAC_OVERRIDE so its entrypoint can traverse its own data dir. Co-Authored-By: Claude Fable 5 --- releases/app-catalog.json | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/releases/app-catalog.json b/releases/app-catalog.json index 44279454..364dfa3d 100644 --- a/releases/app-catalog.json +++ b/releases/app-catalog.json @@ -717,7 +717,8 @@ "bind": "127.0.0.1", "container": 8334, "host": 8334, - "protocol": "tcp" + "protocol": "tcp", + "session_passthrough": true } ], "resources": { @@ -1175,7 +1176,8 @@ "bind": "127.0.0.1", "container": 50002, "host": 50002, - "protocol": "tcp" + "protocol": "tcp", + "session_passthrough": true } ], "resources": { @@ -1732,7 +1734,8 @@ "bind": "127.0.0.1", "container": 8336, "host": 8336, - "protocol": "tcp" + "protocol": "tcp", + "session_passthrough": true } ], "resources": { @@ -2722,6 +2725,8 @@ }, "security": { "capabilities": [ + "CHOWN", + "DAC_OVERRIDE", "SETGID", "SETUID" ], @@ -3120,7 +3125,8 @@ "bind": "127.0.0.1", "container": 18083, "host": 18083, - "protocol": "tcp" + "protocol": "tcp", + "session_passthrough": true } ], "resources": { @@ -4891,7 +4897,7 @@ } }, "schema": 1, - "signature": "a8502a3664500e6f22e47d2f29456cde208c0cf117767083be8ec6c238f73e546ae33ccea0c100573cfd9a474614da03903d5be6b7cae72bb3c5439d2b510104", + "signature": "a9a0bf60aa6c47ae970a6c7c3e19e9390ee7af157f425c2e38b1bbb194f0315b73ddf16bc46244b36390454787a1ddecc559be806098fdf569d3315f388b9006", "signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT", - "updated": "2026-08-05" + "updated": "2026-08-06" } From 92cffe9d46c0fe56cec99b8f6623d4dc39d08386 Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 5 Aug 2026 22:50:42 -0400 Subject: [PATCH 45/60] fix(reconciler): recreate an absent stack member when its siblings are live MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The periodic reconcile runs ExistingOnly — merely listing a catalog manifest must never install an app — and its only absent-container recovery keyed on the last running-names snapshot, which ages out after a few daemon restarts. An absent member of an installed stack then stays absent forever: .38 ran indeedhub with no minio/postgres for days, nginx down on 'host not found in upstream "minio"', and nothing ever put the members back. A live sibling container is proof the stack is installed on this node, so an absent member is now treated as a hole to repair, not a choice to respect: the recovery guard also fires when another member of the same stack (app_ops::stack_member_app_ids) has a container in any state. A stack with no containers at all is left untouched, and sibling app ids resolve through the loaded-manifest container names (immich-postgres runs as immich_postgres). Co-Authored-By: Claude Fable 5 --- core/archipelago/src/app_ops.rs | 3 +- .../src/container/prod_orchestrator.rs | 123 +++++++++++++++++- 2 files changed, 121 insertions(+), 5 deletions(-) diff --git a/core/archipelago/src/app_ops.rs b/core/archipelago/src/app_ops.rs index c036d863..7311ade7 100644 --- a/core/archipelago/src/app_ops.rs +++ b/core/archipelago/src/app_ops.rs @@ -75,7 +75,8 @@ pub fn address_caching_dependents(package_id: &str) -> &'static [&'static str] { /// The package whose lifecycle lock covers `app_id`: the stack package when /// `app_id` is a member (RPC ops on "mempool" hold the "mempool" lock while /// they drive archy-mempool-web), otherwise the app itself. -fn owning_package(app_id: &str) -> &str { +/// Also consulted by the reconciler's absent-stack-member recovery. +pub fn owning_package(app_id: &str) -> &str { const STACKS: &[&str] = &[ "immich", "indeedhub", diff --git a/core/archipelago/src/container/prod_orchestrator.rs b/core/archipelago/src/container/prod_orchestrator.rs index 2eb4dc12..0d66db21 100644 --- a/core/archipelago/src/container/prod_orchestrator.rs +++ b/core/archipelago/src/container/prod_orchestrator.rs @@ -104,6 +104,32 @@ fn dependency_manifests_required_by_active_apps<'a>( required } +/// Whether `app_id` is a member of a known multi-container stack that has at +/// least one OTHER member with a live container (any state). A live sibling +/// proves the stack is installed on this node, so an absent member is a hole +/// to repair — while a stack with no containers at all stays untouched +/// (uninstalled, or never installed here). Sibling app ids resolve to +/// container names through the loaded-manifest map when available (immich's +/// `immich-postgres` app id runs as container `immich_postgres`), falling +/// back to the id itself. +fn absent_stack_member_with_live_sibling( + app_id: &str, + present_containers: &HashSet, + container_name_by_app_id: &std::collections::HashMap, +) -> bool { + let stack = crate::app_ops::owning_package(app_id); + let members = crate::app_ops::stack_member_app_ids(stack); + members.iter().any(|member| { + *member != app_id + && present_containers.contains( + container_name_by_app_id + .get(*member) + .map(String::as_str) + .unwrap_or(member), + ) + }) +} + fn manifest_dependency_app_ids(manifest: &AppManifest) -> Vec { manifest .app @@ -1654,13 +1680,16 @@ impl ProdContainerOrchestrator { // app whose container vanished (e.g. a wedged teardown cleared by a // reboot) instead of leaving it down. See the immich .198 incident. let was_running = crate::crash_recovery::load_last_running_names(&self.data_dir).await; - let manifests: Vec = { + let (manifests, container_name_by_app_id): ( + Vec, + std::collections::HashMap, + ) = { let state = self.state.read().await; let dependency_required = dependency_manifests_required_by_active_apps( state.manifests.values().map(|lm| &lm.manifest), &user_stopped, ); - state + let filtered = state .manifests .iter() .filter(|(app_id, _)| !state.disabled.contains(*app_id)) @@ -1670,8 +1699,25 @@ impl ProdContainerOrchestrator { && !user_stopped.contains(&compute_container_name(&lm.manifest))) }) .map(|(_, lm)| lm.clone()) - .collect() + .collect(); + // Unfiltered id→container-name map for the absent-stack-member + // recovery below: a sibling may be excluded from this pass (e.g. + // user-stopped) yet its live container still proves the stack is + // installed. + let names = state + .manifests + .iter() + .map(|(id, lm)| (id.clone(), compute_container_name(&lm.manifest))) + .collect(); + (filtered, names) }; + // Live container names (any state), for the same recovery check. + let present_containers: std::collections::HashSet = self + .runtime + .list_containers() + .await + .map(|cs| cs.into_iter().map(|c| c.name).collect()) + .unwrap_or_default(); let mut report = ReconcileReport::default(); let disk_gb = self.disk_gb().await; // Register every candidate before the (sequential, possibly slow) @@ -1738,7 +1784,20 @@ impl ProdContainerOrchestrator { Ok(ReconcileAction::Left(reason)) if mode == ReconcileMode::ExistingOnly && reason == "absent" - && was_running.contains(&compute_container_name(&lm.manifest)) => + && (was_running.contains(&compute_container_name(&lm.manifest)) + // Absent STACK MEMBER whose siblings have live + // containers: the stack is installed, so the + // missing member is a hole, not a choice. The + // was_running snapshot ages out after a few daemon + // restarts, which left indeedhub-minio/-postgres + // permanently absent on .38 (2026-08-06) — nginx + // down on `host not found in upstream "minio"` + // with nothing ever recreating the members. + || absent_stack_member_with_live_sibling( + &app_id, + &present_containers, + &container_name_by_app_id, + )) => { tracing::warn!( app_id = %app_id, @@ -4451,6 +4510,62 @@ mod tests { items.iter().map(|s| s.to_string()).collect() } + /// The .38 indeedhub incident class: an absent stack member must be + /// recovered when its siblings have live containers (the stack is + /// installed), and left alone when the whole stack is gone or the app + /// is not a stack member at all. + #[test] + fn absent_stack_member_recovery_requires_a_live_sibling() { + let present: HashSet = ["indeedhub-redis", "indeedhub-relay", "indeedhub"] + .iter() + .map(|s| s.to_string()) + .collect(); + let names = std::collections::HashMap::new(); + // Missing members of a stack with live siblings → recover. + assert!(absent_stack_member_with_live_sibling( + "indeedhub-minio", + &present, + &names + )); + assert!(absent_stack_member_with_live_sibling( + "indeedhub-postgres", + &present, + &names + )); + // Whole stack absent → NOT recovered (uninstalled stays uninstalled). + let empty = HashSet::new(); + assert!(!absent_stack_member_with_live_sibling( + "indeedhub-minio", + &empty, + &names + )); + // Non-stack app → never. + assert!(!absent_stack_member_with_live_sibling( + "vaultwarden", + &present, + &names + )); + // An app's OWN container being present proves nothing about siblings. + let only_self: HashSet = + std::iter::once("indeedhub-minio".to_string()).collect(); + assert!(!absent_stack_member_with_live_sibling( + "indeedhub-minio", + &only_self, + &names + )); + // App-id → container-name mapping is honoured (immich_postgres runs + // under an underscore name while its app id is hyphenated). + let mut mapped = std::collections::HashMap::new(); + mapped.insert("immich-postgres".to_string(), "immich_postgres".to_string()); + let immich_present: HashSet = + std::iter::once("immich_postgres".to_string()).collect(); + assert!(absent_stack_member_with_live_sibling( + "immich-redis", + &immich_present, + &mapped + )); + } + #[test] fn command_drift_tolerates_quadlet_entrypoint_split() { // Quadlet writes Entrypoint=sh + Exec=-lc "