Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2f83c0157 | ||
|
|
d5f709a3c3 | ||
|
|
710f576c77 | ||
|
|
c1d309f21f | ||
|
|
9c39243969 | ||
|
|
f25febf3bb | ||
|
|
0636d611e0 | ||
|
|
f13fdc6451 | ||
|
|
163bc3af01 | ||
|
|
ae12ff2517 | ||
|
|
cf4a8eef0e | ||
|
|
e5f8b5d789 | ||
|
|
aad6faa6d2 | ||
|
|
20edd31abb | ||
|
|
9a7331cead | ||
|
|
9eadec6936 | ||
|
|
6b0a84e710 | ||
|
|
fd361fb35e | ||
|
|
8bb61a51e2 | ||
|
|
17d225190a | ||
|
|
d08c0d29c7 | ||
|
|
a93bd70c5a | ||
|
|
25162ee846 | ||
|
|
837cfdfd1f | ||
|
|
573b469191 | ||
|
|
401f92a24f | ||
|
|
dc0adbef70 | ||
|
|
537c9fa70b | ||
|
|
b6468ebf3c | ||
|
|
70587210fb |
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+1
-1
@@ -95,7 +95,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.7.102-alpha"
|
||||
version = "1.7.104-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+2
-2
@@ -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,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
@@ -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,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
|
||||
|
||||
@@ -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 Enter→click-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"
|
||||
|
||||
@@ -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
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
Executable
+72
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user