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
22 changed files with 556 additions and 131 deletions
+10
View File
@@ -1,5 +1,15 @@
# 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.
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "archipelago"
version = "1.7.104-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();
@@ -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
+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.104-alpha",
"version": "1.7.105-alpha",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "neode-ui",
"version": "1.7.104-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.104-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
@@ -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 -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>
@@ -362,6 +362,22 @@ 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">
+21 -20
View File
@@ -1,30 +1,31 @@
{
"version": "1.7.105-alpha",
"release_date": "2026-07-20",
"changelog": [
"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."
"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.104-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.104-alpha/archipelago",
"name": "archipelago",
"new_version": "1.7.104-alpha",
"sha256": "6f290654d3f6c784dd9518df673a14783b50f8832937306943bf50e62a33f1bb",
"size_bytes": 49976648
"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.104-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.104-alpha/archipelago-frontend-1.7.104-alpha.tar.gz",
"name": "archipelago-frontend-1.7.104-alpha.tar.gz",
"new_version": "1.7.104-alpha",
"sha256": "8413af30dadbcade9ca40984beeac17add1b0477de7b1f7e4274f1482658d554",
"size_bytes": 174592628
"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-19",
"signature": "c1e2f9312d44c6109a77ff4500ce511cfa66b0c879a35271c7295d5673172053942de911db5e64306c4bef78b4bfc259cac0bf0fe1fc8dadfe77b14d4a5c0d08",
"signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur",
"version": "1.7.104-alpha"
]
}
+21 -20
View File
@@ -1,30 +1,31 @@
{
"version": "1.7.105-alpha",
"release_date": "2026-07-20",
"changelog": [
"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."
"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.104-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.104-alpha/archipelago",
"name": "archipelago",
"new_version": "1.7.104-alpha",
"sha256": "6f290654d3f6c784dd9518df673a14783b50f8832937306943bf50e62a33f1bb",
"size_bytes": 49976648
"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.104-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.104-alpha/archipelago-frontend-1.7.104-alpha.tar.gz",
"name": "archipelago-frontend-1.7.104-alpha.tar.gz",
"new_version": "1.7.104-alpha",
"sha256": "8413af30dadbcade9ca40984beeac17add1b0477de7b1f7e4274f1482658d554",
"size_bytes": 174592628
"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-19",
"signature": "c1e2f9312d44c6109a77ff4500ce511cfa66b0c879a35271c7295d5673172053942de911db5e64306c4bef78b4bfc259cac0bf0fe1fc8dadfe77b14d4a5c0d08",
"signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur",
"version": "1.7.104-alpha"
]
}
+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