Compare commits

...
Author SHA1 Message Date
archipelago e2f83c0157 chore: release v1.7.105-alpha 2026-07-20 03:40:38 -04:00
archipelagoandClaude Fable 5 d5f709a3c3 docs(release): curated v1.7.105-alpha changelog + What's New block
Demo images / Build & push demo images (push) Successful in 2m43s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 03:38:23 -04:00
archipelagoandClaude Fable 5 710f576c77 fix(ui): satisfy noUncheckedIndexedAccess in the WG retry ladder
Demo images / Build & push demo images (push) Successful in 2m43s
vue-tsc in the release build (unlike the gate's type-check) rejects
indexing WG_RETRY_DELAYS_MS with a bare counter; hoist the delay into a
local and gate on undefined instead of the length check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 02:52:43 -04:00
archipelagoandClaude Fable 5 c1d309f21f style: cargo fmt crash_recovery.rs
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 02:28:49 -04:00
archipelago 9c39243969 Merge remote-tracking branch 'gitea-ai/fix/companion-autologin-replay-intro'
Demo images / Build & push demo images (push) Successful in 2m49s
2026-07-20 01:57:19 -04:00
archipelagoandClaude Fable 5 f25febf3bb fix(vpn): refresh a stored peer's WireGuard endpoint to the node's current IP
vpn.peer-config returned the config exactly as stored at creation time, so
after a node moves networks the reused companion peer's QR encodes the old
location's address (seen on .116: Endpoint = 10.125.9.0 from the previous
LAN) and the tunnel can never connect. Rewrite the Endpoint line with the
node's current address before rendering the QR, and persist it back so the
downloadable .conf matches. Endpoint detection is shared with
vpn.create-peer via current_wg_endpoint_host().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 01:57:08 -04:00
DorianandClaude Fable 5 0636d611e0 fix(ui): auto-retry the tunnel-QR provisioning while a first install settles
On a fresh node the wgqr step is often reached while the backend is still
settling (services starting, backend restarting during orchestration): a
single 'Failed to fetch' left a permanently blank QR. Retry network-class
failures on a 2s/4s/8s ladder while the step is still on screen, then fall
back to the friendly error + Try again button.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 06:45:34 +01:00
archipelagoandClaude Fable 5 f13fdc6451 fix(recovery): don't brick-loop startup on stale crash-snapshot containers
After an unclean shutdown, crash recovery walked the running-containers
snapshot and retried 'no such container' failures (2 attempts, 10s
backoff) for containers that no longer exist. Recovery runs before the
server binds :5678 and notifies systemd ready, so a stale snapshot
pushed startup past TimeoutStartSec=5min — systemd killed the daemon
mid-recovery, the next boot saw a crash again, and the node looped
forever (151 restarts on .116 after a hard poweroff).

- pre-filter the snapshot against 'podman ps -a' and skip vanished
  containers outright (fail-open if the query fails)
- never retry a 'no such container' failure
- extend the systemd start timeout ahead of each container start so a
  heavy node recovering dozens of real containers isn't killed while
  making progress

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 01:32:42 -04:00
archipelagoandClaude Fable 5 163bc3af01 fix(tor): stop listing/pre-baking hidden services for uninstalled apps (#79)
Backend: tor.list-services now hides services whose name maps to a
known-but-uninstalled catalog app (aliases covered: bitcoin/knots/core,
electrumx/electrs, btcpay, mempool). The node's own service, the relay,
and custom user services always show.

ISO: first-boot tor setup no longer pre-creates hidden services for six
apps that may never be installed — only the node's own onion is baked.
It also only seeds services.json/torrc on FIRST boot instead of
clobbering backend-managed services on every boot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 01:32:33 -04:00
DorianandClaude Fable 5 ae12ff2517 fix(iso): fail builds on missing VPN binaries, skip units cleanly, invalidate stale rootfs cache
v1.7.104 shipped ISOs whose units crash-looped (nostr-relay 3s loop,
archipelago-diag 203/EXEC) because build-time extraction failures were mere
warnings and the cached rootfs never tracked its recipe:

- hash the rootfs-defining region of the build script and rebuild when it
  changes (stale cache shipped ISOs without wpasupplicant/iw/rfkill)
- refuse to build without nvpn / nostr-rs-relay unless
  ALLOW_MISSING_VPN_BINARIES=1
- ConditionPathExists on nostr-relay/nostr-vpn/diag units so a stripped
  image skips them instead of crash-looping
- post-install test: every enabled archipelago*/nostr* unit must have an
  existing ExecStart payload

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 06:08:04 +01:00
DorianandClaude Fable 5 cf4a8eef0e fix(core): throttle volume-ownership sweep to first-seen + hourly per container
The sweep podman-execs into every running container; doing that on each 30s
reconcile tick was a permanent conmon 'Failed to create container' storm on
hosts where exec from the backend's cgroup context fails (Debian 13 first
boot). Ownership drift is an install/OTA-time event — sweep on first pass
after a container appears, then at most hourly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 06:07:56 +01:00
DorianandClaude Fable 5 e5f8b5d789 fix(ui): keep kiosk-mode selectors fully inside :global() (v1.7.104 white screen)
With :global(html.kiosk-mode) .bg-layer the SFC compiler drops the
descendant part and emits bare html.kiosk-mode rules — including
display: none !important — blanking the whole document on kiosk.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 06:07:42 +01:00
DorianandClaude Fable 5 aad6faa6d2 fix(ui): harden companion-overlay WireGuard provisioning and hide it in the companion app
- Never show the get-the-app pitch inside the companion WebView itself
- Don't guess peer-exists when list-peers is unreachable: try create,
  fall back to peer-config on already-exists errors
- Translate raw 'Failed to fetch' into an actionable network hint and
  add a Try again button instead of a dead-end error

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 06:07:35 +01:00
DorianandClaude Fable 5 20edd31abb fix(ui): play the intro on backend-confirmed fresh nodes despite stale browser flags
neode_intro_seen / neode_onboarding_complete are per-origin browser state:
after a reinstall (or another node on a DHCP-recycled IP) they describe the
previous node and muted a genuinely fresh install's intro. Root boots now
always ask the backend; a confirmed-fresh answer plays the intro and clears
the stale flag.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 06:07:28 +01:00
archipelagoandClaude Fable 5 9a7331cead fix(cli): --version/-V and --help print and exit instead of booting the daemon
A stray `archipelago --version` used to start a full second instance next
to the systemd one (issue #74). Handle -V/--version, -h/--help, and reject
unknown dashed options before any tracing/state init, mirroring the
ceremony subcommand's clean-stdout precedent. Version output matches the
health RPC format: <pkg-version>-<git-hash|dev>.

Fixes #74

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 16:49:12 -04:00
archipelagoandClaude Fable 5 9eadec6936 chore: sign v1.7.104-alpha OTA manifest
Demo images / Build & push demo images (push) Successful in 2m36s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 02:27:55 -04:00
archipelago 6b0a84e710 chore: release v1.7.104-alpha 2026-07-19 02:17:45 -04:00
archipelagoandClaude Fable 5 fd361fb35e test(update): serialize the apply_update regression tests
Both tests take the global single-flight UPDATE_OP_LOCK; run concurrently
by the test harness, one saw the other's lock and failed with 'another
update operation is already running' instead of its expected refusal.
A shared test mutex makes them mutually exclusive deterministically.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 02:01:58 -04:00
archipelagoandClaude Fable 5 8bb61a51e2 style: cargo fmt update.rs
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 00:32:57 -04:00
archipelagoandClaude Fable 5 17d225190a docs: v1.7.104-alpha changelog + What's New sync
Demo images / Build & push demo images (push) Successful in 2m42s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 00:31:12 -04:00
archipelagoandClaude Fable 5 d08c0d29c7 fix(ui): ElectrumX sync screen shows the app's icon instead of a generic glyph
Demo images / Build & push demo images (push) Has been cancelled
The pre-UI sync overlay used a hardcoded orange box-glyph SVG; it now renders
the same resolved app icon the loading screen uses (with the shared image-error
fallback), so the wait page reads as ElectrumX at a glance.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 00:29:46 -04:00
archipelagoandClaude Fable 5 a93bd70c5a fix(electrumx): probe bitcoin-knots/bitcoin-core for the daemon host instead of hardcoding knots
The manifest baked bitcoin-knots:8332 into DAEMON_URL. Nodes whose
backend runs under the bitcoin-core name (and any future variant
without a knots DNS alias) left electrumx permanently disconnected —
'connection problem' forever and a block index stuck at 0. The
startup script now picks the first backend name that resolves on
archy-net and falls back to bitcoin-knots.

Catalog regenerated (catalog manifests override disk ones fleet-wide);
this regen also embeds the recently-merged barkd/Ark manifest for the
first time.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:55:06 -04:00
archipelagoandClaude Fable 5 25162ee846 feat(update): auto-rollback OTA binaries that crash-loop before they can self-verify
.198 crash-looped 236x on a truncated OTA binary with a good backup
sitting in update-backup/ — verify_pending_update() can't help when
the new binary never runs. New scripts/ota-crash-guard.sh runs as
root from ExecStartPre: while the post-OTA pending-verify marker
exists it counts start attempts, and after 5 failures restores the
backup binary (atomic rename), replaces the marker with an
update-rolled-back.json tombstone, and lets the service come back
on the previous version.

Wiring: fresh ISOs get the ExecStartPre line in the unit file;
existing nodes get a drop-in installed by apply_update's runtime
component step on their next OTA. '+-' prefix so a missing or
failing guard can never block the service.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:42:54 -04:00
archipelagoandClaude Fable 5 837cfdfd1f fix(update): never apply unverified staging — close the OTA truncated-binary race
Root cause of .198 bricking on the 1.7.103 OTA: two concurrent
update.download RPCs shared one staging file, cancel_download wiped
staging mid-flight, a third download began re-filling it, and
apply_update mv'd the 3-second-old 17MB partial of the 49MB binary
into /usr/local/bin -> SEGV boot loop (236 restarts, no rollback).

- Single-flight UPDATE_OP_LOCK across download/apply; concurrent
  callers get an explicit 'already running' error.
- apply_update now requires the .download-complete marker AND
  re-verifies every staged component (size + SHA-256 + BLAKE3)
  against the manifest before touching the system.
- cancel_download only wipes staging when no operation holds the
  lock; otherwise it just flags the in-flight loop to bail.
- Fixed the 'file already complete' path in
  download_component_resumable, which skipped verification and fell
  through to the bogus 'download failed without a captured error'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:33:52 -04:00
archipelagoandClaude Fable 5 573b469191 chore: sign v1.7.103-alpha OTA manifest
Demo images / Build & push demo images (push) Successful in 2m53s
Also folds the Cargo.lock version bump that create-release.sh missed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 07:48:56 -04:00
archipelago 401f92a24f chore: release v1.7.103-alpha 2026-07-18 07:30:25 -04:00
archipelagoandClaude Fable 5 dc0adbef70 docs: v1.7.103-alpha changelog + What's New sync
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 07:01:45 -04:00
archipelagoandClaude Fable 5 537c9fa70b fix(demo): suppress the PWA update prompt + reload on the public demo
The SW-update modal is noise on the demo (nothing to update to) and both
accept-paths end in a reload that replays the intro from scratch. With
skipWaiting/clientsClaim off, ignoring the waiting worker is safe — the
new build activates on the visitor's next session.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 07:01:45 -04:00
DorianandClaude Fable 5 b6468ebf3c fix(android): let Vue re-render before auto-login submits the password
Dispatch the Enter keydown two frames after the input event so the login
button is enabled when it arrives. Keeps auto-login working against nodes
running older web builds where controller-nav's Enter-in-input pattern
would otherwise click Replay Intro (see the matching web-ui fix).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 11:54:12 +01:00
DorianandClaude Fable 5 70587210fb fix(ui): stop controller-nav Enter from clicking Replay Intro on auth inputs
The companion's auto-login fills the password and dispatches Enter in the
same tick, before Vue re-enables the disabled submit button. Controller-nav's
'Enter in input clicks the next enabled button' pattern then hit the Replay
Intro button — clearing neode_intro_seen and hard-navigating to /, so every
app connect replayed the intro cinematic in a loop. The auth inputs all have
their own Enter handlers, so they opt out via data-controller-no-submit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 11:54:12 +01:00
32 changed files with 1010 additions and 188 deletions
@@ -741,7 +741,16 @@ private fun buildAutoLoginScript(password: String): String {
var setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
setter.call(el, pw);
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
// Let Vue re-render before submitting: a synchronous Enter arrives
// while the login button is still disabled, and the web UI's
// controller-nav "Enter in input clicks the next enabled button"
// pattern then hits Replay Intro instead — restarting the intro
// cinematic on every connect (two frames = value flush + render).
requestAnimationFrame(function () {
requestAnimationFrame(function () {
el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
});
});
}, 1500);
})();
""".trimIndent()
+23
View File
@@ -1,5 +1,28 @@
# Changelog
## v1.7.105-alpha (2026-07-20)
- Fixed a failure loop where a node that lost power or was moved could get stuck on a blank "can't reach your node" screen forever: startup recovery no longer spends minutes retrying containers that no longer exist, and a genuinely large recovery is no longer cut off half-way and forced to start over. The node now reaches its login screen even after the messiest shutdown.
- Phone tunnel setup (WireGuard) is now dependable: the QR screen automatically retries while a fresh install is still settling instead of dead-ending at "failed to fetch", and if your node has moved to a different network the QR and downloadable config now carry the node's current address instead of the old one.
- Fixed the white screen some laptop displays showed right after the intro on v1.7.104.
- The companion phone app no longer suggests installing the companion app from inside itself.
- The Tor page now lists onion addresses only for apps you actually have installed — fresh installs no longer come with six pre-made addresses for apps that were never set up.
- Running `archipelago --version` or `--help` on the command line now prints and exits instead of silently starting a second copy of the node, which could briefly disrupt running apps.
- Behind the scenes: installer image builds now stop loudly if VPN components are missing instead of producing a broken image, and a background file-permission sweep runs far less often, reducing disk churn on busy nodes.
## v1.7.104-alpha (2026-07-19)
- Software updates are now much safer to receive: the node will never install an update that isn't completely downloaded and verified byte-for-byte, closing a rare bug where an interrupted or cancelled download could leave a node unable to start.
- If a freshly installed update does fail to start, the node now notices and automatically restores the previous working version by itself — no manual rescue needed.
- The Electrum server now works with whichever Bitcoin you run: it finds Bitcoin Knots or Bitcoin Core automatically instead of assuming Knots.
- While the Electrum server is first building its index, its waiting screen now shows the ElectrumX app icon and live progress.
## v1.7.103-alpha (2026-07-18)
- Connecting from the phone app no longer replays the intro cinematic on a loop: signing in after scanning the pairing QR could accidentally trigger "Replay Intro" instead of logging you in. The companion app now lands you straight on your dashboard, and the Android app waits for the login screen to be ready before it types your password.
- Pressing Enter in any password box now does what you expect — it signs you in or moves to the next field, and can no longer "click" a nearby button by mistake when using a controller or the companion app.
- The public demo no longer interrupts you with an "Update Available" popup that reset the site back to the intro — demo visitors simply get the newest version on their next visit.
## v1.7.102-alpha (2026-07-17)
- The password you choose during setup is now truly your node's password: it also becomes the system login for console and SSH access, instead of leaving the factory default in place. If you ever renamed your node and the TV screen went black on the next boot, that's fixed too — renaming no longer breaks the kiosk display.
+8 -1
View File
@@ -10,9 +10,16 @@ app:
network: archy-net
data_uid: "1000:1000"
entrypoint: ["sh", "-lc"]
# The bitcoin backend container is bitcoin-knots OR bitcoin-core depending
# on which version the node runs (multi-version switch) — probe which name
# resolves on archy-net instead of hardcoding knots, which left electrumx
# permanently disconnected (block index 0) on core nodes.
custom_args:
- >-
export DAEMON_URL="http://archipelago:$(printenv BITCOIN_RPC_PASS)@bitcoin-knots:8332/";
for h in bitcoin-knots bitcoin-core; do
if getent hosts "$h" >/dev/null 2>&1; then BTC_HOST="$h"; break; fi;
done;
export DAEMON_URL="http://archipelago:$(printenv BITCOIN_RPC_PASS)@${BTC_HOST:-bitcoin-knots}:8332/";
exec electrumx_server
secret_env:
- key: BITCOIN_RPC_PASS
+1 -1
View File
@@ -95,7 +95,7 @@ dependencies = [
[[package]]
name = "archipelago"
version = "1.7.102-alpha"
version = "1.7.104-alpha"
dependencies = [
"anyhow",
"archipelago-container",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "archipelago"
version = "1.7.102-alpha"
version = "1.7.105-alpha"
edition = "2021"
description = "Archipelago Bitcoin Node OS - Native backend"
authors = ["Archipelago Team"]
+13 -1
View File
@@ -4,9 +4,21 @@ use std::time::{SystemTime, UNIX_EPOCH};
impl RpcHandler {
/// List all configured hidden services with their .onion addresses.
/// Services for known-but-uninstalled apps are hidden (issue #79).
pub(in crate::api::rpc) async fn handle_tor_list_services(&self) -> Result<serde_json::Value> {
let config_dir = self.config.data_dir.join("tor-config");
let services = list_services(&config_dir).await?;
let (data, _) = self.state_manager.get_snapshot().await;
let mut apps = AppInstallState {
known: Default::default(),
installed: Default::default(),
};
for (id, pkg) in &data.package_data {
apps.known.insert(id.clone());
if pkg.installed.is_some() {
apps.installed.insert(id.clone());
}
}
let services = list_services(&config_dir, Some(&apps)).await?;
let tor_running = check_tor_running().await;
Ok(serde_json::json!({ "services": services, "tor_running": tor_running }))
}
+58 -3
View File
@@ -228,15 +228,67 @@ pub(super) async fn sync_all_hostname_copies(config: &ServicesConfig) {
// ─── Service Listing ─────────────────────────────────────────────
pub(super) async fn list_services(config_dir: &std::path::Path) -> Result<Vec<TorService>> {
/// Which packages the node knows about and which are installed — used to
/// hide hidden services for apps that aren't installed. ISO first-boot used
/// to pre-bake onions for a fixed app list (bitcoin/electrumx/lnd/btcpay/
/// mempool/fedimint), so fresh nodes showed Tor sites for apps that were
/// never installed (issue #79).
pub(super) struct AppInstallState {
pub known: std::collections::HashSet<String>,
pub installed: std::collections::HashSet<String>,
}
/// Package ids a Tor service name may correspond to. Service names predate
/// the catalog app ids (the ISO baked "bitcoin"/"btcpay"), so one service
/// can map to several package ids.
fn service_alias_candidates(name: &str) -> Vec<&str> {
match name {
"bitcoin" | "bitcoin-knots" | "bitcoin-core" => {
vec!["bitcoin", "bitcoin-knots", "bitcoin-core"]
}
"electrumx" | "electrs" | "mempool-electrs" => {
vec!["electrumx", "electrs", "mempool-electrs"]
}
"btcpay" | "btcpay-server" | "btcpayserver" => {
vec!["btcpay", "btcpay-server", "btcpayserver"]
}
"mempool" | "mempool-web" => vec!["mempool", "mempool-web"],
other => vec![other],
}
}
impl AppInstallState {
/// A service is listed unless it names a known-but-uninstalled app.
/// The node's own service, the content relay, and custom user-created
/// services (names matching no catalog package) always show.
fn service_visible(&self, name: &str) -> bool {
if name == "archipelago" || name == "relay" {
return true;
}
let candidates = service_alias_candidates(name);
if !candidates.iter().any(|c| self.known.contains(*c)) {
return true; // not an app — custom hidden service
}
candidates.iter().any(|c| self.installed.contains(*c))
}
}
pub(super) async fn list_services(
config_dir: &std::path::Path,
apps: Option<&AppInstallState>,
) -> Result<Vec<TorService>> {
let base = detect_hidden_service_base();
let config = load_services_config(config_dir).await;
let mut services = Vec::new();
let mut seen = std::collections::HashSet::new();
let visible = |name: &str| apps.map(|a| a.service_visible(name)).unwrap_or(true);
for entry in &config.services {
let onion = read_onion_address(&entry.name).await;
seen.insert(entry.name.clone());
if !visible(&entry.name) {
continue;
}
let onion = read_onion_address(&entry.name).await;
services.push(TorService {
name: entry.name.clone(),
local_port: entry.local_port,
@@ -260,9 +312,12 @@ pub(super) async fn list_services(config_dir: &std::path::Path) -> Result<Vec<To
if seen.contains(&service_name) {
continue;
}
seen.insert(service_name.clone());
if !visible(&service_name) {
continue;
}
let onion = read_onion_address(&service_name).await;
let port = known_service_port(&service_name);
seen.insert(service_name.clone());
let is_proto = is_protocol_service(&service_name);
services.push(TorService {
name: service_name,
+46 -18
View File
@@ -437,6 +437,23 @@ impl RpcHandler {
Ok(serde_json::json!({ "added": true, "npub": npub }))
}
/// The host address a WireGuard peer should dial — prefer the configured
/// host IP, then public-IP lookup, then first local address.
async fn current_wg_endpoint_host(&self) -> String {
if self.config.host_ip != "127.0.0.1" {
return self.config.host_ip.clone();
}
tokio::process::Command::new("sh")
.arg("-c")
.arg("curl -s --connect-timeout 5 https://api.ipify.org 2>/dev/null || hostname -I | awk '{print $1}'")
.output()
.await
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| self.config.host_ip.clone())
}
/// vpn.create-peer — Generate a WireGuard peer config + QR code for mobile devices.
pub(super) async fn handle_vpn_create_peer(
&self,
@@ -501,22 +518,7 @@ impl RpcHandler {
.ok_or_else(|| anyhow::anyhow!("Cannot read server public key"))?
};
// Detect host IP — prefer config, then nvpn, then system detection
let host_ip = if self.config.host_ip != "127.0.0.1" {
self.config.host_ip.clone()
} else {
// Fallback: get public IP via external service
tokio::process::Command::new("sh")
.arg("-c")
.arg("curl -s --connect-timeout 5 https://api.ipify.org 2>/dev/null || hostname -I | awk '{print $1}'")
.output()
.await
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| self.config.host_ip.clone())
};
let endpoint = format!("{}:51820", host_ip);
let endpoint = format!("{}:51820", self.current_wg_endpoint_host().await);
// Allocate a peer IP (simple: hash the peer name)
let peer_num = (name.bytes().map(|b| b as u32).sum::<u32>() % 253) + 2;
@@ -667,15 +669,41 @@ impl RpcHandler {
let content = tokio::fs::read_to_string(&peer_file)
.await
.map_err(|_| anyhow::anyhow!("Peer '{}' not found", name))?;
let peer: serde_json::Value = serde_json::from_str(&content)?;
let mut peer: serde_json::Value = serde_json::from_str(&content)?;
let config = peer.get("config").and_then(|v| v.as_str()).ok_or_else(|| {
let stored = peer.get("config").and_then(|v| v.as_str()).ok_or_else(|| {
anyhow::anyhow!(
"No config stored for peer '{}' — recreate the device to get a new QR code",
name
)
})?;
// The stored Endpoint is the node's address at creation time; after
// the node moves networks it points at a dead IP and the QR produces
// a tunnel that can never connect. Refresh it to the current address.
let endpoint = format!("{}:51820", self.current_wg_endpoint_host().await);
let config: String = stored
.lines()
.map(|l| {
if l.trim_start().starts_with("Endpoint") {
format!("Endpoint = {}", endpoint)
} else {
l.to_string()
}
})
.collect::<Vec<_>>()
.join("\n");
if config != stored {
if let Some(obj) = peer.as_object_mut() {
obj.insert("config".to_string(), config.clone().into());
}
if let Ok(json) = serde_json::to_string_pretty(&peer) {
if tokio::fs::write(&peer_file, json).await.is_ok() {
info!("VPN peer '{}' endpoint refreshed to {}", name, endpoint);
}
}
}
let qr = qrcode::QrCode::new(config.as_bytes())
.map_err(|e| anyhow::anyhow!("QR generation failed: {}", e))?;
let svg = qr
@@ -301,6 +301,33 @@ fn unrepairable_ownership() -> &'static std::sync::Mutex<std::collections::HashS
SET.get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new()))
}
/// Per-container timestamp of the last volume-ownership sweep. The sweep's
/// write-probes are `podman exec`s into EVERY running container; running them
/// on every 30s reconcile tick meant six-plus cross-context exec attempts per
/// tick forever — a permanent conmon "Failed to create container" storm on
/// hosts where exec from the backend's cgroup context fails (Debian 13 first
/// boot, 2026-07-19). Ownership drift is an install/OTA-time event, not a
/// steady-state one: sweep each container on the first pass after it appears,
/// then at most once per hour.
fn ownership_sweep_due(name: &str) -> bool {
const SWEEP_INTERVAL: std::time::Duration = std::time::Duration::from_secs(60 * 60);
static LAST: std::sync::OnceLock<
std::sync::Mutex<std::collections::HashMap<String, std::time::Instant>>,
> = std::sync::OnceLock::new();
let map = LAST.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()));
let Ok(mut map) = map.lock() else {
return true;
};
let now = std::time::Instant::now();
match map.get(name) {
Some(last) if now.duration_since(*last) < SWEEP_INTERVAL => false,
_ => {
map.insert(name.to_string(), now);
true
}
}
}
/// App-agnostic, userns-mapping-proof volume-ownership repair for a RUNNING
/// container.
///
@@ -1739,6 +1766,11 @@ impl ProdContainerOrchestrator {
if crate::app_ops::lifecycle_op_in_flight(&c.name) {
continue;
}
// Throttled: first pass after the container appears, then
// hourly — not on every 30s tick (see ownership_sweep_due).
if !ownership_sweep_due(&c.name) {
continue;
}
if ensure_running_container_ownership(&c.name).await {
tracing::info!(container = %c.name, "volume ownership repaired during reconcile — restarting to recover");
let _ = tokio::process::Command::new("podman")
+56
View File
@@ -372,6 +372,28 @@ pub async fn save_container_snapshot(data_dir: &Path) -> Result<()> {
/// Recover containers that were running before a crash.
/// Attempts to start each container, logging success/failure.
pub async fn recover_containers(containers: &[RunningContainerRecord]) -> RecoveryReport {
// Snapshot entries can outlive their containers (removed while we were
// down, or podman storage partially reset by an unclean poweroff).
// `podman start` on those fails permanently, and recovery runs BEFORE the
// server binds its port and notifies systemd ready — burning retries on
// them pushed recovery past TimeoutStartSec and brick-looped the node
// (killed mid-recovery → next boot sees a crash again, forever).
let containers: Vec<&RunningContainerRecord> = match existing_container_names().await {
Some(existing) => {
let (present, missing): (Vec<_>, Vec<_>) =
containers.iter().partition(|r| existing.contains(&r.name));
if !missing.is_empty() {
warn!(
"Skipping {} snapshot container(s) that no longer exist: {:?}",
missing.len(),
missing.iter().map(|r| r.name.as_str()).collect::<Vec<_>>()
);
}
present
}
None => containers.iter().collect(),
};
let mut report = RecoveryReport {
total: containers.len(),
recovered: 0,
@@ -386,6 +408,15 @@ pub async fn recover_containers(containers: &[RunningContainerRecord]) -> Recove
record.name, record.image
);
// Recovery counts against systemd's start timeout; a heavy node
// legitimately needs several minutes for dozens of containers. Push
// the deadline out ahead of each container so systemd only kills us
// if we stop making progress (360s covers one full attempt chain).
let _ = sd_notify::notify(
false,
&[sd_notify::NotifyState::ExtendTimeoutUsec(360_000_000)],
);
// Rate-limit container starts to avoid overwhelming podman on low-resource systems
if i > 0 {
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
@@ -427,6 +458,11 @@ pub async fn recover_containers(containers: &[RunningContainerRecord]) -> Recove
attempt + 1,
stderr.trim()
);
// The container is gone (raced past the pre-filter, or the
// filter query failed) — retrying can never succeed.
if stderr.contains("no such container") {
break;
}
}
Err(e) => {
warn!(
@@ -448,6 +484,26 @@ pub async fn recover_containers(containers: &[RunningContainerRecord]) -> Recove
report
}
/// All container names podman knows about (running or not). `None` if the
/// query fails — callers fail open and attempt every snapshot entry.
async fn existing_container_names() -> Option<std::collections::HashSet<String>> {
let output = podman_output(
&["ps", "-a", "--format", "{{.Names}}"],
Duration::from_secs(30),
)
.await
.ok()?;
if !output.status.success() {
return None;
}
Some(
String::from_utf8_lossy(&output.stdout)
.split_whitespace()
.map(|s| s.to_string())
.collect(),
)
}
#[derive(Debug)]
pub struct RecoveryReport {
pub total: usize,
+34
View File
@@ -98,6 +98,40 @@ async fn main() -> Result<()> {
return ceremony::run();
}
// Plain CLI flags must never boot the daemon (a stray `--version` used to
// start a second instance next to the systemd one). Handled before any
// tracing/state init so stdout stays clean.
match std::env::args().nth(1).as_deref() {
Some("--version") | Some("-V") => {
println!(
"archipelago {}-{}",
env!("CARGO_PKG_VERSION"),
option_env!("GIT_HASH").unwrap_or("dev")
);
return Ok(());
}
Some("--help") | Some("-h") => {
println!("Archipelago Bitcoin Node OS");
println!();
println!("Usage: archipelago [COMMAND]");
println!();
println!("Running with no arguments starts the node daemon.");
println!();
println!("Commands:");
println!(" ceremony <gen|pubkey|sign|verify> Release-root signing ceremony");
println!();
println!("Options:");
println!(" -V, --version Print version and exit");
println!(" -h, --help Print this help and exit");
return Ok(());
}
Some(other) if other.starts_with('-') => {
eprintln!("archipelago: unknown option '{other}' (see --help)");
std::process::exit(2);
}
_ => {}
}
let startup_start = std::time::Instant::now();
crash_recovery::init_start_time();
+226 -38
View File
@@ -24,6 +24,16 @@ pub static DOWNLOAD_CANCEL: AtomicBool = AtomicBool::new(false);
/// confidence than "looks stuck at 0%".
pub static DOWNLOAD_PROGRESS_AT: AtomicU64 = AtomicU64::new(0);
/// Serializes the mutating update operations (download, apply, and the
/// staging wipe in cancel). The .198 v1.7.103 bricking (2026-07-18) was
/// exactly this race: two concurrent `update.download` RPCs shared one
/// staging file, a cancel wiped staging mid-flight, a third download began
/// re-filling it, and `apply_update` mv'd the 3-second-old 17MB partial of
/// a 49MB binary into /usr/local/bin → SEGV boot loop. Writers take this
/// via `try_lock` so a concurrent caller gets an explicit "already running"
/// error instead of silently interleaving.
static UPDATE_OP_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
fn now_ms() -> u64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
@@ -976,6 +986,9 @@ pub async fn dismiss_update(data_dir: &Path) -> Result<()> {
/// verified over the complete file at the end of each component, so a
/// partially-corrupt resume still fails cleanly.
pub async fn download_update(data_dir: &Path) -> Result<DownloadProgress> {
let _op = UPDATE_OP_LOCK.try_lock().map_err(|_| {
anyhow::anyhow!("another update operation (download or apply) is already running")
})?;
let mut state = load_state(data_dir).await?;
if state.available_update.is_none() {
state = check_for_updates(data_dir).await?;
@@ -1133,7 +1146,6 @@ async fn download_component_resumable(
dest: &Path,
prior_total: u64,
) -> Result<()> {
use sha2::{Digest, Sha256};
use tokio::io::AsyncWriteExt;
const MAX_ATTEMPTS: u32 = 6;
const BACKOFFS: [u64; 5] = [5, 15, 30, 60, 120];
@@ -1145,8 +1157,19 @@ async fn download_component_resumable(
Err(_) => 0,
};
if existing_len >= component.size_bytes {
// File is already complete — break out and go verify.
break;
// File is already complete (a resumed run finished it, or a
// leftover from an earlier attempt) — verify it instead of
// trusting it. The old code `break`d here, which skipped
// verification entirely AND landed on the error return below
// ("download failed without a captured error").
match verify_component_on_disk(component, dest).await {
Ok(()) => return Ok(()),
Err(e) => {
let _ = tokio::fs::remove_file(dest).await;
last_err = Some(e);
continue;
}
}
}
if attempt > 1 {
let delay = BACKOFFS[(attempt as usize - 2).min(BACKOFFS.len() - 1)];
@@ -1294,44 +1317,86 @@ async fn download_component_resumable(
continue;
}
// Full file — verify hash.
let bytes = tokio::fs::read(dest)
.await
.context("read staging file for hash check")?;
let hash = hex::encode(Sha256::digest(&bytes));
if hash == component.sha256 {
// DHT Phase 1: if the manifest also pins a BLAKE3 digest, it must
// match too. SHA-256 stays the mandatory gate during migration;
// BLAKE3 is the hash the iroh swarm will fetch/verify by, so a
// present-but-wrong BLAKE3 means the bytes aren't swarm-consistent
// — treat it like a SHA mismatch and re-download.
if let Some(b3) = component.blake3.as_deref() {
let expected = b3.trim().strip_prefix("blake3:").unwrap_or(b3.trim());
let actual = crate::content_hash::blake3_hex(&bytes);
if !actual.eq_ignore_ascii_case(expected) {
let _ = tokio::fs::remove_file(dest).await;
last_err = Some(anyhow::anyhow!(
"BLAKE3 mismatch for {}: expected {}, got {}",
component.name,
expected,
actual
));
continue;
}
// Full file — verify hashes. On mismatch the file on disk is
// garbage: nuke it and start over from scratch on the next attempt.
match verify_component_on_disk(component, dest).await {
Ok(()) => return Ok(()),
Err(e) => {
let _ = tokio::fs::remove_file(dest).await;
last_err = Some(e);
}
return Ok(());
}
// SHA mismatch — the file on disk is garbage. Nuke it and
// start over from scratch on the next attempt.
let _ = tokio::fs::remove_file(dest).await;
last_err = Some(anyhow::anyhow!(
}
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("download failed without a captured error")))
}
/// Verify a fully-downloaded component file on disk: SHA-256 is the
/// mandatory gate; when the manifest also pins a BLAKE3 digest it must
/// match too (BLAKE3 is the hash the iroh swarm fetches/verifies by, so
/// a present-but-wrong BLAKE3 means the bytes aren't swarm-consistent —
/// treated exactly like a SHA mismatch). Err = mismatch; the caller
/// decides whether to remove the file and retry.
async fn verify_component_on_disk(component: &ComponentUpdate, dest: &Path) -> Result<()> {
use sha2::{Digest, Sha256};
let bytes = tokio::fs::read(dest)
.await
.context("read staging file for hash check")?;
let hash = hex::encode(Sha256::digest(&bytes));
if hash != component.sha256 {
anyhow::bail!(
"SHA256 mismatch for {}: expected {}, got {}",
component.name,
component.sha256,
hash
));
);
}
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("download failed without a captured error")))
if let Some(b3) = component.blake3.as_deref() {
let expected = b3.trim().strip_prefix("blake3:").unwrap_or(b3.trim());
let actual = crate::content_hash::blake3_hex(&bytes);
if !actual.eq_ignore_ascii_case(expected) {
anyhow::bail!(
"BLAKE3 mismatch for {}: expected {}, got {}",
component.name,
expected,
actual
);
}
}
Ok(())
}
/// Re-verify every manifest component against the bytes actually sitting
/// in staging, immediately before install. The download path verifies as
/// it goes, but staging can change between download and apply — on .198
/// (v1.7.103, 2026-07-18) a concurrent download was re-filling a wiped
/// staging dir when apply ran, and a 17MB partial of the 49MB binary got
/// installed. This apply-time gate is the one that must never be skipped.
async fn verify_staged_components(staging_dir: &Path, manifest: &UpdateManifest) -> Result<()> {
for component in &manifest.components {
let dest = staging_dir.join(&component.name);
let len = tokio::fs::metadata(&dest)
.await
.map(|m| m.len())
.unwrap_or(0);
if len != component.size_bytes {
anyhow::bail!(
"staged component {} is {} bytes but the manifest says {} — \
refusing to apply (incomplete or concurrently-rewritten download)",
component.name,
len,
component.size_bytes
);
}
verify_component_on_disk(component, &dest)
.await
.with_context(|| {
format!(
"staged component {} failed verification — refusing to apply",
component.name
)
})?;
}
Ok(())
}
/// Cancel an in-flight download. Sets the cancellation flag so the
@@ -1343,11 +1408,21 @@ pub async fn cancel_download(data_dir: &Path) -> Result<()> {
DOWNLOAD_CANCEL.store(true, Ordering::Relaxed);
DOWNLOAD_BYTES.store(0, Ordering::Relaxed);
DOWNLOAD_TOTAL.store(0, Ordering::Relaxed);
// Only wipe staging when no download/apply holds the op lock. Wiping
// under a live operation is how .198 ended up applying a re-filling
// staging dir; with the lock held elsewhere we just set the cancel
// flag and let the in-flight loop bail at its next chunk boundary
// (partials are size+hash revalidated on the next resume anyway).
let staging = data_dir.join("update-staging");
let wiped = if staging.exists() {
tokio::fs::remove_dir_all(&staging).await.is_ok()
} else {
false
let wiped = match UPDATE_OP_LOCK.try_lock() {
Ok(_op) => {
if staging.exists() {
tokio::fs::remove_dir_all(&staging).await.is_ok()
} else {
false
}
}
Err(_) => false,
};
// Clear the "downloaded, ready to apply" marker too — a canceled
// download is not a staged update.
@@ -1398,11 +1473,34 @@ pub(crate) async fn host_sudo(args: &[&str]) -> Result<std::process::ExitStatus>
/// Apply a downloaded update. Backs up current binaries, replaces with staged versions.
pub async fn apply_update(data_dir: &Path) -> Result<()> {
let _op = UPDATE_OP_LOCK.try_lock().map_err(|_| {
anyhow::anyhow!("another update operation (download or apply) is already running")
})?;
let staging_dir = data_dir.join("update-staging");
if !staging_dir.exists() {
anyhow::bail!("No staged update found. Download first.");
}
// Gate 1: the completion marker is written only after EVERY component
// downloaded and hash-verified. A staging dir without it is a partial
// or in-flight download — exactly what got installed on .198.
if !has_staged_update(data_dir).await {
anyhow::bail!(
"Staged update is incomplete (no completion marker) — download the update again before applying"
);
}
// Gate 2: re-verify the actual staged bytes against the manifest.
let manifest = load_state(data_dir)
.await?
.available_update
.ok_or_else(|| {
anyhow::anyhow!(
"no update manifest in state to verify staged files against — re-download the update"
)
})?;
verify_staged_components(&staging_dir, &manifest).await?;
let backup_dir = data_dir.join("update-backup");
fs::create_dir_all(&backup_dir)
.await
@@ -1690,6 +1788,30 @@ pub async fn apply_update(data_dir: &Path) -> Result<()> {
.await;
}
// Install the OTA crash-loop guard as a drop-in on existing
// nodes (fresh ISOs carry it in the unit file itself). The
// guard restores the update-backup binary when a freshly
// applied binary SEGVs before it can run its own post-OTA
// verification — the .198 v1.7.103 truncated-binary loop.
// Best-effort: `+-` in the drop-in means a missing script can
// never block the service, and a failed install here must not
// abort the apply.
if Path::new("/opt/archipelago/scripts/ota-crash-guard.sh").exists() {
let dropin_dir = "/etc/systemd/system/archipelago.service.d";
let _ = host_sudo(&["mkdir", "-p", dropin_dir]).await;
let _ = host_sudo(&[
"bash",
"-c",
&format!(
"printf '%s\\n' '[Service]' \
'ExecStartPre=+-/opt/archipelago/scripts/ota-crash-guard.sh' \
> {}/ota-crash-guard.conf",
dropin_dir
),
])
.await;
}
let _ = host_sudo(&["systemctl", "daemon-reload"]).await;
let _ =
host_sudo(&["systemctl", "enable", "--now", "archipelago-doctor.timer"]).await;
@@ -2443,6 +2565,72 @@ mod tests {
assert!(!persisted.update_in_progress);
}
/// apply_update takes the global single-flight UPDATE_OP_LOCK, so tests
/// that call it must not run concurrently — one would see the other's
/// lock and fail with "another update operation is already running".
static APPLY_TEST_SERIAL: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
#[tokio::test]
async fn test_apply_refuses_unmarked_staging() {
let _serial = APPLY_TEST_SERIAL.lock().await;
// Regression: .198 v1.7.103 bricking — apply ran against a staging
// dir that a concurrent download was still filling. Without the
// .download-complete marker, apply must refuse before touching
// anything.
let dir = tempfile::tempdir().unwrap();
let staging = dir.path().join("update-staging");
tokio::fs::create_dir_all(&staging).await.unwrap();
tokio::fs::write(staging.join("archipelago"), b"partial")
.await
.unwrap();
let err = apply_update(dir.path()).await.unwrap_err();
assert!(
err.to_string().contains("completion marker"),
"got: {err:#}"
);
}
#[tokio::test]
async fn test_apply_refuses_staged_bytes_that_mismatch_manifest() {
let _serial = APPLY_TEST_SERIAL.lock().await;
// Marker present (a complete download once existed) but the staged
// bytes no longer match the manifest — apply must re-verify and
// refuse rather than install whatever is on disk.
let dir = tempfile::tempdir().unwrap();
let staging = dir.path().join("update-staging");
tokio::fs::create_dir_all(&staging).await.unwrap();
tokio::fs::write(staging.join(STAGED_COMPLETE_MARKER), b"1")
.await
.unwrap();
tokio::fs::write(staging.join("archipelago"), b"truncated-garbage")
.await
.unwrap();
let state = UpdateState {
available_update: Some(UpdateManifest {
version: "999.0.0".to_string(),
release_date: "2026-07-18".to_string(),
changelog: vec![],
components: vec![ComponentUpdate {
name: "archipelago".to_string(),
current_version: "1.0.0".to_string(),
new_version: "999.0.0".to_string(),
download_url: "http://example.invalid/archipelago".to_string(),
sha256: "0".repeat(64),
size_bytes: 49_949_048,
blake3: None,
}],
}),
update_in_progress: true,
..UpdateState::default()
};
save_state(dir.path(), &state).await.unwrap();
let err = apply_update(dir.path()).await.unwrap_err();
assert!(
err.to_string().contains("refusing to apply"),
"got: {err:#}"
);
}
#[tokio::test]
async fn test_dismiss_update_clears_available() {
let dir = tempfile::tempdir().unwrap();
@@ -254,8 +254,17 @@ container_pull() {
echo "📦 Step 1: Building root filesystem..."
ROOTFS_TAR="$WORK_DIR/archipelago-rootfs.tar"
ROOTFS_STAMP="$WORK_DIR/archipelago-rootfs.recipe.sha256"
if [ ! -f "$ROOTFS_TAR" ] || [ "$1" == "--rebuild" ]; then
# The cached rootfs must be invalidated when its recipe changes: a stale
# archipelago-rootfs.tar on the build machine shipped ISOs with NO
# wpasupplicant/iw/rfkill (WiFi dead on laptops) long after those packages
# were added to the Dockerfile below — the cache condition never looked at
# the recipe. Hash the rootfs-defining region of this script; any edit to it
# forces a rebuild. `--rebuild` still forces one unconditionally.
RECIPE_HASH=$(sed -n '/^# STEP 1: Build complete root filesystem/,/^# STEP 2: Build minimal installer/p' "$0" | sha256sum | cut -d' ' -f1)
if [ ! -f "$ROOTFS_TAR" ] || [ "${1:-}" == "--rebuild" ] || [ "$(cat "$ROOTFS_STAMP" 2>/dev/null)" != "$RECIPE_HASH" ]; then
echo " Using Docker to create Debian root filesystem..."
# Create a Dockerfile for building the rootfs
@@ -694,6 +703,7 @@ SYSTEMDSERVICE
$CONTAINER_CMD export archipelago-rootfs-tmp > "$ROOTFS_TAR"
$CONTAINER_CMD rm archipelago-rootfs-tmp
echo "$RECIPE_HASH" > "$ROOTFS_STAMP"
echo "✅ Root filesystem created: $(du -h "$ROOTFS_TAR" | cut -f1)"
else
echo "✅ Using cached root filesystem: $(du -h "$ROOTFS_TAR" | cut -f1)"
@@ -1218,6 +1228,23 @@ else
echo " ⚠ nostr-rs-relay image not available — relay binary will be missing"
fi
# A missing nvpn/nostr-rs-relay used to be a warning, and the resulting ISO
# shipped units that crash-looped (or silently lacked VPN signaling) on every
# install. Refuse to produce that ISO unless explicitly overridden.
MISSING_VPN_BINARIES=""
[ -f "$ARCH_DIR/bin/nvpn" ] || MISSING_VPN_BINARIES="$MISSING_VPN_BINARIES nvpn"
[ -f "$ARCH_DIR/bin/nostr-rs-relay" ] || MISSING_VPN_BINARIES="$MISSING_VPN_BINARIES nostr-rs-relay"
if [ -n "$MISSING_VPN_BINARIES" ]; then
if [ "${ALLOW_MISSING_VPN_BINARIES:-0}" = "1" ]; then
echo " ⚠ Building WITHOUT:$MISSING_VPN_BINARIES (ALLOW_MISSING_VPN_BINARIES=1)"
else
echo " ❌ Required binaries not extracted:$MISSING_VPN_BINARIES"
echo " The registry (146.59.87.168:3000) must be reachable and hold the images,"
echo " or set ALLOW_MISSING_VPN_BINARIES=1 to ship without VPN signaling."
exit 1
fi
fi
# Copy WireGuard helper script
if [ -f "$WORK_DIR/archipelago-wg" ]; then
cp "$WORK_DIR/archipelago-wg" "$ARCH_DIR/bin/archipelago-wg"
@@ -1658,17 +1685,17 @@ LOG="/var/log/archipelago-tor.log"
mkdir -p "$ARCHY_TOR_DIR" "$TOR_CONFIG_DIR"
# Write services.json for the backend to read
# First boot only: seed services.json + torrc. The unit runs on EVERY boot
# (oneshot, multi-user.target), and rewriting these unconditionally clobbered
# hidden services the backend added after app installs. Only the node's own
# service is pre-baked — apps get their hidden service created on install
# (auto_add_tor_service / tor.create-service), never pre-created for apps
# that may never be installed (issue #79).
if [ ! -f "$TOR_CONFIG_DIR/services.json" ]; then
cat > "$ARCHY_TOR_DIR/services.json" <<TORJSON
{
"services": [
{"name": "archipelago", "local_port": 80, "enabled": true},
{"name": "bitcoin", "local_port": 8333, "enabled": true},
{"name": "electrumx", "local_port": 50001, "enabled": true},
{"name": "lnd", "local_port": 9735, "enabled": true},
{"name": "btcpay", "local_port": 23000, "enabled": true},
{"name": "mempool", "local_port": 4080, "enabled": true},
{"name": "fedimint", "local_port": 8175, "enabled": true}
{"name": "archipelago", "local_port": 80, "enabled": true}
]
}
TORJSON
@@ -1688,33 +1715,16 @@ SocksPolicy reject *
HiddenServiceDir $TOR_DIR/hidden_service_archipelago
HiddenServicePort 80 127.0.0.1:80
HiddenServiceDir $TOR_DIR/hidden_service_bitcoin
HiddenServicePort 8333 127.0.0.1:8333
HiddenServicePort 8332 127.0.0.1:8332
HiddenServiceDir $TOR_DIR/hidden_service_electrumx
HiddenServicePort 50001 127.0.0.1:50001
HiddenServiceDir $TOR_DIR/hidden_service_lnd
HiddenServicePort 9735 127.0.0.1:9735
HiddenServicePort 8080 127.0.0.1:8080
HiddenServiceDir $TOR_DIR/hidden_service_btcpay
HiddenServicePort 23000 127.0.0.1:23000
HiddenServiceDir $TOR_DIR/hidden_service_mempool
HiddenServicePort 4080 127.0.0.1:4080
HiddenServiceDir $TOR_DIR/hidden_service_fedimint
HiddenServicePort 8175 127.0.0.1:8175
HiddenServiceDir $TOR_DIR/hidden_service_relay
HiddenServicePort 7777 127.0.0.1:7777
TORRC
else
echo "$(date): tor already initialized — leaving services.json/torrc alone" >> "$LOG"
fi
# Create hidden service dirs with correct ownership and permissions (700, not 750)
# Tor refuses to start if permissions are too permissive
for svc in archipelago bitcoin electrumx lnd btcpay mempool fedimint relay; do
for svc in archipelago relay; do
mkdir -p "$TOR_DIR/hidden_service_$svc"
chown debian-tor:debian-tor "$TOR_DIR/hidden_service_$svc"
chmod 700 "$TOR_DIR/hidden_service_$svc"
@@ -1759,7 +1769,7 @@ done
# Sync hostnames to backend-readable directory
HOSTNAMES_DIR="/var/lib/archipelago/tor-hostnames"
mkdir -p "$HOSTNAMES_DIR"
for svc in archipelago bitcoin electrumx lnd btcpay mempool fedimint relay; do
for svc in archipelago relay; do
if [ -f "$TOR_DIR/hidden_service_${svc}/hostname" ]; then
cp "$TOR_DIR/hidden_service_${svc}/hostname" "$HOSTNAMES_DIR/$svc"
echo "$(date): Synced hostname: $svc" >> "$LOG"
@@ -3345,6 +3355,11 @@ echo ""
echo "=== Done ==="
DIAGSCRIPT
chmod +x /mnt/target/opt/archipelago/scripts/first-boot-diag.sh
# v1.7.104 shipped installs where this script was missing while its unit was
# enabled (203/EXEC forever). Verify the write actually landed, loudly.
if [ ! -x /mnt/target/opt/archipelago/scripts/first-boot-diag.sh ]; then
echo "ERROR: first-boot-diag.sh was not written to the target" >&2
fi
# Systemd oneshot service for first-boot diagnostics
cat > /mnt/target/etc/systemd/system/archipelago-diag.service <<'DIAGSVC'
@@ -3352,6 +3367,8 @@ cat > /mnt/target/etc/systemd/system/archipelago-diag.service <<'DIAGSVC'
Description=Archipelago First Boot Diagnostics
After=multi-user.target archipelago.service nginx.service
ConditionPathExists=!/var/log/archipelago-first-boot-diag.log
# Skip cleanly (instead of failing 203/EXEC) if the script is missing.
ConditionPathExists=/opt/archipelago/scripts/first-boot-diag.sh
[Service]
Type=oneshot
+5
View File
@@ -25,6 +25,11 @@ ExecStartPre=+/bin/bash -c 'mkdir -p /run/user/1000 /var/lib/containers && chown
# once a VPN/bridge interface exists (netbird's wg tunnel sorted first and
# poisoned every host_ip consumer). Falls back to hostname -I when routeless.
ExecStartPre=+/bin/bash -c 'mkdir -p /var/lib/archipelago && chown archipelago:archipelago /var/lib/archipelago && IP=$(ip -4 route show default 2>/dev/null | sed -n "s/.* src \([0-9.]*\).*/\1/p" | head -1); [ -n "$$IP" ] || IP=$(hostname -I 2>/dev/null | awk "{print $$1}"); echo "ARCHIPELAGO_HOST_IP=$$IP" > /var/lib/archipelago/host-ip.env && chown archipelago:archipelago /var/lib/archipelago/host-ip.env'
# OTA crash-loop guard: if a just-applied binary can't start (SEGV loop), the
# in-binary post-OTA probe never runs — this restores the update-backup binary
# after 5 failed start attempts while the pending-verify marker exists.
# "-" so a missing/failed guard can never block the service itself.
ExecStartPre=+-/opt/archipelago/scripts/ota-crash-guard.sh
ExecStart=/usr/local/bin/archipelago
Restart=on-failure
RestartSec=5
+3
View File
@@ -3,6 +3,9 @@ Description=Archipelago Private Nostr Relay
After=network-online.target
Wants=network-online.target
Before=nostr-vpn.service
# An ISO built without the relay binary (registry unreachable at build time)
# must not crash-loop every 3s forever — skip cleanly instead.
ConditionPathExists=/usr/local/bin/nostr-rs-relay
[Service]
Type=simple
+3
View File
@@ -4,6 +4,9 @@ After=network-online.target tor.service archipelago.service
Wants=network-online.target
StartLimitIntervalSec=300
StartLimitBurst=10
# An ISO built without the nvpn binary (registry unreachable at build time)
# must not restart-loop — skip cleanly instead.
ConditionPathExists=/usr/local/bin/nvpn
[Service]
Type=simple
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "neode-ui",
"version": "1.7.102-alpha",
"version": "1.7.105-alpha",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "neode-ui",
"version": "1.7.102-alpha",
"version": "1.7.105-alpha",
"dependencies": {
"@types/dompurify": "^3.0.5",
"@vue-leaflet/vue-leaflet": "^0.10.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "neode-ui",
"private": true,
"version": "1.7.102-alpha",
"version": "1.7.105-alpha",
"type": "module",
"scripts": {
"start": "./start-dev.sh",
+15 -4
View File
@@ -424,10 +424,14 @@ onMounted(async () => {
const { IS_DEMO } = await import('@/composables/useDemoIntro')
if (IS_DEMO && bootPath === '/') replayRequested = true
let onboardingComplete: boolean | null = localStorage.getItem('neode_onboarding_complete') === '1' ? true : null
const splashCandidate = !seenIntro
&& (fromBoot || (bootPath === '/' && import.meta.env.VITE_DEV_MODE !== 'boot'))
// Root boots always ask the backend even when this browser thinks it has
// seen the intro. Both `neode_intro_seen` and `neode_onboarding_complete`
// are per-origin browser state: after a reinstall (or another node coming
// up on a DHCP-recycled IP) they describe the PREVIOUS node and would mute
// a fresh install's intro / misroute it to login.
const splashCandidate = fromBoot || (bootPath === '/' && import.meta.env.VITE_DEV_MODE !== 'boot')
if (splashCandidate && onboardingComplete !== true) {
if (splashCandidate) {
try {
const { checkOnboardingStatus } = await import('@/composables/useOnboarding')
// Bound the pre-splash status check: its retry ladder can spend ~30s
@@ -437,10 +441,17 @@ onMounted(async () => {
// the splash play (a fresh install IS the slow-backend case; onboarded
// nodes answer in milliseconds, so their suppression path is intact).
// handleSplashComplete re-checks with full retries after the intro.
onboardingComplete = await Promise.race([
const live = await Promise.race([
checkOnboardingStatus(),
new Promise<null>((resolve) => setTimeout(() => resolve(null), 2500)),
])
if (live !== null) onboardingComplete = live
if (live === false && seenIntro) {
// Backend-confirmed fresh node behind a browser with a stale flag
// drop it so this boot (and every later one) plays the intro.
try { localStorage.removeItem('neode_intro_seen') } catch { /* noop */ }
seenIntro = false
}
} catch {
onboardingComplete = localStorage.getItem('neode_onboarding_complete') === '1' ? true : null
}
@@ -161,6 +161,14 @@
</div>
</div>
<p v-if="wgError" class="text-xs text-red-400 text-center mb-3">{{ wgError }}</p>
<button
v-if="wgError && !wgLoading"
type="button"
class="inline-flex w-full items-center justify-center rounded-lg bg-white/5 border border-white/15 px-4 py-2.5 text-sm font-medium text-white/80 hover:bg-white/10 hover:text-white transition-colors mb-3"
@click="retryWgPeer"
>
Try again
</button>
<!-- Same-device path: a phone can't scan its own screen, so offer
the config as a file WireGuard can import. -->
@@ -318,7 +326,15 @@ const POST_INTRO_GRACE_MS = 2000
let calmTicker: ReturnType<typeof setInterval> | null = null
// Running inside the companion app's own WebView (it injects this JS bridge).
// The "get the companion app" pitch is nonsense there the user is already in
// it and the WG steps mid-pairing race the phone's changing network (the
// "Failed to fetch" dead-end QR). Server/tunnel management for connected
// companions lives in the NESMenu instead.
const IN_COMPANION_APP = typeof (window as { ArchipelagoNative?: unknown }).ArchipelagoNative !== 'undefined'
onMounted(() => {
if (IN_COMPANION_APP) return
try {
if (localStorage.getItem(STORAGE_KEY) !== '1') {
setTimeout(maybeShow, BASE_DELAY_MS)
@@ -471,6 +487,19 @@ function backFromPair() {
}
}
// Network-class failures: the node itself was unreachable (as opposed to the
// backend answering with an RPC error). Includes the client's own timeout.
const WG_NETWORK_ERR = /failed to fetch|networkerror|load failed|abort|request timeout/i
// Auto-retry ladder for network-class failures. On a first install this step
// is often reached while the backend is still settling (services starting,
// backend restarting during container orchestration) a single failed fetch
// left a permanently blank QR unless the user spotted the retry button.
const WG_RETRY_DELAYS_MS = [2000, 4000, 8000]
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
const stillOnWgStep = () => visible.value && step.value === 'wgqr'
// Create (or fetch) the phone's VPN peer and render its config as a QR.
// Reuses the same RPCs as the Server page's Add Device modal; the peer is
// looked up first so reopening the modal never duplicates it.
@@ -478,30 +507,71 @@ async function loadWgPeer() {
if (wgQrDataUrl.value || wgLoading.value) return
wgLoading.value = true
wgError.value = ''
try {
const listed = await rpcClient
.call<{ peers: { name: string }[] }>({ method: 'vpn.list-peers' })
.catch(() => ({ peers: [] as { name: string }[] }))
const exists = (listed.peers || []).some((p) => p.name === WG_PEER_NAME)
const res = await rpcClient.call<{ config: string; peer_ip: string }>({
method: exists ? 'vpn.peer-config' : 'vpn.create-peer',
params: { name: WG_PEER_NAME },
})
wgConfig.value = res.config
wgQrDataUrl.value = await QRCode.toDataURL(res.config, {
width: 512,
margin: 3,
errorCorrectionLevel: 'M',
color: {
dark: '#111111',
light: '#ffffff',
},
})
} catch (e) {
wgError.value = e instanceof Error ? e.message : 'Failed to generate the tunnel config'
} finally {
wgLoading.value = false
for (let attempt = 0; ; attempt++) {
try {
await provisionWgPeer()
break
} catch (e) {
const raw = e instanceof Error ? e.message : ''
const isNetworkErr = WG_NETWORK_ERR.test(raw)
const retryDelay = WG_RETRY_DELAYS_MS[attempt]
if (isNetworkErr && retryDelay !== undefined && stillOnWgStep()) {
await sleep(retryDelay)
if (stillOnWgStep()) continue
}
// fetch()'s raw "Failed to fetch" means the node itself was unreachable
// after the retry ladder that's usually the phone's network mid-change
// (WiFi drop, or a half-configured tunnel already routing 10.44.0.0/16).
// Say so, and leave a Retry path instead of a dead end.
wgError.value = isNetworkErr
? "Can't reach your node. Check the phone is on the same network as the node (and any half-set-up tunnel is switched off), then tap Try again."
: raw || 'Failed to generate the tunnel config'
break
}
}
wgLoading.value = false
}
async function provisionWgPeer() {
const listed = await rpcClient
.call<{ peers: { name: string }[] }>({ method: 'vpn.list-peers' })
.catch(() => null)
// list-peers unreachable don't guess "doesn't exist": create-peer on an
// existing name would fail. Try create first, fall back to peer-config.
const exists = listed === null
? null
: (listed.peers || []).some((p) => p.name === WG_PEER_NAME)
let res: { config: string; peer_ip: string }
if (exists === true) {
res = await rpcClient.call({ method: 'vpn.peer-config', params: { name: WG_PEER_NAME } })
} else {
try {
res = await rpcClient.call({ method: 'vpn.create-peer', params: { name: WG_PEER_NAME } })
} catch (e) {
// Peer already provisioned on a previous visit (list failed or raced).
const msg = e instanceof Error ? e.message : ''
if (/exist|duplicate/i.test(msg)) {
res = await rpcClient.call({ method: 'vpn.peer-config', params: { name: WG_PEER_NAME } })
} else {
throw e
}
}
}
wgConfig.value = res.config
wgQrDataUrl.value = await QRCode.toDataURL(res.config, {
width: 512,
margin: 3,
errorCorrectionLevel: 'M',
color: {
dark: '#111111',
light: '#ffffff',
},
})
}
function retryWgPeer() {
wgError.value = ''
void loadWgPeer()
}
// Same-device path: hand the config to the WireGuard app as an importable
@@ -26,6 +26,7 @@
import { ref, onMounted } from 'vue'
import BaseModal from '@/components/BaseModal.vue'
import { useLoginTransitionStore } from '@/stores/loginTransition'
import { IS_DEMO } from '@/composables/useDemoIntro'
const showUpdatePrompt = ref(false)
let updateCallback: (() => Promise<void>) | null = null
@@ -53,6 +54,12 @@ function reloadAfterCinematic() {
}
onMounted(() => {
// The public demo has no version to update to the prompt is noise, and
// both accept-paths end in a reload that replays the demo intro ("the site
// just reset itself"). skipWaiting/clientsClaim are off, so ignoring the
// waiting worker is safe: this page keeps its complete old cache, and the
// new build activates on the next visit.
if (IS_DEMO) return
// Listen for service worker updates
if ('serviceWorker' in navigator) {
// On the very first visit the page loads with no controlling SW; the
@@ -38,4 +38,50 @@ describe('shouldShowIntroSplash', () => {
replayRequested: true,
})).toBe(true)
})
it('a confirmed-fresh node plays the intro despite a stale per-origin seenIntro flag (reinstall / DHCP-recycled IP)', () => {
expect(shouldShowIntroSplash({
seenIntro: true,
routePath: '/',
fromBoot: false,
onboardingComplete: false,
})).toBe(true)
})
it('a confirmed-fresh node plays the intro on the boot-screen handoff too', () => {
expect(shouldShowIntroSplash({
seenIntro: true,
routePath: '/login',
fromBoot: true,
onboardingComplete: false,
})).toBe(true)
})
it('stale seenIntro still suppresses when the backend answer is unknown', () => {
expect(shouldShowIntroSplash({
seenIntro: true,
routePath: '/',
fromBoot: false,
onboardingComplete: null,
})).toBe(false)
})
it('fresh node on a deep route without boot handoff stays suppressed', () => {
expect(shouldShowIntroSplash({
seenIntro: false,
routePath: '/onboarding/seed',
fromBoot: false,
onboardingComplete: false,
})).toBe(false)
})
it('boot dev mode never root-boots into the intro', () => {
expect(shouldShowIntroSplash({
seenIntro: false,
routePath: '/',
fromBoot: false,
devMode: 'boot',
onboardingComplete: false,
})).toBe(false)
})
})
+10 -1
View File
@@ -10,10 +10,19 @@ export interface IntroSplashDecisionInput {
export function shouldShowIntroSplash(input: IntroSplashDecisionInput): boolean {
if (input.replayRequested) return true
const isDirectRoute = input.routePath !== '/'
// A node the backend CONFIRMS has never completed onboarding always gets
// the full intro on a root boot. `seenIntro` is per-origin browser state —
// after a reinstall (or a DHCP-recycled IP), the browser still carries the
// previous node's flag at the same origin, which silently muted the intro
// on genuinely fresh installs.
if (input.onboardingComplete === false && (input.fromBoot || (!isDirectRoute && input.devMode !== 'boot'))) {
return true
}
if (input.seenIntro) return false
if (input.onboardingComplete === true) return false
const isDirectRoute = input.routePath !== '/'
if (input.fromBoot) return true
if (input.devMode === 'boot') return false
return !isDirectRoute
+10
View File
@@ -64,6 +64,7 @@
type="password"
autocomplete="new-password"
data-form-type="other"
data-controller-no-submit
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/40 focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/20 transition-colors"
:placeholder="t('login.enterPasswordSetup')"
@keydown.enter="confirmPasswordInputRef?.focus()"
@@ -83,6 +84,7 @@
type="password"
autocomplete="new-password"
data-form-type="other"
data-controller-no-submit
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/40 focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/20 transition-colors"
:placeholder="t('login.confirmPasswordPlaceholder')"
@keydown.enter="handleSetupWithSound"
@@ -127,6 +129,7 @@
pattern="[0-9]*"
maxlength="8"
autocomplete="one-time-code"
data-controller-no-submit
:aria-label="t('login.totpLabel')"
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white text-center text-2xl tracking-[0.5em] placeholder-white/40 focus:outline-none focus:border-orange-400/60 focus:ring-1 focus:ring-orange-400/30 transition-colors"
:placeholder="useBackupCode ? 'XXXX-XXXX' : '000000'"
@@ -165,6 +168,12 @@
🎮 Demo mode Password: <span class="font-mono font-semibold">{{ DEMO_PASSWORD }}</span>
</div>
<!-- All auth inputs opt out of controller-nav's Enterclick-next-button
pattern (data-controller-no-submit): they submit via their own Enter
handlers, and while the submit button is still disabled the "next
focusable" is Replay Intro the companion's auto-login injects
Enter before Vue re-enables the button, which replayed the intro
in a loop on every app connect. -->
<div class="mb-6">
<label for="login-password" class="block text-sm font-medium text-white/80 mb-2">
{{ t('login.password') }}
@@ -175,6 +184,7 @@
type="password"
autocomplete="current-password"
data-form-type="other"
data-controller-no-submit
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/40 focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/20 transition-colors"
:placeholder="t('login.enterPasswordPlaceholder')"
@keydown.enter="handleLoginWithSound"
+10 -6
View File
@@ -691,20 +691,24 @@ video.bg-layer {
why the kiosk login/onboarding background still went black. Keep 2D
opacity crossfades; drop 3D transforms, blur filters, and blend-mode
glitch overlays. */
:global(html.kiosk-mode) .bg-perspective-container,
:global(html.kiosk-mode) .perspective-container {
/* The full selector must live inside :global() with `:global(html.kiosk-mode)
.bg-layer` the SFC compiler drops the descendant part, emitting bare
`html.kiosk-mode { display: none !important }` rules that blank the whole
document on kiosk (the v1.7.104 white-screen). */
:global(html.kiosk-mode .bg-perspective-container),
:global(html.kiosk-mode .perspective-container) {
perspective: none !important;
}
:global(html.kiosk-mode) .bg-layer,
:global(html.kiosk-mode) .view-wrapper {
:global(html.kiosk-mode .bg-layer),
:global(html.kiosk-mode .view-wrapper) {
transform: none !important;
transform-style: flat !important;
backface-visibility: visible !important;
will-change: auto !important;
filter: none !important;
}
:global(html.kiosk-mode) .login-glitch-layer,
:global(html.kiosk-mode) .login-glitch-scan {
:global(html.kiosk-mode .login-glitch-layer),
:global(html.kiosk-mode .login-glitch-scan) {
display: none !important;
}
</style>
@@ -18,10 +18,8 @@
let the app's own UI load instead of a loader stuck on top (B7). -->
<div v-if="electrsSync && !electrsSync.stale" class="absolute inset-0 z-10 flex flex-col items-center justify-center">
<div class="text-center px-8 w-full max-w-md">
<div class="w-16 h-16 mx-auto mb-4 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center">
<svg class="w-8 h-8 text-orange-300 animate-pulse" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 7v10a2 2 0 002 2h12a2 2 0 002-2V7M4 7l8 5 8-5M4 7l8-4 8 4" />
</svg>
<div class="w-16 h-16 mx-auto mb-4 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center overflow-hidden animate-pulse">
<img :src="appIcon" :alt="appTitle" class="w-full h-full object-cover" @error="handleImageError" />
</div>
<h3 class="text-lg font-semibold text-white mb-2">{{ appTitle }} is syncing</h3>
<p class="text-white/50 text-sm mb-5">
@@ -119,6 +117,7 @@
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
import type { ElectrsSyncStatus } from '@/composables/useElectrsSync'
import AppLoadingScreen from '@/components/AppLoadingScreen.vue'
import { handleImageError } from '@/views/apps/appsConfig'
const props = defineProps<{
appUrl: string
@@ -362,6 +362,47 @@ init()
</button>
</div>
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
<!-- v1.7.105-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.105-alpha</span>
<span class="text-xs text-white/40">July 20, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Fixed a failure loop where a node that lost power or was moved could get stuck on a blank "can't reach your node" screen forever: startup recovery no longer spends minutes retrying containers that no longer exist, and a genuinely large recovery is no longer cut off half-way and forced to start over. The node now reaches its login screen even after the messiest shutdown.</p>
<p>Phone tunnel setup (WireGuard) is now dependable: the QR screen automatically retries while a fresh install is still settling instead of dead-ending at "failed to fetch", and if your node has moved to a different network the QR and downloadable config now carry the node's current address instead of the old one.</p>
<p>Fixed the white screen some laptop displays showed right after the intro on v1.7.104.</p>
<p>The companion phone app no longer suggests installing the companion app from inside itself.</p>
<p>The Tor page now lists onion addresses only for apps you actually have installed fresh installs no longer come with six pre-made addresses for apps that were never set up.</p>
<p>Running archipelago --version or --help on the command line now prints and exits instead of silently starting a second copy of the node, which could briefly disrupt running apps.</p>
<p>Behind the scenes: installer image builds now stop loudly if VPN components are missing instead of producing a broken image, and a background file-permission sweep runs far less often, reducing disk churn on busy nodes.</p>
</div>
</div>
<!-- v1.7.104-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.104-alpha</span>
<span class="text-xs text-white/40">July 19, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Software updates are now much safer to receive: the node will never install an update that isn't completely downloaded and verified byte-for-byte, closing a rare bug where an interrupted or cancelled download could leave a node unable to start.</p>
<p>If a freshly installed update does fail to start, the node now notices and automatically restores the previous working version by itself no manual rescue needed.</p>
<p>The Electrum server now works with whichever Bitcoin you run: it finds Bitcoin Knots or Bitcoin Core automatically instead of assuming Knots.</p>
<p>While the Electrum server is first building its index, its waiting screen now shows the ElectrumX app icon and live progress.</p>
</div>
</div>
<!-- v1.7.103-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.103-alpha</span>
<span class="text-xs text-white/40">July 18, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Connecting from the phone app no longer replays the intro cinematic on a loop: signing in after scanning the pairing QR could accidentally trigger "Replay Intro" instead of logging you in. The companion app now lands you straight on your dashboard, and the Android app waits for the login screen to be ready before it types your password.</p>
<p>Pressing Enter in any password box now does what you expect it signs you in or moves to the next field, and can no longer "click" a nearby button by mistake when using a controller or the companion app.</p>
<p>The public demo no longer interrupts you with an "Update Available" popup that reset the site back to the intro demo visitors simply get the newest version on their next visit.</p>
</div>
</div>
<!-- v1.7.102-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
+21 -25
View File
@@ -1,35 +1,31 @@
{
"version": "1.7.105-alpha",
"release_date": "2026-07-20",
"changelog": [
"The password you choose during setup is now truly your node's password: it also becomes the system login for console and SSH access, instead of leaving the factory default in place. If you ever renamed your node and the TV screen went black on the next boot, that's fixed too — renaming no longer breaks the kiosk display.",
"Setting up Lightning is now a guided journey: a fund-your-wallet step that shows a live countdown while Bitcoin syncs, suggested channels you can open straight into the Zeus mobile wallet with one tap, and a \"finish setup\" prompt that walks you to the end — goals now complete when you've actually done the steps, not just when apps happen to be running.",
"Pair your phone by pointing it at the screen: the companion app now connects by scanning a QR code — scan, and it fills in your node's address and logs you in. The App Store has a banner to grab the Android app, and the pairing flow can now also set up secure remote access so your phone reaches home from anywhere.",
"First installs are far more dependable: app downloads that stall now retry instead of hanging forever (the old \"first install fails, the second works\" pattern), big multi-part apps show their real download progress instead of sitting at \"Preparing\", Lightning no longer fails its first install over temporary hiccups, and a brand-new node now comes up with its core apps — file cloud and ecash wallet — even with no internet connection.",
"The installer image is about 160MB smaller and gets to a working screen faster, because the apps bundled for offline setup are now compressed.",
"The first-run experience keeps its magic: the typing intro is back on fresh installs and can no longer be cut short by a mid-play refresh — updates now politely wait for the cinematic to finish — and dark backgrounds stay dark instead of flashing black or white.",
"Your backups now include your secrets — including the key that protects your Lightning wallet's recovery seed — and there's a Download button to take a copy off the node; the seed-backup reminder now actually opens the backup flow when you tap it.",
"Networking Profits grew into a full dashboard, network cards keep their action buttons in reach on every screen size, \"Connect to Mesh\" goes to the right page instead of a dead end, and the identity pages got a round of mobile polish.",
"Behind the scenes: apps that report their own health are no longer second-guessed by a port probe (fewer false \"restarting\" states), and pressing arrow keys or a gamepad is once again the only thing that shows the controller focus ring."
"Fixed a failure loop where a node that lost power or was moved could get stuck on a blank \"can't reach your node\" screen forever: startup recovery no longer spends minutes retrying containers that no longer exist, and a genuinely large recovery is no longer cut off half-way and forced to start over. The node now reaches its login screen even after the messiest shutdown.",
"Phone tunnel setup (WireGuard) is now dependable: the QR screen automatically retries while a fresh install is still settling instead of dead-ending at \"failed to fetch\", and if your node has moved to a different network the QR and downloadable config now carry the node's current address instead of the old one.",
"Fixed the white screen some laptop displays showed right after the intro on v1.7.104.",
"The companion phone app no longer suggests installing the companion app from inside itself.",
"The Tor page now lists onion addresses only for apps you actually have installed \u2014 fresh installs no longer come with six pre-made addresses for apps that were never set up.",
"Running `archipelago --version` or `--help` on the command line now prints and exits instead of silently starting a second copy of the node, which could briefly disrupt running apps.",
"Behind the scenes: installer image builds now stop loudly if VPN components are missing instead of producing a broken image, and a background file-permission sweep runs far less often, reducing disk churn on busy nodes."
],
"components": [
{
"current_version": "1.7.102-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.102-alpha/archipelago",
"name": "archipelago",
"new_version": "1.7.102-alpha",
"sha256": "13218bfac5f3e1b641edebac0fcccb483fb4500d764369da4947a467762f2e5a",
"size_bytes": 49951520
"current_version": "1.7.105-alpha",
"new_version": "1.7.105-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.105-alpha/archipelago",
"sha256": "5e9eb56458dd1bc4d60904ed3c84f8a8409778e640d900084e0e57b2c8c52143",
"size_bytes": 50275432
},
{
"current_version": "1.7.102-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.102-alpha/archipelago-frontend-1.7.102-alpha.tar.gz",
"name": "archipelago-frontend-1.7.102-alpha.tar.gz",
"new_version": "1.7.102-alpha",
"sha256": "663cf9a35d98fa6dad2d7fb2906e4a939a906382f2e9729156c3630e282cdc94",
"size_bytes": 174594796
"name": "archipelago-frontend-1.7.105-alpha.tar.gz",
"current_version": "1.7.105-alpha",
"new_version": "1.7.105-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.105-alpha/archipelago-frontend-1.7.105-alpha.tar.gz",
"sha256": "486ed02515e62caf3c40e8de0fe388ad7fb6589e454bec77111ac4780a1f6f01",
"size_bytes": 174593338
}
],
"release_date": "2026-07-17",
"signature": "999e50b9aff22f544677986ca8c686614d8c3d4cbe501c2d5de86d2a1ea56fc731bb9434d722c41c7a124932f98e629706b41a64f57551e69529be59fe671d04",
"signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur",
"version": "1.7.102-alpha"
]
}
+74 -2
View File
@@ -1,6 +1,6 @@
{
"schema": 1,
"updated": "2026-07-10",
"updated": "2026-07-18",
"apps": {
"adguardhome": {
"version": "v0.107.55",
@@ -333,6 +333,78 @@
}
}
},
"barkd": {
"version": "0.3.0",
"manifest": {
"app": {
"id": "barkd",
"name": "Ark Wallet",
"version": "0.3.0",
"description": "Ark protocol wallet daemon (barkd). Lets the node hold self-custodial off-chain bitcoin via an Ark server; the wallet talks to it over a local REST API. Signet by default while Ark matures.",
"container": {
"image": "146.59.87.168:3000/lfg2025/barkd:0.3.0",
"pull_policy": "if-not-present",
"network": "archy-net",
"generated_secrets": [
{
"name": "barkd-secret",
"kind": "hex32"
}
],
"secret_env": [
{
"key": "BARKD_SECRET",
"secret_file": "barkd-secret"
}
],
"data_uid": "1000:1000"
},
"dependencies": [
{
"storage": "1Gi"
}
],
"resources": {
"cpu_limit": 1,
"memory_limit": "512Mi",
"disk_limit": "1Gi"
},
"security": {
"readonly_root": true,
"network_policy": "bridge"
},
"ports": [
{
"host": 3535,
"container": 3535,
"protocol": "tcp"
}
],
"volumes": [
{
"type": "bind",
"source": "/var/lib/archipelago/barkd",
"target": "/data",
"options": [
"rw"
]
}
],
"environment": [
"BARKD_DATADIR=/data",
"BARKD_BIND_HOST=0.0.0.0",
"BARKD_BIND_PORT=3535"
],
"health_check": {
"type": "tcp",
"endpoint": "localhost:3535",
"interval": "30s",
"timeout": "5s",
"retries": 3
}
}
}
},
"bitcoin-core": {
"version": "latest",
"manifest": {
@@ -1085,7 +1157,7 @@
"-lc"
],
"custom_args": [
"export DAEMON_URL=\"http://archipelago:$(printenv BITCOIN_RPC_PASS)@bitcoin-knots:8332/\"; exec electrumx_server"
"for h in bitcoin-knots bitcoin-core; do if getent hosts \"$h\" >/dev/null 2>&1; then BTC_HOST=\"$h\"; break; fi; done; export DAEMON_URL=\"http://archipelago:$(printenv BITCOIN_RPC_PASS)@${BTC_HOST:-bitcoin-knots}:8332/\"; exec electrumx_server"
],
"secret_env": [
{
+21 -25
View File
@@ -1,35 +1,31 @@
{
"version": "1.7.105-alpha",
"release_date": "2026-07-20",
"changelog": [
"The password you choose during setup is now truly your node's password: it also becomes the system login for console and SSH access, instead of leaving the factory default in place. If you ever renamed your node and the TV screen went black on the next boot, that's fixed too — renaming no longer breaks the kiosk display.",
"Setting up Lightning is now a guided journey: a fund-your-wallet step that shows a live countdown while Bitcoin syncs, suggested channels you can open straight into the Zeus mobile wallet with one tap, and a \"finish setup\" prompt that walks you to the end — goals now complete when you've actually done the steps, not just when apps happen to be running.",
"Pair your phone by pointing it at the screen: the companion app now connects by scanning a QR code — scan, and it fills in your node's address and logs you in. The App Store has a banner to grab the Android app, and the pairing flow can now also set up secure remote access so your phone reaches home from anywhere.",
"First installs are far more dependable: app downloads that stall now retry instead of hanging forever (the old \"first install fails, the second works\" pattern), big multi-part apps show their real download progress instead of sitting at \"Preparing\", Lightning no longer fails its first install over temporary hiccups, and a brand-new node now comes up with its core apps — file cloud and ecash wallet — even with no internet connection.",
"The installer image is about 160MB smaller and gets to a working screen faster, because the apps bundled for offline setup are now compressed.",
"The first-run experience keeps its magic: the typing intro is back on fresh installs and can no longer be cut short by a mid-play refresh — updates now politely wait for the cinematic to finish — and dark backgrounds stay dark instead of flashing black or white.",
"Your backups now include your secrets — including the key that protects your Lightning wallet's recovery seed — and there's a Download button to take a copy off the node; the seed-backup reminder now actually opens the backup flow when you tap it.",
"Networking Profits grew into a full dashboard, network cards keep their action buttons in reach on every screen size, \"Connect to Mesh\" goes to the right page instead of a dead end, and the identity pages got a round of mobile polish.",
"Behind the scenes: apps that report their own health are no longer second-guessed by a port probe (fewer false \"restarting\" states), and pressing arrow keys or a gamepad is once again the only thing that shows the controller focus ring."
"Fixed a failure loop where a node that lost power or was moved could get stuck on a blank \"can't reach your node\" screen forever: startup recovery no longer spends minutes retrying containers that no longer exist, and a genuinely large recovery is no longer cut off half-way and forced to start over. The node now reaches its login screen even after the messiest shutdown.",
"Phone tunnel setup (WireGuard) is now dependable: the QR screen automatically retries while a fresh install is still settling instead of dead-ending at \"failed to fetch\", and if your node has moved to a different network the QR and downloadable config now carry the node's current address instead of the old one.",
"Fixed the white screen some laptop displays showed right after the intro on v1.7.104.",
"The companion phone app no longer suggests installing the companion app from inside itself.",
"The Tor page now lists onion addresses only for apps you actually have installed \u2014 fresh installs no longer come with six pre-made addresses for apps that were never set up.",
"Running `archipelago --version` or `--help` on the command line now prints and exits instead of silently starting a second copy of the node, which could briefly disrupt running apps.",
"Behind the scenes: installer image builds now stop loudly if VPN components are missing instead of producing a broken image, and a background file-permission sweep runs far less often, reducing disk churn on busy nodes."
],
"components": [
{
"current_version": "1.7.102-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.102-alpha/archipelago",
"name": "archipelago",
"new_version": "1.7.102-alpha",
"sha256": "13218bfac5f3e1b641edebac0fcccb483fb4500d764369da4947a467762f2e5a",
"size_bytes": 49951520
"current_version": "1.7.105-alpha",
"new_version": "1.7.105-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.105-alpha/archipelago",
"sha256": "5e9eb56458dd1bc4d60904ed3c84f8a8409778e640d900084e0e57b2c8c52143",
"size_bytes": 50275432
},
{
"current_version": "1.7.102-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.102-alpha/archipelago-frontend-1.7.102-alpha.tar.gz",
"name": "archipelago-frontend-1.7.102-alpha.tar.gz",
"new_version": "1.7.102-alpha",
"sha256": "663cf9a35d98fa6dad2d7fb2906e4a939a906382f2e9729156c3630e282cdc94",
"size_bytes": 174594796
"name": "archipelago-frontend-1.7.105-alpha.tar.gz",
"current_version": "1.7.105-alpha",
"new_version": "1.7.105-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.105-alpha/archipelago-frontend-1.7.105-alpha.tar.gz",
"sha256": "486ed02515e62caf3c40e8de0fe388ad7fb6589e454bec77111ac4780a1f6f01",
"size_bytes": 174593338
}
],
"release_date": "2026-07-17",
"signature": "999e50b9aff22f544677986ca8c686614d8c3d4cbe501c2d5de86d2a1ea56fc731bb9434d722c41c7a124932f98e629706b41a64f57551e69529be59fe671d04",
"signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur",
"version": "1.7.102-alpha"
]
}
+72
View File
@@ -0,0 +1,72 @@
#!/bin/bash
# OTA crash-loop guard — runs as root from ExecStartPre=+- on archipelago.service.
#
# Covers the failure mode verify_pending_update() cannot: a freshly-applied
# binary that can't even start (SEGV/ENOEXEC — e.g. the truncated 17MB binary
# .198 installed on the v1.7.103 OTA, which crash-looped 236 times with a
# perfectly good backup sitting in update-backup/). The in-binary probe never
# runs because the binary never runs, so this guard counts start attempts from
# outside and restores the backup binary once the new one has clearly failed.
#
# Scope is deliberately narrow: it acts ONLY while the post-OTA pending-verify
# marker exists (written by apply_update just before the restart, deleted by
# the new binary once it boots and passes its probes). A crash loop with no
# marker is not an OTA gone wrong, and this script stays out of it.
#
# Always exits 0 — a guard must never be the reason the service can't start.
set -u
DATA_DIR=/var/lib/archipelago
MARKER="$DATA_DIR/update-pending-verify.json"
COUNT_FILE="$DATA_DIR/ota-crash-guard.count"
BACKUP="$DATA_DIR/update-backup/archipelago"
BINARY=/usr/local/bin/archipelago
MAX_ATTEMPTS=5
log() {
echo "$*" | systemd-cat -t ota-crash-guard -p warning 2>/dev/null || true
}
# No pending OTA verification -> nothing to guard; clear any stale counter.
if [ ! -f "$MARKER" ]; then
rm -f "$COUNT_FILE"
exit 0
fi
# Count this start attempt. The counter only accumulates while the marker
# exists; a healthy new binary deletes the marker on its first successful
# boot, and the next start clears the counter above.
count=$(cat "$COUNT_FILE" 2>/dev/null || echo 0)
case "$count" in ''|*[!0-9]*) count=0 ;; esac
count=$((count + 1))
echo "$count" > "$COUNT_FILE" 2>/dev/null || true
if [ "$count" -lt "$MAX_ATTEMPTS" ]; then
exit 0
fi
if [ ! -f "$BACKUP" ]; then
log "OTA crash guard: $count failed start attempts but no backup binary at $BACKUP — cannot roll back"
exit 0
fi
# Already restored (or the OTA never replaced the binary)? Don't loop.
if cmp -s "$BACKUP" "$BINARY"; then
exit 0
fi
# Restore via copy-to-temp + atomic rename; never truncate the live path.
tmp="$BINARY.rollback.$$"
if cp "$BACKUP" "$tmp" && chown root:root "$tmp" && chmod 755 "$tmp" && mv "$tmp" "$BINARY"; then
# Leave a tombstone for the UI/logs instead of the marker so the restored
# binary doesn't run the post-OTA probe against the rolled-back version.
mv "$MARKER" "$DATA_DIR/update-rolled-back.json" 2>/dev/null || rm -f "$MARKER"
rm -f "$COUNT_FILE"
log "OTA crash guard: restored previous binary after $count failed start attempts of the updated one"
else
rm -f "$tmp" 2>/dev/null
log "OTA crash guard: failed to restore backup binary (cp/mv error)"
fi
exit 0
+17
View File
@@ -103,6 +103,23 @@ for f in /usr/local/bin/archipelago \
fi
done
# 1.1b — Every enabled archipelago/nostr unit must have an existing ExecStart
# payload. v1.7.104 shipped nostr-relay.service without its binary (3s
# crash-loop forever) and archipelago-diag.service without its script
# (203/EXEC) — catch that class of ISO defect on first boot, by generic rule.
for unit in /etc/systemd/system/archipelago*.service /etc/systemd/system/nostr*.service; do
[ -f "$unit" ] || continue
systemctl is-enabled "$(basename "$unit")" >/dev/null 2>&1 || continue
exec_bin=$(grep -m1 '^ExecStart=' "$unit" | sed 's/^ExecStart=[-+@!:]*//' | awk '{print $1}')
case "$exec_bin" in
/*) if [ -e "$exec_bin" ]; then
pass "Unit payload exists: $(basename "$unit")$exec_bin"
else
fail "Unit payload missing" "$(basename "$unit")$exec_bin"
fi ;;
esac
done
# 1.2 — Critical services active
for svc in archipelago nginx; do
if systemctl is-active "$svc" >/dev/null 2>&1; then