fix: fresh-ISO feedback bug-bash — onboarding, status truthfulness, recovery, kiosk, logs
Fixes from real fresh-install feedback (Framework node .81) + its log bundle:
Backend:
- websocket: subscribe before initial snapshot — broadcasts in the gap were
silently lost, stranding clients on stale state until a hard refresh
(the "everything needs ctrl-r" bug: My Apps stuck Loading, App Store
stuck Checking, containers-scanned never arriving)
- crash recovery: check the crash marker BEFORE writing our own PID —
recovery had never run on any node (always saw its own PID and skipped);
PID-reuse guard via /proc cmdline
- boot status: pending-boot-starts registry (recovery, stack recovery,
reconciler, adoption) — scanner overlays queued-but-down apps as
Restarting instead of Stopped after a reboot; scanner-authored
Restarting resolves immediately on a settled scan (no transitional wedge)
- install deps: bounded wait (36x5s) when a dependency is installed but
still starting ("Waiting for Bitcoin to start…") instead of instant
rejection; dependency-gate rejections remove the optimistic entry (no
phantom Stopped tile) and surface as a notification
- seed backup: auth.setup persists the onboarding mnemonic as the
encrypted seed backup (reveal previously failed on EVERY node — nothing
ever wrote master_seed.enc); seed.restore stashes too; error sanitizer
lets seed/2FA errors through instead of "Check server logs"
- lnd: bitcoind.rpchost resolved from the running Bitcoin variant
(hardcoded bitcoin-knots broke Core nodes); manifest uses derived_env
- bitcoin status: clean human message for connection-reset/startup; raw
URLs + os-error chains no longer reach the app card
- fedimint-clientd: chown /var/lib/archipelago/fmcd to 1000:1000 (root-
created dir crash-looped the rootless container, EACCES) — first-boot
script + pre-start self-heal
- log volume (>1GB/day on a day-old node): journald caps drop-in (ISO +
bootstrap self-heal), bitcoind -printtoconsole=0 everywhere (90% of the
journal was IBD UpdateTip spam), tracing default debug→info
Frontend:
- Login: Enter advances to confirm field then submits; submit always
clickable with inline errors (was silently disabled on mismatch);
Restart Onboarding needs a confirming second click (the mismatch →
"onboarding restarted" trap)
- sync store: 30s state reconciliation + refetch on re-entrant connect;
20s containers-scanned escape hatch so Checking can never show forever;
fresh empty node reaches the real "no apps yet" state
- intro video: CRF20 re-encode (SSIM 0.988) + faststart — moov was at EOF
so playback needed the full 15MB first (the intro lag)
- backgrounds: 10 heaviest JPEGs → WebP q90 (9.4MB→6.6MB); 7 stayed JPEG
(WebP larger on noisy sources)
- Web5ConnectedNodes: drop unused template ref that failed vue-tsc -b
ISO/kiosk:
- nginx: /assets/ 404s no longer cached immutable for a year; HTTPS block
gained the missing /assets/ location (served index.html as images)
- kiosk: launcher/service spliced from configs/ at ISO build (stale
heredoc force-disabled GPU); MemoryHigh/Max 1200/1500→2200/2800M (kiosk
rode the reclaim throttle = the lag); firmware-intel-graphics +
firmware-amd-graphics (trixie split DMC blobs out of misc-nonfree)
Verified: cargo test 898/898 green, npm run build green with dist
contents confirmed (webp refs, lnd.png, faststart video, new strings).
Handover for ISO build + deploy: docs/HANDOVER-2026-07-02-iso-feedback.md
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ -1,7 +1,7 @@
|
|||||||
app:
|
app:
|
||||||
id: archy-btcpay-db
|
id: archy-btcpay-db
|
||||||
name: BTCPay Postgres
|
name: BTCPay Postgres
|
||||||
version: 15.17
|
version: "15.17"
|
||||||
description: Postgres backend for BTCPay and NBXplorer.
|
description: Postgres backend for BTCPay and NBXplorer.
|
||||||
|
|
||||||
container:
|
container:
|
||||||
|
|||||||
@ -17,6 +17,13 @@ app:
|
|||||||
# the IBD sweet spot - 4GB on full nodes, 1GB on pruned. Container
|
# the IBD sweet spot - 4GB on full nodes, 1GB on pruned. Container
|
||||||
# --memory=8g (config.rs::get_memory_limit) leaves headroom for
|
# --memory=8g (config.rs::get_memory_limit) leaves headroom for
|
||||||
# mempool + connections.
|
# mempool + connections.
|
||||||
|
#
|
||||||
|
# -printtoconsole=0: foreground bitcoind defaults console logging ON,
|
||||||
|
# which pushed every IBD "UpdateTip" line through conmon into journald
|
||||||
|
# (>1 GB/day on a fresh node). bitcoind still writes debug.log in the
|
||||||
|
# datadir (/var/lib/archipelago/bitcoin/debug.log, self-shrunk on
|
||||||
|
# restart) — use that for deep debugging; podman logs only carries
|
||||||
|
# entrypoint/startup errors.
|
||||||
- >-
|
- >-
|
||||||
BITCOIND="$(command -v bitcoind || true)";
|
BITCOIND="$(command -v bitcoind || true)";
|
||||||
if [ -z "$BITCOIND" ]; then
|
if [ -z "$BITCOIND" ]; then
|
||||||
@ -36,9 +43,9 @@ app:
|
|||||||
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
|
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
|
||||||
fi;
|
fi;
|
||||||
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
|
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
|
||||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
|
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
|
||||||
else
|
else
|
||||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
|
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
|
||||||
fi
|
fi
|
||||||
derived_env:
|
derived_env:
|
||||||
- key: DISK_GB
|
- key: DISK_GB
|
||||||
|
|||||||
@ -17,6 +17,13 @@ app:
|
|||||||
# the IBD sweet spot - 4GB on full nodes, 1GB on pruned. Container
|
# the IBD sweet spot - 4GB on full nodes, 1GB on pruned. Container
|
||||||
# --memory=8g (config.rs::get_memory_limit) leaves headroom for
|
# --memory=8g (config.rs::get_memory_limit) leaves headroom for
|
||||||
# mempool + connections.
|
# mempool + connections.
|
||||||
|
#
|
||||||
|
# -printtoconsole=0: foreground bitcoind defaults console logging ON,
|
||||||
|
# which pushed every IBD "UpdateTip" line through conmon into journald
|
||||||
|
# (>1 GB/day on a fresh node). bitcoind still writes debug.log in the
|
||||||
|
# datadir (/var/lib/archipelago/bitcoin/debug.log, self-shrunk on
|
||||||
|
# restart) — use that for deep debugging; podman logs only carries
|
||||||
|
# entrypoint/startup errors.
|
||||||
- >-
|
- >-
|
||||||
BITCOIND="$(command -v bitcoind || true)";
|
BITCOIND="$(command -v bitcoind || true)";
|
||||||
if [ -z "$BITCOIND" ]; then
|
if [ -z "$BITCOIND" ]; then
|
||||||
@ -36,9 +43,9 @@ app:
|
|||||||
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
|
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
|
||||||
fi;
|
fi;
|
||||||
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
|
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
|
||||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
|
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
|
||||||
else
|
else
|
||||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
|
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
|
||||||
fi
|
fi
|
||||||
derived_env:
|
derived_env:
|
||||||
- key: DISK_GB
|
- key: DISK_GB
|
||||||
|
|||||||
@ -8,6 +8,13 @@ app:
|
|||||||
image: 146.59.87.168:3000/lfg2025/lnd:v0.18.4-beta
|
image: 146.59.87.168:3000/lfg2025/lnd:v0.18.4-beta
|
||||||
pull_policy: if-not-present
|
pull_policy: if-not-present
|
||||||
network: archy-net
|
network: archy-net
|
||||||
|
# BITCOIND_HOST must follow the node's actual Bitcoin container — Knots or
|
||||||
|
# Core — resolved at apply time from host facts. Hardcoding either breaks
|
||||||
|
# LND's chain backend connection on the other (lnd.conf is likewise
|
||||||
|
# resolved in lnd::ensure_config).
|
||||||
|
derived_env:
|
||||||
|
- key: BITCOIND_HOST
|
||||||
|
template: "{{BITCOIN_HOST}}"
|
||||||
secret_env:
|
secret_env:
|
||||||
- key: BITCOIND_RPCPASS
|
- key: BITCOIND_RPCPASS
|
||||||
secret_file: bitcoin-rpc-password
|
secret_file: bitcoin-rpc-password
|
||||||
@ -45,7 +52,6 @@ app:
|
|||||||
options: [rw]
|
options: [rw]
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
- BITCOIND_HOST=bitcoin-knots
|
|
||||||
- BITCOIND_RPCUSER=archipelago
|
- BITCOIND_RPCUSER=archipelago
|
||||||
- NETWORK=mainnet
|
- NETWORK=mainnet
|
||||||
|
|
||||||
|
|||||||
@ -39,6 +39,17 @@ impl ApiHandler {
|
|||||||
|
|
||||||
let (mut tx, mut rx) = ws_stream.split();
|
let (mut tx, mut rx) = ws_stream.split();
|
||||||
|
|
||||||
|
// Subscribe BEFORE taking the initial snapshot. Messages are full
|
||||||
|
// data dumps keyed by a monotonic revision, so a broadcast that
|
||||||
|
// races the snapshot is at worst a harmless duplicate/newer dump
|
||||||
|
// delivered right after — but subscribing after the snapshot send
|
||||||
|
// (the old order) let any update in that window vanish forever,
|
||||||
|
// since a tokio broadcast channel never delivers sends that
|
||||||
|
// predate subscribe(). That silently stuck clients (e.g. a fresh
|
||||||
|
// install's post-boot container scan) on a stale initial snapshot
|
||||||
|
// until a full page reload opened a new connection past the race.
|
||||||
|
let mut state_rx = state_manager.subscribe();
|
||||||
|
|
||||||
let initial_msg = state_manager.get_initial_message().await;
|
let initial_msg = state_manager.get_initial_message().await;
|
||||||
if let Ok(json_msg) = serde_json::to_string(&initial_msg) {
|
if let Ok(json_msg) = serde_json::to_string(&initial_msg) {
|
||||||
if let Err(e) = tx.send(Message::Text(json_msg)).await {
|
if let Err(e) = tx.send(Message::Text(json_msg)).await {
|
||||||
@ -47,8 +58,6 @@ impl ApiHandler {
|
|||||||
}
|
}
|
||||||
debug!("Sent initial data dump at revision {}", initial_msg.rev);
|
debug!("Sent initial data dump at revision {}", initial_msg.rev);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut state_rx = state_manager.subscribe();
|
|
||||||
let ping_interval = tokio::time::interval(tokio::time::Duration::from_secs(30));
|
let ping_interval = tokio::time::interval(tokio::time::Duration::from_secs(30));
|
||||||
tokio::pin!(ping_interval);
|
tokio::pin!(ping_interval);
|
||||||
let mut last_client_activity = Instant::now();
|
let mut last_client_activity = Instant::now();
|
||||||
|
|||||||
@ -141,6 +141,19 @@ impl RpcHandler {
|
|||||||
|
|
||||||
self.auth_manager.setup_user(password).await?;
|
self.auth_manager.setup_user(password).await?;
|
||||||
tracing::info!("[onboarding] user setup complete");
|
tracing::info!("[onboarding] user setup complete");
|
||||||
|
|
||||||
|
// Persist the pending onboarding seed as the encrypted backup now that
|
||||||
|
// a passphrase (the login password) finally exists — otherwise "Reveal
|
||||||
|
// recovery phrase" has nothing to decrypt on this node, ever.
|
||||||
|
// Best-effort: a failure here must not break password setup.
|
||||||
|
match super::seed_rpc::save_pending_seed_encrypted(&self.config.data_dir, password).await {
|
||||||
|
Ok(true) => tracing::info!("[onboarding] encrypted seed backup saved"),
|
||||||
|
Ok(false) => tracing::info!(
|
||||||
|
"[onboarding] no pending mnemonic to back up (restored earlier or legacy node)"
|
||||||
|
),
|
||||||
|
Err(e) => tracing::warn!("[onboarding] encrypted seed backup failed: {e:#}"),
|
||||||
|
}
|
||||||
|
|
||||||
Ok(serde_json::json!(true))
|
Ok(serde_json::json!(true))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -64,6 +64,19 @@ pub(super) fn sanitize_error_message(msg: &str) -> String {
|
|||||||
"Container",
|
"Container",
|
||||||
"Image",
|
"Image",
|
||||||
"Bitcoin address",
|
"Bitcoin address",
|
||||||
|
// seed.reveal / auth flows — user-actionable, no internals to leak.
|
||||||
|
// Without these the sanitizer collapsed every reveal failure into
|
||||||
|
// "Operation failed. Check server logs." (which isn't even a crash).
|
||||||
|
"Incorrect",
|
||||||
|
"This node has no encrypted seed",
|
||||||
|
"A 2FA code is required",
|
||||||
|
"2FA is enabled but",
|
||||||
|
"Could not decrypt the saved seed",
|
||||||
|
"Could not unlock 2FA",
|
||||||
|
"No mnemonic available",
|
||||||
|
"No pending seed generation",
|
||||||
|
"Submitted words",
|
||||||
|
"Already set up",
|
||||||
];
|
];
|
||||||
for prefix in &user_facing_prefixes {
|
for prefix in &user_facing_prefixes {
|
||||||
if msg.starts_with(prefix) {
|
if msg.starts_with(prefix) {
|
||||||
@ -83,6 +96,43 @@ pub(super) fn sanitize_error_message(msg: &str) -> String {
|
|||||||
"Operation failed. Check server logs for details.".to_string()
|
"Operation failed. Check server logs for details.".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod sanitize_tests {
|
||||||
|
use super::sanitize_error_message;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn seed_reveal_errors_pass_through() {
|
||||||
|
// Every user-actionable seed.reveal failure must reach the user —
|
||||||
|
// masking them as "Check server logs" sent a real user hunting a
|
||||||
|
// crash that never happened.
|
||||||
|
for msg in [
|
||||||
|
"Incorrect password",
|
||||||
|
"This node has no encrypted seed backup, so the recovery phrase cannot be shown. It was only displayed once during setup.",
|
||||||
|
"A 2FA code is required to reveal the recovery phrase",
|
||||||
|
"2FA is enabled but no TOTP data found",
|
||||||
|
"Could not decrypt the saved seed. If you set a separate backup passphrase during setup, enter that passphrase.",
|
||||||
|
"Could not unlock 2FA with this password",
|
||||||
|
"No mnemonic available. Generate or restore a seed first.",
|
||||||
|
"Submitted words do not match generated seed",
|
||||||
|
"Already set up. Use auth.changePassword to change.",
|
||||||
|
] {
|
||||||
|
assert_ne!(
|
||||||
|
sanitize_error_message(msg),
|
||||||
|
"Operation failed. Check server logs for details.",
|
||||||
|
"masked: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn internal_errors_stay_generic() {
|
||||||
|
assert_eq!(
|
||||||
|
sanitize_error_message("thread panicked at src/foo.rs:42"),
|
||||||
|
"Operation failed. Check server logs for details."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Derive a CSRF token from the session token via HMAC.
|
/// Derive a CSRF token from the session token via HMAC.
|
||||||
/// Deterministic: same session token always produces the same CSRF token.
|
/// Deterministic: same session token always produces the same CSRF token.
|
||||||
/// Survives backend restarts because it depends only on the session token
|
/// Survives backend restarts because it depends only on the session token
|
||||||
|
|||||||
@ -114,6 +114,31 @@ impl RpcHandler {
|
|||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("package.install {} failed: {:#}", package_id_spawn, e);
|
error!("package.install {} failed: {:#}", package_id_spawn, e);
|
||||||
install_log(&format!("INSTALL FAIL: {} — {:#}", package_id_spawn, e)).await;
|
install_log(&format!("INSTALL FAIL: {} — {:#}", package_id_spawn, e)).await;
|
||||||
|
// Dependency-gate rejections happen BEFORE any resource
|
||||||
|
// (container/image/data dir) exists for this package, so
|
||||||
|
// keeping the optimistic entry would leave a phantom
|
||||||
|
// "Stopped" tile whose Start fails with `no such object`
|
||||||
|
// (the log-confirmed LND fresh-install failure). Remove
|
||||||
|
// the entry so the card reverts to installable, and
|
||||||
|
// surface the reason as a notification instead.
|
||||||
|
if let Some(gate) = e.downcast_ref::<super::dependencies::DependencyGateError>()
|
||||||
|
{
|
||||||
|
let (mut data, _) = handler.state_manager.get_snapshot().await;
|
||||||
|
data.package_data.remove(&package_id_spawn);
|
||||||
|
data.notifications.push(crate::data_model::Notification {
|
||||||
|
id: format!("install-deps-{package_id_spawn}"),
|
||||||
|
level: crate::data_model::NotificationLevel::Error,
|
||||||
|
title: format!("Could not install {package_id_spawn}"),
|
||||||
|
message: gate.to_string(),
|
||||||
|
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||||
|
app_id: Some(package_id_spawn.clone()),
|
||||||
|
});
|
||||||
|
while data.notifications.len() > 20 {
|
||||||
|
data.notifications.remove(0);
|
||||||
|
}
|
||||||
|
handler.state_manager.update_data(data).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Don't remove the entry — that's what made the card
|
// Don't remove the entry — that's what made the card
|
||||||
// vanish from My Apps mid-install / between retry-loop
|
// vanish from My Apps mid-install / between retry-loop
|
||||||
// attempts (e.g. tailscale's entrypoint failure). Leave
|
// attempts (e.g. tailscale's entrypoint failure). Leave
|
||||||
|
|||||||
@ -707,12 +707,17 @@ pub(super) async fn get_app_config(
|
|||||||
// effectively pinned at 2 by --cpus=2 (now removed).
|
// effectively pinned at 2 by --cpus=2 (now removed).
|
||||||
// -maxconnections=125 — default but explicit, so ops can
|
// -maxconnections=125 — default but explicit, so ops can
|
||||||
// tune downward on bandwidth-constrained nodes.
|
// tune downward on bandwidth-constrained nodes.
|
||||||
|
// Log volume: -printtoconsole=0 — bitcoind already writes
|
||||||
|
// debug.log in the datadir (self-shrunk on restart); echoing it
|
||||||
|
// to stdout too pushed every IBD "UpdateTip" line through
|
||||||
|
// conmon into journald (>1 GB/day on a fresh node). Deep
|
||||||
|
// debugging uses /var/lib/archipelago/bitcoin/debug.log.
|
||||||
Some(vec![
|
Some(vec![
|
||||||
"-server=1".to_string(),
|
"-server=1".to_string(),
|
||||||
"-rpcbind=0.0.0.0".to_string(),
|
"-rpcbind=0.0.0.0".to_string(),
|
||||||
"-rpcallowip=0.0.0.0/0".to_string(),
|
"-rpcallowip=0.0.0.0/0".to_string(),
|
||||||
"-rpcport=8332".to_string(),
|
"-rpcport=8332".to_string(),
|
||||||
"-printtoconsole=1".to_string(),
|
"-printtoconsole=0".to_string(),
|
||||||
"-datadir=/home/bitcoin/.bitcoin".to_string(),
|
"-datadir=/home/bitcoin/.bitcoin".to_string(),
|
||||||
format!("-dbcache={}", bitcoin_dbcache_mb()),
|
format!("-dbcache={}", bitcoin_dbcache_mb()),
|
||||||
"-par=0".to_string(),
|
"-par=0".to_string(),
|
||||||
|
|||||||
@ -58,6 +58,7 @@ fn archival_bitcoin_required_message(package_id: &str) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Snapshot of which dependency services are currently running.
|
/// Snapshot of which dependency services are currently running.
|
||||||
|
#[derive(Debug)]
|
||||||
pub(super) struct RunningDeps {
|
pub(super) struct RunningDeps {
|
||||||
pub has_bitcoin: bool,
|
pub has_bitcoin: bool,
|
||||||
pub has_electrumx: bool,
|
pub has_electrumx: bool,
|
||||||
@ -227,6 +228,190 @@ pub(super) fn check_install_deps(package_id: &str, deps: &RunningDeps) -> Result
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Bounded dependency wait (install race fix)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Confirmed race on fresh nodes: the user clicks "Install LND" while
|
||||||
|
// bitcoin-knots is itself still installing/starting. `check_install_deps`
|
||||||
|
// rejected instantly ("LND requires a running Bitcoin node…") even though
|
||||||
|
// Bitcoin came up 55s later. The fix: when the dependency is INSTALLED
|
||||||
|
// (container exists in `podman ps -a`, or the package state knows about it)
|
||||||
|
// but not Running yet, poll for up to DEP_WAIT_MAX_ATTEMPTS × DEP_WAIT_INTERVAL
|
||||||
|
// (~3 minutes) before failing, surfacing "Waiting for X to start…" via the
|
||||||
|
// install-progress message. If the dependency is not installed at all, fail
|
||||||
|
// fast with the canonical `check_install_deps` message — waiting can't help.
|
||||||
|
|
||||||
|
/// Poll interval while waiting for an installed dependency to start.
|
||||||
|
pub(super) const DEP_WAIT_INTERVAL: std::time::Duration = std::time::Duration::from_secs(5);
|
||||||
|
/// 36 × 5s = 3 minutes of bounded waiting.
|
||||||
|
pub(super) const DEP_WAIT_MAX_ATTEMPTS: u32 = 36;
|
||||||
|
|
||||||
|
/// Marker error: the install was rejected by the dependency gate BEFORE any
|
||||||
|
/// resource (container, image, data dir) was created for the package. The
|
||||||
|
/// async install wrapper (`async_lifecycle.rs`) downcasts to this to remove
|
||||||
|
/// the optimistic `Installing` state entry instead of leaving a phantom
|
||||||
|
/// "Stopped" tile whose Start fails with `no such object`.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(in crate::api::rpc) struct DependencyGateError(pub String);
|
||||||
|
|
||||||
|
impl std::fmt::Display for DependencyGateError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str(&self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for DependencyGateError {}
|
||||||
|
|
||||||
|
/// One unsatisfied install dependency: a user-facing label plus the container
|
||||||
|
/// name variants that would satisfy it.
|
||||||
|
struct MissingDep {
|
||||||
|
label: &'static str,
|
||||||
|
containers: &'static [&'static str],
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which dependencies `check_install_deps` would reject `package_id` over.
|
||||||
|
/// Must stay in lockstep with the match arms in `check_install_deps` (the
|
||||||
|
/// wait loop re-runs `check_install_deps` for the canonical error message).
|
||||||
|
fn missing_install_deps(package_id: &str, deps: &RunningDeps) -> Vec<MissingDep> {
|
||||||
|
const BITCOIN: MissingDep = MissingDep {
|
||||||
|
label: "Bitcoin",
|
||||||
|
containers: BITCOIN_NAMES,
|
||||||
|
};
|
||||||
|
const ELECTRUM: MissingDep = MissingDep {
|
||||||
|
label: "ElectrumX",
|
||||||
|
containers: ELECTRUM_NAMES,
|
||||||
|
};
|
||||||
|
let mut missing = Vec::new();
|
||||||
|
match package_id {
|
||||||
|
"electrumx" | "mempool-electrs" | "electrs" | "lnd" | "btcpay-server" | "btcpayserver" => {
|
||||||
|
if !deps.has_bitcoin {
|
||||||
|
missing.push(BITCOIN);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"mempool" | "mempool-web" => {
|
||||||
|
if !deps.has_bitcoin {
|
||||||
|
missing.push(BITCOIN);
|
||||||
|
}
|
||||||
|
if !deps.has_electrumx {
|
||||||
|
missing.push(ELECTRUM);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// fedimint deliberately absent: check_install_deps allows it without
|
||||||
|
// a local Bitcoin node (remote RPC configured in guardian setup).
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
missing
|
||||||
|
}
|
||||||
|
|
||||||
|
fn join_dep_labels(missing: &[MissingDep]) -> String {
|
||||||
|
missing
|
||||||
|
.iter()
|
||||||
|
.map(|d| d.label)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" and ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One snapshot of the dependency world, fed to [`wait_for_install_deps`].
|
||||||
|
pub(super) struct DepProbe {
|
||||||
|
/// Which dependency services are currently Running.
|
||||||
|
pub running: RunningDeps,
|
||||||
|
/// Container/package names that EXIST in any state — installed, but
|
||||||
|
/// possibly not running yet (`podman ps -a` ∪ package-state entries).
|
||||||
|
pub existing: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All container names known to podman in any state (`podman ps -a`).
|
||||||
|
/// Conservative on probe failure: returns an empty list, which makes the
|
||||||
|
/// wait loop fall back to the pre-fix fail-fast behavior.
|
||||||
|
pub(super) async fn detect_existing_containers() -> Vec<String> {
|
||||||
|
let out = tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(30),
|
||||||
|
tokio::process::Command::new("podman")
|
||||||
|
.args(["ps", "-a", "--format", "{{.Names}}"])
|
||||||
|
.output(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
match out {
|
||||||
|
Ok(Ok(o)) if o.status.success() => String::from_utf8_lossy(&o.stdout)
|
||||||
|
.lines()
|
||||||
|
.map(|l| l.trim().to_string())
|
||||||
|
.filter(|l| !l.is_empty())
|
||||||
|
.collect(),
|
||||||
|
_ => Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bounded dependency gate. Returns the (satisfied) `RunningDeps` snapshot,
|
||||||
|
/// or a [`DependencyGateError`]:
|
||||||
|
/// - immediately, when a missing dependency is not installed at all
|
||||||
|
/// (canonical `check_install_deps` message), or
|
||||||
|
/// - after `max_attempts × interval`, when an installed dependency never
|
||||||
|
/// reached Running.
|
||||||
|
///
|
||||||
|
/// `probe` and `on_waiting` are injected so unit tests can drive the loop
|
||||||
|
/// without a podman runtime; production wires them to
|
||||||
|
/// `RpcHandler::dep_probe_for_install` / `set_install_message`.
|
||||||
|
pub(super) async fn wait_for_install_deps<P, PF, L, LF>(
|
||||||
|
package_id: &str,
|
||||||
|
mut probe: P,
|
||||||
|
mut on_waiting: L,
|
||||||
|
max_attempts: u32,
|
||||||
|
interval: std::time::Duration,
|
||||||
|
) -> Result<RunningDeps>
|
||||||
|
where
|
||||||
|
P: FnMut() -> PF,
|
||||||
|
PF: std::future::Future<Output = Result<DepProbe>>,
|
||||||
|
L: FnMut(String) -> LF,
|
||||||
|
LF: std::future::Future<Output = ()>,
|
||||||
|
{
|
||||||
|
let mut waited_attempts = 0u32;
|
||||||
|
loop {
|
||||||
|
let DepProbe { running, existing } = probe().await?;
|
||||||
|
let missing = missing_install_deps(package_id, &running);
|
||||||
|
if missing.is_empty() {
|
||||||
|
// Keep behavior in lockstep with the canonical gate (covers any
|
||||||
|
// future arm added there but not mirrored in missing_install_deps).
|
||||||
|
check_install_deps(package_id, &running)?;
|
||||||
|
return Ok(running);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fail fast if any missing dependency has no installed container
|
||||||
|
// under any name variant — waiting cannot satisfy it.
|
||||||
|
let some_dep_not_installed = missing
|
||||||
|
.iter()
|
||||||
|
.any(|dep| !dep.containers.iter().any(|c| existing.iter().any(|e| e == c)));
|
||||||
|
if some_dep_not_installed {
|
||||||
|
let msg = match check_install_deps(package_id, &running) {
|
||||||
|
Err(e) => e.to_string(),
|
||||||
|
Ok(()) => format!("{package_id} dependencies are not running"),
|
||||||
|
};
|
||||||
|
return Err(anyhow::Error::new(DependencyGateError(msg)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if waited_attempts >= max_attempts {
|
||||||
|
let labels = join_dep_labels(&missing);
|
||||||
|
return Err(anyhow::Error::new(DependencyGateError(format!(
|
||||||
|
"{labels} is installed but did not reach the running state within \
|
||||||
|
{} seconds. Start {labels}, then install {package_id} again.",
|
||||||
|
u64::from(max_attempts) * interval.as_secs()
|
||||||
|
))));
|
||||||
|
}
|
||||||
|
waited_attempts += 1;
|
||||||
|
|
||||||
|
let labels = join_dep_labels(&missing);
|
||||||
|
if waited_attempts == 1 {
|
||||||
|
info!(
|
||||||
|
"Install {package_id}: dependency {labels} installed but not running yet — \
|
||||||
|
waiting up to {}s for it to start",
|
||||||
|
u64::from(max_attempts) * interval.as_secs()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
on_waiting(format!("Waiting for {labels} to start…")).await;
|
||||||
|
tokio::time::sleep(interval).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// ElectrumX and Mempool's Electrum backend need historical blocks from an
|
/// ElectrumX and Mempool's Electrum backend need historical blocks from an
|
||||||
/// unpruned node while building their indexes. A pruned Bitcoin node can be
|
/// unpruned node while building their indexes. A pruned Bitcoin node can be
|
||||||
/// running and RPC-reachable but still leave them stuck with closed ports.
|
/// running and RPC-reachable but still leave them stuck with closed ports.
|
||||||
@ -625,6 +810,218 @@ mod tests {
|
|||||||
assert!(!manifest_declares_archival_bitcoin("does-not-exist"));
|
assert!(!manifest_declares_archival_bitcoin("does-not-exist"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mod dep_wait {
|
||||||
|
use super::super::{wait_for_install_deps, DepProbe, DependencyGateError, RunningDeps};
|
||||||
|
use std::sync::atomic::{AtomicU32, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
fn deps(has_bitcoin: bool, has_electrumx: bool) -> RunningDeps {
|
||||||
|
RunningDeps {
|
||||||
|
has_bitcoin,
|
||||||
|
has_electrumx,
|
||||||
|
has_lnd: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn probe(has_bitcoin: bool, has_electrumx: bool, existing: &[&str]) -> DepProbe {
|
||||||
|
DepProbe {
|
||||||
|
running: deps(has_bitcoin, has_electrumx),
|
||||||
|
existing: existing.iter().map(|s| s.to_string()).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collects "Waiting for X to start…" labels emitted during the wait.
|
||||||
|
fn label_sink() -> (Arc<Mutex<Vec<String>>>, impl FnMut(String) -> std::future::Ready<()>)
|
||||||
|
{
|
||||||
|
let labels = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let sink = {
|
||||||
|
let labels = Arc::clone(&labels);
|
||||||
|
move |msg: String| {
|
||||||
|
labels.lock().unwrap().push(msg);
|
||||||
|
std::future::ready(())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
(labels, sink)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn passes_immediately_when_dependency_is_running() {
|
||||||
|
let (labels, sink) = label_sink();
|
||||||
|
let result = wait_for_install_deps(
|
||||||
|
"lnd",
|
||||||
|
|| async { Ok(probe(true, false, &["bitcoin-knots"])) },
|
||||||
|
sink,
|
||||||
|
3,
|
||||||
|
Duration::ZERO,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(result.is_ok());
|
||||||
|
assert!(labels.lock().unwrap().is_empty(), "no waiting expected");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn fails_fast_when_dependency_not_installed_at_all() {
|
||||||
|
let calls = AtomicU32::new(0);
|
||||||
|
let (labels, sink) = label_sink();
|
||||||
|
let err = wait_for_install_deps(
|
||||||
|
"lnd",
|
||||||
|
|| {
|
||||||
|
calls.fetch_add(1, Ordering::SeqCst);
|
||||||
|
async { Ok(probe(false, false, &["uptime-kuma"])) }
|
||||||
|
},
|
||||||
|
sink,
|
||||||
|
36,
|
||||||
|
Duration::ZERO,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
// Single probe — no polling when waiting cannot help.
|
||||||
|
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||||
|
assert!(labels.lock().unwrap().is_empty());
|
||||||
|
// Canonical check_install_deps message, wrapped in the gate marker
|
||||||
|
// so async_lifecycle removes the optimistic Installing entry.
|
||||||
|
assert!(err.downcast_ref::<DependencyGateError>().is_some());
|
||||||
|
assert!(
|
||||||
|
err.to_string().contains("LND requires a running Bitcoin node"),
|
||||||
|
"unexpected message: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn waits_while_installed_dependency_starts_then_passes() {
|
||||||
|
// Bitcoin container exists (installing/starting) but only reports
|
||||||
|
// Running from the 3rd probe onward — the log-confirmed LND race.
|
||||||
|
let calls = Arc::new(AtomicU32::new(0));
|
||||||
|
let (labels, sink) = label_sink();
|
||||||
|
let probe_calls = Arc::clone(&calls);
|
||||||
|
let result = wait_for_install_deps(
|
||||||
|
"lnd",
|
||||||
|
move || {
|
||||||
|
let n = probe_calls.fetch_add(1, Ordering::SeqCst);
|
||||||
|
async move { Ok(probe(n >= 2, false, &["bitcoin-knots"])) }
|
||||||
|
},
|
||||||
|
sink,
|
||||||
|
36,
|
||||||
|
Duration::ZERO,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(result.is_ok(), "{result:?}");
|
||||||
|
assert_eq!(calls.load(Ordering::SeqCst), 3);
|
||||||
|
let labels = labels.lock().unwrap();
|
||||||
|
assert_eq!(labels.len(), 2, "one waiting label per polling attempt");
|
||||||
|
assert!(labels.iter().all(|l| l == "Waiting for Bitcoin to start…"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn times_out_when_installed_dependency_never_runs() {
|
||||||
|
let (labels, sink) = label_sink();
|
||||||
|
let err = wait_for_install_deps(
|
||||||
|
"lnd",
|
||||||
|
|| async { Ok(probe(false, false, &["bitcoin-knots"])) },
|
||||||
|
sink,
|
||||||
|
4,
|
||||||
|
Duration::ZERO,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(err.downcast_ref::<DependencyGateError>().is_some());
|
||||||
|
assert!(
|
||||||
|
err.to_string()
|
||||||
|
.contains("did not reach the running state within 0 seconds"),
|
||||||
|
"unexpected message: {err}"
|
||||||
|
);
|
||||||
|
assert_eq!(labels.lock().unwrap().len(), 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn mempool_waits_on_both_bitcoin_and_electrumx() {
|
||||||
|
let calls = Arc::new(AtomicU32::new(0));
|
||||||
|
let (labels, sink) = label_sink();
|
||||||
|
let probe_calls = Arc::clone(&calls);
|
||||||
|
let result = wait_for_install_deps(
|
||||||
|
"mempool",
|
||||||
|
move || {
|
||||||
|
let n = probe_calls.fetch_add(1, Ordering::SeqCst);
|
||||||
|
// Bitcoin comes up on probe 2, electrumx on probe 3.
|
||||||
|
async move { Ok(probe(n >= 1, n >= 2, &["bitcoin-knots", "electrumx"])) }
|
||||||
|
},
|
||||||
|
sink,
|
||||||
|
36,
|
||||||
|
Duration::ZERO,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(result.is_ok(), "{result:?}");
|
||||||
|
let labels = labels.lock().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
labels.as_slice(),
|
||||||
|
&[
|
||||||
|
"Waiting for Bitcoin and ElectrumX to start…".to_string(),
|
||||||
|
"Waiting for ElectrumX to start…".to_string(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn mempool_fails_fast_when_one_dep_is_not_installed() {
|
||||||
|
// Bitcoin is installed (waiting could help) but ElectrumX is not
|
||||||
|
// installed at all — waiting can never satisfy the gate, so fail
|
||||||
|
// fast with the canonical message.
|
||||||
|
let (labels, sink) = label_sink();
|
||||||
|
let err = wait_for_install_deps(
|
||||||
|
"mempool",
|
||||||
|
|| async { Ok(probe(false, false, &["bitcoin-knots"])) },
|
||||||
|
sink,
|
||||||
|
36,
|
||||||
|
Duration::ZERO,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(err.downcast_ref::<DependencyGateError>().is_some());
|
||||||
|
assert!(labels.lock().unwrap().is_empty());
|
||||||
|
assert!(
|
||||||
|
err.to_string().contains("Mempool requires"),
|
||||||
|
"unexpected message: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn variant_container_names_count_as_installed() {
|
||||||
|
// bitcoin-core (not just bitcoin-knots) satisfies the "installed"
|
||||||
|
// check for the wait path.
|
||||||
|
let calls = Arc::new(AtomicU32::new(0));
|
||||||
|
let (_labels, sink) = label_sink();
|
||||||
|
let probe_calls = Arc::clone(&calls);
|
||||||
|
let result = wait_for_install_deps(
|
||||||
|
"electrumx",
|
||||||
|
move || {
|
||||||
|
let n = probe_calls.fetch_add(1, Ordering::SeqCst);
|
||||||
|
async move { Ok(probe(n >= 1, false, &["bitcoin-core"])) }
|
||||||
|
},
|
||||||
|
sink,
|
||||||
|
36,
|
||||||
|
Duration::ZERO,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(result.is_ok(), "{result:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn apps_without_dependency_gate_pass_untouched() {
|
||||||
|
let (labels, sink) = label_sink();
|
||||||
|
let result = wait_for_install_deps(
|
||||||
|
"uptime-kuma",
|
||||||
|
|| async { Ok(probe(false, false, &[])) },
|
||||||
|
sink,
|
||||||
|
36,
|
||||||
|
Duration::ZERO,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(result.is_ok());
|
||||||
|
assert!(labels.lock().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mempool_api_is_directly_installable_and_covered_by_the_archival_gate() {
|
fn mempool_api_is_directly_installable_and_covered_by_the_archival_gate() {
|
||||||
// `mempool-api` is a legitimate direct `package.install` target
|
// `mempool-api` is a legitimate direct `package.install` target
|
||||||
|
|||||||
@ -3,9 +3,10 @@ use super::config::{
|
|||||||
is_readonly_compatible, is_valid_docker_image,
|
is_readonly_compatible, is_valid_docker_image,
|
||||||
};
|
};
|
||||||
use super::dependencies::{
|
use super::dependencies::{
|
||||||
check_bitcoin_pruning_compatibility, check_install_deps, configure_fedimint_lnd,
|
check_bitcoin_pruning_compatibility, configure_fedimint_lnd, detect_existing_containers,
|
||||||
detect_running_deps, detect_running_deps_from_package_data, log_optional_dep_info,
|
detect_running_deps, detect_running_deps_from_package_data, log_optional_dep_info,
|
||||||
needs_archy_net, RunningDeps,
|
needs_archy_net, wait_for_install_deps, DepProbe, RunningDeps, DEP_WAIT_INTERVAL,
|
||||||
|
DEP_WAIT_MAX_ATTEMPTS,
|
||||||
};
|
};
|
||||||
use super::progress::parse_pull_progress;
|
use super::progress::parse_pull_progress;
|
||||||
use super::validation::validate_app_id;
|
use super::validation::validate_app_id;
|
||||||
@ -265,8 +266,7 @@ impl RpcHandler {
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
if matches!(package_id, "mempool" | "mempool-web") {
|
if matches!(package_id, "mempool" | "mempool-web") {
|
||||||
let deps = self.running_deps_for_install(package_id).await?;
|
self.gate_install_deps(package_id).await?;
|
||||||
check_install_deps(package_id, &deps)?;
|
|
||||||
check_bitcoin_pruning_compatibility(package_id).await?;
|
check_bitcoin_pruning_compatibility(package_id).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -289,9 +289,11 @@ impl RpcHandler {
|
|||||||
// Dependency checks. Prefer the scanner's cached package state so a
|
// Dependency checks. Prefer the scanner's cached package state so a
|
||||||
// congested Podman API does not turn an already-running dependency into
|
// congested Podman API does not turn an already-running dependency into
|
||||||
// a false install failure. Fall back to a bounded direct Podman probe
|
// a false install failure. Fall back to a bounded direct Podman probe
|
||||||
// only when the cache does not show the dependency.
|
// only when the cache does not show the dependency. When the dependency
|
||||||
let deps = self.running_deps_for_install(package_id).await?;
|
// is installed but not Running yet (the "clicked Install LND 55s before
|
||||||
check_install_deps(package_id, &deps)?;
|
// Bitcoin was up" race), wait up to ~3 minutes for it instead of
|
||||||
|
// failing instantly.
|
||||||
|
let deps = self.gate_install_deps(package_id).await?;
|
||||||
check_bitcoin_pruning_compatibility(package_id).await?;
|
check_bitcoin_pruning_compatibility(package_id).await?;
|
||||||
log_optional_dep_info(package_id, &deps);
|
log_optional_dep_info(package_id, &deps);
|
||||||
let repaired_bitcoin_conf =
|
let repaired_bitcoin_conf =
|
||||||
@ -945,6 +947,27 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bounded dependency gate for installs: passes immediately when deps are
|
||||||
|
/// running, fails fast (with the phantom-tile marker) when a dependency
|
||||||
|
/// isn't installed at all, and otherwise waits up to
|
||||||
|
/// `DEP_WAIT_MAX_ATTEMPTS × DEP_WAIT_INTERVAL` for an installed-but-
|
||||||
|
/// starting dependency, surfacing "Waiting for X to start…" on the card.
|
||||||
|
pub(super) async fn gate_install_deps(&self, package_id: &str) -> Result<RunningDeps> {
|
||||||
|
wait_for_install_deps(
|
||||||
|
package_id,
|
||||||
|
|| async {
|
||||||
|
Ok(DepProbe {
|
||||||
|
running: self.running_deps_for_install(package_id).await?,
|
||||||
|
existing: detect_existing_containers().await,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|msg| async move { self.set_install_message(package_id, &msg).await },
|
||||||
|
DEP_WAIT_MAX_ATTEMPTS,
|
||||||
|
DEP_WAIT_INTERVAL,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
// -- Private helpers for install --
|
// -- Private helpers for install --
|
||||||
|
|
||||||
/// Pull the image from a registry or verify a local image exists.
|
/// Pull the image from a registry or verify a local image exists.
|
||||||
@ -1295,6 +1318,11 @@ impl RpcHandler {
|
|||||||
// Default to full archive — operators with 2TB+ drives shouldn't be
|
// Default to full archive — operators with 2TB+ drives shouldn't be
|
||||||
// silently pruned down to 550 MB. Users who want a pruned node can
|
// silently pruned down to 550 MB. Users who want a pruned node can
|
||||||
// set `prune=N` in bitcoin.conf themselves after install.
|
// set `prune=N` in bitcoin.conf themselves after install.
|
||||||
|
//
|
||||||
|
// printtoconsole=0: bitcoind already writes debug.log in the datadir
|
||||||
|
// (self-shrunk on restart); duplicating it to stdout pushed every IBD
|
||||||
|
// "UpdateTip" line through conmon into journald (>1 GB/day). Deep
|
||||||
|
// debugging uses /var/lib/archipelago/bitcoin/debug.log.
|
||||||
let bitcoin_conf = format!(
|
let bitcoin_conf = format!(
|
||||||
"\
|
"\
|
||||||
# rpcauth: salted hash only - no plaintext password in config or CLI\n\
|
# rpcauth: salted hash only - no plaintext password in config or CLI\n\
|
||||||
@ -1304,7 +1332,7 @@ rpcallowip=0.0.0.0/0\n\
|
|||||||
listen=1\n\
|
listen=1\n\
|
||||||
rpcthreads=16\n\
|
rpcthreads=16\n\
|
||||||
rpcworkqueue=256\n\
|
rpcworkqueue=256\n\
|
||||||
printtoconsole=1\n",
|
printtoconsole=0\n",
|
||||||
rpcauth_line
|
rpcauth_line
|
||||||
);
|
);
|
||||||
tokio::fs::create_dir_all(bitcoin_dir)
|
tokio::fs::create_dir_all(bitcoin_dir)
|
||||||
|
|||||||
@ -61,6 +61,31 @@ impl RpcHandler {
|
|||||||
self.state_manager.update_data(data).await;
|
self.state_manager.update_data(data).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set a user-facing install status message (e.g. "Waiting for Bitcoin
|
||||||
|
/// to start…") without disturbing the current phase/byte counters.
|
||||||
|
pub(super) async fn set_install_message(&self, package_id: &str, message: &str) {
|
||||||
|
let (mut data, _rev) = self.state_manager.get_snapshot().await;
|
||||||
|
let entry = data
|
||||||
|
.package_data
|
||||||
|
.entry(package_id.to_string())
|
||||||
|
.or_insert_with(|| create_installing_entry(package_id));
|
||||||
|
if entry.state != PackageState::Updating {
|
||||||
|
entry.state = PackageState::Installing;
|
||||||
|
}
|
||||||
|
let (size, downloaded, phase) = entry
|
||||||
|
.install_progress
|
||||||
|
.as_ref()
|
||||||
|
.map(|p| (p.size, p.downloaded, p.phase))
|
||||||
|
.unwrap_or((0, 0, None));
|
||||||
|
entry.install_progress = Some(InstallProgress {
|
||||||
|
size,
|
||||||
|
downloaded,
|
||||||
|
phase,
|
||||||
|
message: Some(message.to_string()),
|
||||||
|
});
|
||||||
|
self.state_manager.update_data(data).await;
|
||||||
|
}
|
||||||
|
|
||||||
/// Clear install progress after pull completes or fails.
|
/// Clear install progress after pull completes or fails.
|
||||||
pub(super) async fn clear_install_progress(&self, package_id: &str) {
|
pub(super) async fn clear_install_progress(&self, package_id: &str) {
|
||||||
let (mut data, _rev) = self.state_manager.get_snapshot().await;
|
let (mut data, _rev) = self.state_manager.get_snapshot().await;
|
||||||
|
|||||||
@ -1009,9 +1009,9 @@ impl RpcHandler {
|
|||||||
return Ok(adopted);
|
return Ok(adopted);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dependency check: Bitcoin must be running
|
// Dependency check: Bitcoin must be running. Bounded wait covers the
|
||||||
let deps = super::dependencies::detect_running_deps().await?;
|
// "installed but still starting" race instead of failing instantly.
|
||||||
super::dependencies::check_install_deps("btcpay-server", &deps)?;
|
self.gate_install_deps("btcpay-server").await?;
|
||||||
|
|
||||||
install_log("INSTALL START: btcpay-server (stack: postgres + nbxplorer + btcpay)").await;
|
install_log("INSTALL START: btcpay-server (stack: postgres + nbxplorer + btcpay)").await;
|
||||||
|
|
||||||
|
|||||||
@ -26,6 +26,36 @@ impl Drop for OnboardingMnemonicState {
|
|||||||
|
|
||||||
const MNEMONIC_TTL: std::time::Duration = std::time::Duration::from_secs(600); // 10 minutes
|
const MNEMONIC_TTL: std::time::Duration = std::time::Duration::from_secs(600); // 10 minutes
|
||||||
|
|
||||||
|
/// Persist the pending onboarding mnemonic as `identity/master_seed.enc`,
|
||||||
|
/// encrypted with `passphrase`. Called from `auth.setup` — the first moment a
|
||||||
|
/// user password exists — so "Reveal recovery phrase" works after onboarding
|
||||||
|
/// without the frontend having to remember a separate save step (it never
|
||||||
|
/// did, which left every onboarded node with no encrypted seed backup).
|
||||||
|
///
|
||||||
|
/// Deliberately ignores MNEMONIC_TTL: the mnemonic stays in memory until
|
||||||
|
/// overwritten regardless, so using it here widens nothing, and onboarding
|
||||||
|
/// legitimately takes longer than 10 minutes when the user carefully writes
|
||||||
|
/// down 24 words. Clears the in-memory copy on success — password setup is
|
||||||
|
/// the end of onboarding, so the plaintext no longer needs to linger.
|
||||||
|
///
|
||||||
|
/// Returns Ok(true) if a seed was saved, Ok(false) if none was pending.
|
||||||
|
pub(in crate::api::rpc) async fn save_pending_seed_encrypted(
|
||||||
|
data_dir: &std::path::Path,
|
||||||
|
passphrase: &str,
|
||||||
|
) -> Result<bool> {
|
||||||
|
let mut state = ONBOARDING_MNEMONIC.lock().await;
|
||||||
|
let Some(pending) = state.as_ref() else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
let mnemonic: bip39::Mnemonic = pending
|
||||||
|
.words
|
||||||
|
.parse()
|
||||||
|
.context("Invalid mnemonic in memory")?;
|
||||||
|
crate::seed::save_seed_encrypted(data_dir, &mnemonic, passphrase).await?;
|
||||||
|
*state = None;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
/// Best-effort: install fips.yaml + start archipelago-fips.service after the
|
/// Best-effort: install fips.yaml + start archipelago-fips.service after the
|
||||||
/// seed onboarding has written the fips_key to disk. Runs in a detached task
|
/// seed onboarding has written the fips_key to disk. Runs in a detached task
|
||||||
/// so the user-facing RPC returns immediately — the systemctl calls can take
|
/// so the user-facing RPC returns immediately — the systemctl calls can take
|
||||||
@ -208,6 +238,17 @@ impl RpcHandler {
|
|||||||
let phrase = words.join(" ");
|
let phrase = words.join(" ");
|
||||||
let (_mnemonic, seed) = crate::seed::MasterSeed::from_mnemonic_words(&phrase)?;
|
let (_mnemonic, seed) = crate::seed::MasterSeed::from_mnemonic_words(&phrase)?;
|
||||||
|
|
||||||
|
// Stash the restored words like seed.generate does, so auth.setup can
|
||||||
|
// persist the encrypted backup once the user's password exists and
|
||||||
|
// "Reveal recovery phrase" works on restored nodes too.
|
||||||
|
{
|
||||||
|
let mut state = ONBOARDING_MNEMONIC.lock().await;
|
||||||
|
*state = Some(OnboardingMnemonicState {
|
||||||
|
words: phrase.clone(),
|
||||||
|
created_at: std::time::Instant::now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Derive and write node Ed25519 key.
|
// Derive and write node Ed25519 key.
|
||||||
let identity_dir = self.config.data_dir.join("identity");
|
let identity_dir = self.config.data_dir.join("identity");
|
||||||
crate::identity::NodeIdentity::from_seed(&identity_dir, &seed).await?;
|
crate::identity::NodeIdentity::from_seed(&identity_dir, &seed).await?;
|
||||||
|
|||||||
@ -101,19 +101,45 @@ fn friendly_transient_error(has_cached_state: bool, err_msg: &str) -> String {
|
|||||||
.trim_end_matches('.');
|
.trim_end_matches('.');
|
||||||
let lower = detail.to_lowercase();
|
let lower = detail.to_lowercase();
|
||||||
let state = if lower.contains("verifying blocks") {
|
let state = if lower.contains("verifying blocks") {
|
||||||
"verifying blocks after restart"
|
Some("verifying blocks after restart")
|
||||||
|
} else if lower.contains("connection reset") {
|
||||||
|
Some("starting up and not yet accepting RPC connections")
|
||||||
} else if lower.contains("connection refused") || lower.contains("tcp connect error") {
|
} else if lower.contains("connection refused") || lower.contains("tcp connect error") {
|
||||||
"waiting for the Bitcoin RPC listener"
|
Some("waiting for the Bitcoin RPC listener")
|
||||||
} else if lower.contains("timed out") || lower.contains("timeout") {
|
} else if lower.contains("timed out") || lower.contains("timeout") {
|
||||||
"busy and not answering RPC before the timeout"
|
Some("busy and not answering RPC before the timeout")
|
||||||
} else {
|
} else {
|
||||||
"starting or busy syncing"
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
if has_cached_state {
|
// Recognized transient causes get a clean human sentence only — the raw
|
||||||
format!("Bitcoin node is {state}; showing last known state and retrying. Detail: {detail}")
|
// transport error (URLs, repeated "os error 104" chains) is operator
|
||||||
|
// noise that was ending up verbatim on the app card. Unrecognized errors
|
||||||
|
// keep a bounded detail so a genuinely new failure stays diagnosable.
|
||||||
|
let (state, detail) = match state {
|
||||||
|
Some(state) => (state, None),
|
||||||
|
None => (
|
||||||
|
"starting or busy syncing",
|
||||||
|
Some(if detail.len() > 120 {
|
||||||
|
let mut cut = 120;
|
||||||
|
while !detail.is_char_boundary(cut) {
|
||||||
|
cut -= 1;
|
||||||
|
}
|
||||||
|
format!("{}…", &detail[..cut])
|
||||||
|
} else {
|
||||||
|
detail.to_string()
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
let base = if has_cached_state {
|
||||||
|
format!("Bitcoin node is {state}; showing last known state and retrying.")
|
||||||
} else {
|
} else {
|
||||||
format!("Bitcoin node is {state}; retrying automatically. Detail: {detail}")
|
format!("Bitcoin node is {state}; retrying automatically.")
|
||||||
|
};
|
||||||
|
match detail {
|
||||||
|
Some(detail) => format!("{base} Detail: {detail}"),
|
||||||
|
None => base,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -278,4 +304,39 @@ mod tests {
|
|||||||
|
|
||||||
assert!(msg.contains("busy and not answering RPC before the timeout"));
|
assert!(msg.contains("busy and not answering RPC before the timeout"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn connection_reset_gets_clean_message_without_raw_detail() {
|
||||||
|
// The exact string a fresh install showed on the app card: the raw
|
||||||
|
// reqwest chain (URL + repeated "os error 104") must not surface.
|
||||||
|
let msg = friendly_transient_error(
|
||||||
|
false,
|
||||||
|
"getblockchaininfo: Bitcoin RPC request failed: error sending request for url (http://127.0.0.1:8332/): connection error: Connection reset by peer (os error 104): connection error: Connection reset by peer (os error 104): Connection reset by peer (os error 104)",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(msg.contains("starting up and not yet accepting RPC connections"));
|
||||||
|
assert!(!msg.contains("os error"));
|
||||||
|
assert!(!msg.contains("127.0.0.1"));
|
||||||
|
assert!(!msg.contains("Detail:"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recognized_causes_omit_detail_entirely() {
|
||||||
|
for raw in [
|
||||||
|
"x: Connection refused (os error 111)",
|
||||||
|
"x: operation timed out",
|
||||||
|
r#"x: {"error":{"code":-28,"message":"Verifying blocks..."}}"#,
|
||||||
|
] {
|
||||||
|
let msg = friendly_transient_error(false, raw);
|
||||||
|
assert!(!msg.contains("Detail:"), "leaked detail for: {raw}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_errors_keep_bounded_detail() {
|
||||||
|
let long = format!("weird new failure {}", "x".repeat(300));
|
||||||
|
let msg = friendly_transient_error(false, &long);
|
||||||
|
assert!(msg.contains("Detail: weird new failure"));
|
||||||
|
assert!(msg.len() < 260);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -39,6 +39,16 @@ const KIOSK_LAUNCHER: &str =
|
|||||||
const KIOSK_SERVICE_PATH: &str = "/etc/systemd/system/archipelago-kiosk.service";
|
const KIOSK_SERVICE_PATH: &str = "/etc/systemd/system/archipelago-kiosk.service";
|
||||||
const KIOSK_LAUNCHER_PATH: &str = "/usr/local/bin/archipelago-kiosk-launcher";
|
const KIOSK_LAUNCHER_PATH: &str = "/usr/local/bin/archipelago-kiosk-launcher";
|
||||||
|
|
||||||
|
// Journald log-volume policy (size cap + per-service rate limit). Fresh ISOs
|
||||||
|
// write the identical file at build time (image-recipe/_archived/
|
||||||
|
// build-auto-installer-iso.sh); this heals already-deployed nodes via OTA.
|
||||||
|
// A fresh node produced >1 GB/day of journal (bitcoind IBD console spam plus
|
||||||
|
// debug-level backend logging) — the cap bounds disk use and the rate limit
|
||||||
|
// keeps one chatty service from drowning everything else.
|
||||||
|
const JOURNALD_DROPIN: &str =
|
||||||
|
include_str!("../../../image-recipe/configs/journald-archipelago.conf");
|
||||||
|
const JOURNALD_DROPIN_PATH: &str = "/etc/systemd/journald.conf.d/10-archipelago-persistent.conf";
|
||||||
|
|
||||||
const NGINX_CONF_PATH: &str = "/etc/nginx/sites-available/archipelago";
|
const NGINX_CONF_PATH: &str = "/etc/nginx/sites-available/archipelago";
|
||||||
const NGINX_ENABLED_CONF_PATH: &str = "/etc/nginx/sites-enabled/archipelago";
|
const NGINX_ENABLED_CONF_PATH: &str = "/etc/nginx/sites-enabled/archipelago";
|
||||||
/// Per-app proxy snippet included by the HTTPS (:443) server block. Carries its
|
/// Per-app proxy snippet included by the HTTPS (:443) server block. Carries its
|
||||||
@ -120,6 +130,11 @@ pub async fn ensure_doctor_installed() {
|
|||||||
Ok(false) => debug!("Bitcoin RPC bind settings already usable"),
|
Ok(false) => debug!("Bitcoin RPC bind settings already usable"),
|
||||||
Err(e) => warn!("Bitcoin RPC repair failed (non-fatal): {:#}", e),
|
Err(e) => warn!("Bitcoin RPC repair failed (non-fatal): {:#}", e),
|
||||||
}
|
}
|
||||||
|
match run_journald_dropin().await {
|
||||||
|
Ok(true) => info!("Installed journald log-volume policy drop-in"),
|
||||||
|
Ok(false) => debug!("journald log-volume policy already in place"),
|
||||||
|
Err(e) => warn!("journald drop-in bootstrap failed (non-fatal): {:#}", e),
|
||||||
|
}
|
||||||
match tighten_secrets_dir().await {
|
match tighten_secrets_dir().await {
|
||||||
Ok(n) if n > 0 => info!(tightened = n, "Tightened mode on secret files"),
|
Ok(n) if n > 0 => info!(tightened = n, "Tightened mode on secret files"),
|
||||||
Ok(_) => debug!("Secrets directory already at expected mode"),
|
Ok(_) => debug!("Secrets directory already at expected mode"),
|
||||||
@ -408,6 +423,14 @@ ensure_line() {
|
|||||||
ensure_line server=1
|
ensure_line server=1
|
||||||
ensure_line rpcallowip=0.0.0.0/0
|
ensure_line rpcallowip=0.0.0.0/0
|
||||||
ensure_line listen=1
|
ensure_line listen=1
|
||||||
|
# Log-volume fix: printtoconsole=1 duplicated every log line (incl. per-block
|
||||||
|
# IBD "UpdateTip" spam) into journald via conmon on top of the datadir
|
||||||
|
# debug.log bitcoind already writes. Console off; debug.log stays (bitcoind
|
||||||
|
# self-shrinks it on restart).
|
||||||
|
if grep -q '^printtoconsole=1' "$conf"; then
|
||||||
|
sed -i 's/^printtoconsole=1$/printtoconsole=0/' "$conf"
|
||||||
|
changed=1
|
||||||
|
fi
|
||||||
[ "$changed" -eq 0 ] && exit 0
|
[ "$changed" -eq 0 ] && exit 0
|
||||||
exit 2
|
exit 2
|
||||||
"#;
|
"#;
|
||||||
@ -428,6 +451,44 @@ exit 2
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Install the journald log-volume policy drop-in (JOURNALD_DROPIN) so nodes
|
||||||
|
/// deployed before the ISO shipped it get the size cap + rate limit via OTA.
|
||||||
|
/// Idempotent; restarts journald only when the file actually changed (safe:
|
||||||
|
/// the sockets are held by pid1, so at most a few messages queue briefly).
|
||||||
|
async fn run_journald_dropin() -> Result<bool> {
|
||||||
|
// Same dev-box guards as the doctor bootstrap: never touch /etc on
|
||||||
|
// contributors' laptops (symlinked or absent /home/archipelago/archy).
|
||||||
|
let home_archy = Path::new("/home/archipelago/archy");
|
||||||
|
if fs::symlink_metadata(home_archy)
|
||||||
|
.await
|
||||||
|
.map(|m| m.file_type().is_symlink())
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
debug!("/home/archipelago/archy is a symlink — skipping journald bootstrap (dev box)");
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
if fs::metadata(home_archy).await.is_err() {
|
||||||
|
debug!("/home/archipelago/archy missing — skipping journald bootstrap");
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
let dropin_dir = "/etc/systemd/journald.conf.d";
|
||||||
|
let status = host_sudo(&["mkdir", "-p", dropin_dir])
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("mkdir {}", dropin_dir))?;
|
||||||
|
if !status.success() {
|
||||||
|
anyhow::bail!("mkdir {} exited with {}", dropin_dir, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
let changed = write_root_if_needed(JOURNALD_DROPIN_PATH, JOURNALD_DROPIN).await?;
|
||||||
|
if changed {
|
||||||
|
if let Err(e) = host_sudo(&["systemctl", "restart", "systemd-journald"]).await {
|
||||||
|
warn!("journald restart after drop-in update failed: {:#}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(changed)
|
||||||
|
}
|
||||||
|
|
||||||
async fn run() -> Result<bool> {
|
async fn run() -> Result<bool> {
|
||||||
// Dev-box guard: on contributors' laptops `/home/archipelago/archy` is
|
// Dev-box guard: on contributors' laptops `/home/archipelago/archy` is
|
||||||
// typically a symlink into the git checkout, and writing through it
|
// typically a symlink into the git checkout, and writing through it
|
||||||
|
|||||||
@ -43,7 +43,11 @@ pub enum EnsureOutcome {
|
|||||||
Unchanged,
|
Unchanged,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn ensure_config(paths: &EnsurePaths, rpc_pass: &str) -> Result<EnsureOutcome> {
|
pub async fn ensure_config(
|
||||||
|
paths: &EnsurePaths,
|
||||||
|
rpc_pass: &str,
|
||||||
|
bitcoin_host: &str,
|
||||||
|
) -> Result<EnsureOutcome> {
|
||||||
fs::create_dir_all(&paths.data_dir)
|
fs::create_dir_all(&paths.data_dir)
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("creating {}", paths.data_dir.display()))?;
|
.with_context(|| format!("creating {}", paths.data_dir.display()))?;
|
||||||
@ -52,7 +56,7 @@ pub async fn ensure_config(paths: &EnsurePaths, rpc_pass: &str) -> Result<Ensure
|
|||||||
let existing = fs::read_to_string(&paths.conf_path)
|
let existing = fs::read_to_string(&paths.conf_path)
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("reading {}", paths.conf_path.display()))?;
|
.with_context(|| format!("reading {}", paths.conf_path.display()))?;
|
||||||
if has_required_lnd_flags(&existing, rpc_pass) {
|
if has_required_lnd_flags(&existing, rpc_pass, bitcoin_host) {
|
||||||
return Ok(EnsureOutcome::Unchanged);
|
return Ok(EnsureOutcome::Unchanged);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -68,12 +72,11 @@ restlisten=0.0.0.0:8080\n\
|
|||||||
bitcoin.active=true\n\
|
bitcoin.active=true\n\
|
||||||
bitcoin.mainnet=true\n\
|
bitcoin.mainnet=true\n\
|
||||||
bitcoin.node=bitcoind\n\
|
bitcoin.node=bitcoind\n\
|
||||||
bitcoind.rpchost=bitcoin-knots:8332\n\
|
bitcoind.rpchost={bitcoin_host}:8332\n\
|
||||||
bitcoind.rpcuser=archipelago\n\
|
bitcoind.rpcuser=archipelago\n\
|
||||||
bitcoind.rpcpass={}\n\
|
bitcoind.rpcpass={rpc_pass}\n\
|
||||||
bitcoind.rpcpolling=true\n\
|
bitcoind.rpcpolling=true\n\
|
||||||
bitcoind.estimatemode=ECONOMICAL\n",
|
bitcoind.estimatemode=ECONOMICAL\n"
|
||||||
rpc_pass
|
|
||||||
);
|
);
|
||||||
|
|
||||||
write_config_atomically(paths, &conf).await?;
|
write_config_atomically(paths, &conf).await?;
|
||||||
@ -653,13 +656,14 @@ fn shell_quote(s: &str) -> String {
|
|||||||
s.replace('\'', "'\\''")
|
s.replace('\'', "'\\''")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn has_required_lnd_flags(conf: &str, rpc_pass: &str) -> bool {
|
fn has_required_lnd_flags(conf: &str, rpc_pass: &str, bitcoin_host: &str) -> bool {
|
||||||
let rpc_pass_line = format!("bitcoind.rpcpass={rpc_pass}");
|
let rpc_pass_line = format!("bitcoind.rpcpass={rpc_pass}");
|
||||||
|
let rpc_host_line = format!("bitcoind.rpchost={bitcoin_host}:8332");
|
||||||
[
|
[
|
||||||
"bitcoin.active=true",
|
"bitcoin.active=true",
|
||||||
"bitcoin.mainnet=true",
|
"bitcoin.mainnet=true",
|
||||||
"bitcoin.node=bitcoind",
|
"bitcoin.node=bitcoind",
|
||||||
"bitcoind.rpchost=bitcoin-knots:8332",
|
rpc_host_line.as_str(),
|
||||||
rpc_pass_line.as_str(),
|
rpc_pass_line.as_str(),
|
||||||
]
|
]
|
||||||
.iter()
|
.iter()
|
||||||
@ -678,7 +682,7 @@ mod tests {
|
|||||||
conf_path: tmp.path().join("lnd/lnd.conf"),
|
conf_path: tmp.path().join("lnd/lnd.conf"),
|
||||||
};
|
};
|
||||||
|
|
||||||
let out = ensure_config(&paths, "secret").await.unwrap();
|
let out = ensure_config(&paths, "secret", "bitcoin-knots").await.unwrap();
|
||||||
assert_eq!(out, EnsureOutcome::Written);
|
assert_eq!(out, EnsureOutcome::Written);
|
||||||
let conf = fs::read_to_string(&paths.conf_path).await.unwrap();
|
let conf = fs::read_to_string(&paths.conf_path).await.unwrap();
|
||||||
assert!(conf.contains("bitcoin.active=true"));
|
assert!(conf.contains("bitcoin.active=true"));
|
||||||
@ -697,17 +701,46 @@ mod tests {
|
|||||||
};
|
};
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
ensure_config(&paths, "first").await.unwrap(),
|
ensure_config(&paths, "first", "bitcoin-knots").await.unwrap(),
|
||||||
EnsureOutcome::Written
|
EnsureOutcome::Written
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
ensure_config(&paths, "second").await.unwrap(),
|
ensure_config(&paths, "second", "bitcoin-knots").await.unwrap(),
|
||||||
EnsureOutcome::Written
|
EnsureOutcome::Written
|
||||||
);
|
);
|
||||||
let conf = fs::read_to_string(&paths.conf_path).await.unwrap();
|
let conf = fs::read_to_string(&paths.conf_path).await.unwrap();
|
||||||
assert!(conf.contains("bitcoind.rpcpass=second"));
|
assert!(conf.contains("bitcoind.rpcpass=second"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ensure_config_repairs_bitcoin_host_drift() {
|
||||||
|
// A conf written against bitcoin-knots must be rewritten when the
|
||||||
|
// node's Bitcoin variant is bitcoin-core, or LND dials a hostname
|
||||||
|
// that doesn't exist on archy-net and dies on startup.
|
||||||
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
|
let paths = EnsurePaths {
|
||||||
|
data_dir: tmp.path().join("lnd"),
|
||||||
|
conf_path: tmp.path().join("lnd/lnd.conf"),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
ensure_config(&paths, "pw", "bitcoin-knots").await.unwrap(),
|
||||||
|
EnsureOutcome::Written
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ensure_config(&paths, "pw", "bitcoin-core").await.unwrap(),
|
||||||
|
EnsureOutcome::Written
|
||||||
|
);
|
||||||
|
let conf = fs::read_to_string(&paths.conf_path).await.unwrap();
|
||||||
|
assert!(conf.contains("bitcoind.rpchost=bitcoin-core:8332"));
|
||||||
|
assert!(!conf.contains("bitcoind.rpchost=bitcoin-knots:8332"));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
ensure_config(&paths, "pw", "bitcoin-core").await.unwrap(),
|
||||||
|
EnsureOutcome::Unchanged
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn ensure_config_repairs_incomplete_existing_config() {
|
async fn ensure_config_repairs_incomplete_existing_config() {
|
||||||
let tmp = tempfile::TempDir::new().unwrap();
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
@ -721,7 +754,7 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
ensure_config(&paths, "repaired").await.unwrap(),
|
ensure_config(&paths, "repaired", "bitcoin-knots").await.unwrap(),
|
||||||
EnsureOutcome::Written
|
EnsureOutcome::Written
|
||||||
);
|
);
|
||||||
let conf = fs::read_to_string(&paths.conf_path).await.unwrap();
|
let conf = fs::read_to_string(&paths.conf_path).await.unwrap();
|
||||||
|
|||||||
@ -1385,6 +1385,7 @@ impl ProdContainerOrchestrator {
|
|||||||
.list_containers()
|
.list_containers()
|
||||||
.await
|
.await
|
||||||
.context("list_containers during adoption")?;
|
.context("list_containers during adoption")?;
|
||||||
|
let user_stopped = crate::crash_recovery::load_user_stopped(&self.data_dir).await;
|
||||||
let state = self.state.read().await;
|
let state = self.state.read().await;
|
||||||
let mut report = AdoptionReport::default();
|
let mut report = AdoptionReport::default();
|
||||||
for (app_id, lm) in state.manifests.iter() {
|
for (app_id, lm) in state.manifests.iter() {
|
||||||
@ -1394,6 +1395,21 @@ impl ProdContainerOrchestrator {
|
|||||||
.any(|c| c.name == expected || c.name == format!("/{expected}"))
|
.any(|c| c.name == expected || c.name == format!("/{expected}"))
|
||||||
{
|
{
|
||||||
report.adopted.push(app_id.clone());
|
report.adopted.push(app_id.clone());
|
||||||
|
// Adopted apps will be (re)started by boot recovery, the first
|
||||||
|
// reconcile pass, or the doctor — whichever reaches them first
|
||||||
|
// can be minutes away. Register them as pending boot-starts now
|
||||||
|
// so the scanner shows "Restarting" (not "Stopped") from the
|
||||||
|
// very first post-boot scan. Cleared per-app by the first
|
||||||
|
// reconcile pass, so a genuinely failed start surfaces.
|
||||||
|
if !state.disabled.contains(app_id)
|
||||||
|
&& !user_stopped.contains(app_id)
|
||||||
|
&& !user_stopped.contains(&expected)
|
||||||
|
{
|
||||||
|
crate::crash_recovery::pending_boot_starts_add([
|
||||||
|
app_id.clone(),
|
||||||
|
expected.clone(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(report)
|
Ok(report)
|
||||||
@ -1442,8 +1458,19 @@ impl ProdContainerOrchestrator {
|
|||||||
};
|
};
|
||||||
let mut report = ReconcileReport::default();
|
let mut report = ReconcileReport::default();
|
||||||
let disk_gb = self.disk_gb();
|
let disk_gb = self.disk_gb();
|
||||||
|
// Register every candidate before the (sequential, possibly slow)
|
||||||
|
// pass so the scanner overlays queued-but-down apps as Restarting
|
||||||
|
// instead of Stopped. Each app is deregistered as its turn finishes,
|
||||||
|
// so a start that genuinely failed shows its real state again.
|
||||||
|
crate::crash_recovery::pending_boot_starts_add(manifests.iter().flat_map(|lm| {
|
||||||
|
[
|
||||||
|
lm.manifest.app.id.clone(),
|
||||||
|
compute_container_name(&lm.manifest),
|
||||||
|
]
|
||||||
|
}));
|
||||||
for lm in manifests {
|
for lm in manifests {
|
||||||
let app_id = lm.manifest.app.id.clone();
|
let app_id = lm.manifest.app.id.clone();
|
||||||
|
let container_name = compute_container_name(&lm.manifest);
|
||||||
if mode == ReconcileMode::ExistingOnly
|
if mode == ReconcileMode::ExistingOnly
|
||||||
&& requires_archival_bitcoin(&app_id)
|
&& requires_archival_bitcoin(&app_id)
|
||||||
&& disk_gb < ARCHIVAL_BITCOIN_DISK_GB
|
&& disk_gb < ARCHIVAL_BITCOIN_DISK_GB
|
||||||
@ -1452,6 +1479,8 @@ impl ProdContainerOrchestrator {
|
|||||||
&app_id,
|
&app_id,
|
||||||
ReconcileAction::Left("requires-archival-bitcoin".into()),
|
ReconcileAction::Left("requires-archival-bitcoin".into()),
|
||||||
);
|
);
|
||||||
|
crate::crash_recovery::pending_boot_start_done(&app_id);
|
||||||
|
crate::crash_recovery::pending_boot_start_done(&container_name);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
match self.ensure_running_with_mode(&lm, mode).await {
|
match self.ensure_running_with_mode(&lm, mode).await {
|
||||||
@ -2576,7 +2605,8 @@ impl ProdContainerOrchestrator {
|
|||||||
}
|
}
|
||||||
.read("bitcoin-rpc-password")
|
.read("bitcoin-rpc-password")
|
||||||
.context("lnd pre-start: read bitcoin RPC password")?;
|
.context("lnd pre-start: read bitcoin RPC password")?;
|
||||||
let outcome = lnd::ensure_config(&self.lnd_paths, &rpc_pass)
|
let bitcoin_host = self.bitcoin_host();
|
||||||
|
let outcome = lnd::ensure_config(&self.lnd_paths, &rpc_pass, &bitcoin_host)
|
||||||
.await
|
.await
|
||||||
.context("lnd pre-start: ensure lnd.conf")?;
|
.context("lnd pre-start: ensure lnd.conf")?;
|
||||||
Ok(Some(match outcome {
|
Ok(Some(match outcome {
|
||||||
@ -2588,6 +2618,30 @@ impl ProdContainerOrchestrator {
|
|||||||
self.ensure_btcpay_stack_dirs().await?;
|
self.ensure_btcpay_stack_dirs().await?;
|
||||||
Ok(Some(HookOutcome::Unchanged))
|
Ok(Some(HookOutcome::Unchanged))
|
||||||
}
|
}
|
||||||
|
"fedimint-clientd" => {
|
||||||
|
// First-boot (root context) created /var/lib/archipelago/fmcd
|
||||||
|
// as root:root, but the rootless container's uid 0 maps to
|
||||||
|
// host 1000 — fmcd then crash-loops with "Permission denied
|
||||||
|
// (os error 13)". Repair ownership on every start so nodes
|
||||||
|
// installed before the first-boot fix self-heal too. (The
|
||||||
|
// generic running-container ownership sweep can't catch this:
|
||||||
|
// fmcd exits within seconds, so it's never Running when the
|
||||||
|
// sweep probes.)
|
||||||
|
let dir = "/var/lib/archipelago/fmcd";
|
||||||
|
let mkdir = host_sudo(&["mkdir", "-p", dir])
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("mkdir {dir}"))?;
|
||||||
|
if !mkdir.success() {
|
||||||
|
return Err(anyhow::anyhow!("mkdir -p {dir} failed with status {mkdir}"));
|
||||||
|
}
|
||||||
|
let chown = host_sudo(&["chown", "-R", "1000:1000", dir])
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("chown {dir}"))?;
|
||||||
|
if !chown.success() {
|
||||||
|
return Err(anyhow::anyhow!("chown {dir} failed with status {chown}"));
|
||||||
|
}
|
||||||
|
Ok(Some(HookOutcome::Unchanged))
|
||||||
|
}
|
||||||
"grafana" => {
|
"grafana" => {
|
||||||
self.cleanup_stale_grafana_port().await;
|
self.cleanup_stale_grafana_port().await;
|
||||||
Ok(Some(HookOutcome::Unchanged))
|
Ok(Some(HookOutcome::Unchanged))
|
||||||
|
|||||||
@ -49,6 +49,46 @@ pub fn is_recovery_complete() -> bool {
|
|||||||
RECOVERY_COMPLETE.load(Ordering::SeqCst)
|
RECOVERY_COMPLETE.load(Ordering::SeqCst)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Pending boot-start tracking ─────────────────────────────────────────
|
||||||
|
// Containers that boot recovery / the reconciler is about to start (or is
|
||||||
|
// starting right now). The package scanner overlays these as `Restarting`
|
||||||
|
// instead of the raw podman `Stopped`/`Exited`, so a freshly rebooted node
|
||||||
|
// doesn't tell the user their apps are "Stopped" while the sequential
|
||||||
|
// recovery pass (3s stagger + up to minutes for heavyweights like bitcoin)
|
||||||
|
// is still working through the queue. Writers register names when a pass
|
||||||
|
// begins and remove each name once its start attempt finishes, whatever
|
||||||
|
// the outcome — a container that truly failed goes back to showing its
|
||||||
|
// real state on the next scan.
|
||||||
|
|
||||||
|
static PENDING_BOOT_STARTS: std::sync::LazyLock<std::sync::RwLock<std::collections::HashSet<String>>> =
|
||||||
|
std::sync::LazyLock::new(|| std::sync::RwLock::new(std::collections::HashSet::new()));
|
||||||
|
|
||||||
|
/// Register container/app names an active recovery or reconcile pass
|
||||||
|
/// intends to start.
|
||||||
|
pub fn pending_boot_starts_add<I: IntoIterator<Item = String>>(names: I) {
|
||||||
|
if let Ok(mut set) = PENDING_BOOT_STARTS.write() {
|
||||||
|
set.extend(names);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A start attempt for `name` finished (success or failure) — stop
|
||||||
|
/// overlaying it.
|
||||||
|
pub fn pending_boot_start_done(name: &str) {
|
||||||
|
if let Ok(mut set) = PENDING_BOOT_STARTS.write() {
|
||||||
|
set.remove(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `name` (a container name or scanner app id) is queued for a
|
||||||
|
/// boot/reconcile start. Container names may carry an `archy-` prefix the
|
||||||
|
/// scanner strips when deriving app ids, so check both forms.
|
||||||
|
pub fn is_pending_boot_start(name: &str) -> bool {
|
||||||
|
let Ok(set) = PENDING_BOOT_STARTS.read() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
set.contains(name) || set.contains(&format!("archy-{name}"))
|
||||||
|
}
|
||||||
|
|
||||||
// ── User-stopped tracking ───────────────────────────────────────────────
|
// ── User-stopped tracking ───────────────────────────────────────────────
|
||||||
// When a user explicitly stops a container via the UI, we record it here
|
// When a user explicitly stops a container via the UI, we record it here
|
||||||
// so crash recovery and health monitor don't auto-restart it.
|
// so crash recovery and health monitor don't auto-restart it.
|
||||||
@ -178,10 +218,17 @@ pub async fn check_for_crash(data_dir: &Path) -> Result<Option<Vec<RunningContai
|
|||||||
old_pid
|
old_pid
|
||||||
);
|
);
|
||||||
|
|
||||||
// Check if that PID is actually still running (zombie/stuck process)
|
// Check if that PID is actually still running (zombie/stuck process).
|
||||||
|
// Guard against PID reuse: after a reboot the old PID often belongs to an
|
||||||
|
// unrelated process (or, before the main.rs ordering fix, to OURSELVES) —
|
||||||
|
// only treat it as "previous instance still alive" if it's a live process
|
||||||
|
// that is not us and whose cmdline looks like the archipelago binary.
|
||||||
if !old_pid.is_empty() {
|
if !old_pid.is_empty() {
|
||||||
if let Ok(pid) = old_pid.parse::<u32>() {
|
if let Ok(pid) = old_pid.parse::<u32>() {
|
||||||
if is_process_running(pid) {
|
if pid != std::process::id()
|
||||||
|
&& is_process_running(pid)
|
||||||
|
&& process_is_archipelago(pid)
|
||||||
|
{
|
||||||
warn!(
|
warn!(
|
||||||
"Previous process (PID {}) is still running — not a crash, skipping recovery",
|
"Previous process (PID {}) is still running — not a crash, skipping recovery",
|
||||||
pid
|
pid
|
||||||
@ -311,6 +358,8 @@ pub async fn recover_containers(containers: &[RunningContainerRecord]) -> Recove
|
|||||||
failed: Vec::new(),
|
failed: Vec::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pending_boot_starts_add(containers.iter().map(|r| r.name.clone()));
|
||||||
|
|
||||||
for (i, record) in containers.iter().enumerate() {
|
for (i, record) in containers.iter().enumerate() {
|
||||||
info!(
|
info!(
|
||||||
"Recovering container: {} (image: {})",
|
"Recovering container: {} (image: {})",
|
||||||
@ -373,6 +422,7 @@ pub async fn recover_containers(containers: &[RunningContainerRecord]) -> Recove
|
|||||||
if !started {
|
if !started {
|
||||||
report.failed.push(record.name.clone());
|
report.failed.push(record.name.clone());
|
||||||
}
|
}
|
||||||
|
pending_boot_start_done(&record.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
report
|
report
|
||||||
@ -391,6 +441,16 @@ fn is_process_running(pid: u32) -> bool {
|
|||||||
std::path::Path::new(&format!("/proc/{}", pid)).exists()
|
std::path::Path::new(&format!("/proc/{}", pid)).exists()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the process at `pid` looks like an archipelago instance. Used to
|
||||||
|
/// tell "the previous instance is genuinely still alive" apart from PID
|
||||||
|
/// reuse by an unrelated process after a reboot.
|
||||||
|
fn process_is_archipelago(pid: u32) -> bool {
|
||||||
|
match std::fs::read(format!("/proc/{pid}/cmdline")) {
|
||||||
|
Ok(cmdline) => String::from_utf8_lossy(&cmdline).contains("archipelago"),
|
||||||
|
Err(_) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Start all stopped containers that were previously installed.
|
/// Start all stopped containers that were previously installed.
|
||||||
/// Runs on every startup to ensure containers come back after clean reboots.
|
/// Runs on every startup to ensure containers come back after clean reboots.
|
||||||
/// The crash recovery (PID-based) handles dirty shutdowns; this handles clean ones.
|
/// The crash recovery (PID-based) handles dirty shutdowns; this handles clean ones.
|
||||||
@ -425,16 +485,34 @@ async fn start_stopped_app_stacks(data_dir: &Path) -> RecoveryReport {
|
|||||||
);
|
);
|
||||||
repair_stack_network_aliases(stack).await;
|
repair_stack_network_aliases(stack).await;
|
||||||
|
|
||||||
|
// Register the whole stack up front: the per-member dependency waits
|
||||||
|
// below can take minutes, and the UI should say "Restarting", not
|
||||||
|
// "Stopped", for members still queued behind them.
|
||||||
|
pending_boot_starts_add(
|
||||||
|
stack
|
||||||
|
.containers
|
||||||
|
.iter()
|
||||||
|
.filter(|c| !user_stopped.contains(**c))
|
||||||
|
.map(|c| (*c).to_string()),
|
||||||
|
);
|
||||||
|
|
||||||
for container in stack.containers {
|
for container in stack.containers {
|
||||||
if user_stopped.contains(*container) {
|
if user_stopped.contains(*container) {
|
||||||
info!("Skipping user-stopped container: {}", container);
|
info!("Skipping user-stopped container: {}", container);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
match container_state(container).await {
|
let state = container_state(container).await;
|
||||||
Some(state) if state == "running" => continue,
|
match state {
|
||||||
|
Some(state) if state == "running" => {
|
||||||
|
pending_boot_start_done(container);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
Some(_) => {}
|
Some(_) => {}
|
||||||
None => continue,
|
None => {
|
||||||
|
pending_boot_start_done(container);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
repair_stack_network_aliases(stack).await;
|
repair_stack_network_aliases(stack).await;
|
||||||
@ -446,6 +524,7 @@ async fn start_stopped_app_stacks(data_dir: &Path) -> RecoveryReport {
|
|||||||
} else {
|
} else {
|
||||||
report.failed.push((*container).to_string());
|
report.failed.push((*container).to_string());
|
||||||
}
|
}
|
||||||
|
pending_boot_start_done(container);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -98,11 +98,15 @@ async fn main() -> Result<()> {
|
|||||||
let startup_start = std::time::Instant::now();
|
let startup_start = std::time::Instant::now();
|
||||||
crash_recovery::init_start_time();
|
crash_recovery::init_start_time();
|
||||||
|
|
||||||
// Initialize tracing
|
// Initialize tracing. Default to `info`: production units don't set
|
||||||
|
// RUST_LOG, and the old `archipelago=debug` default flooded journald
|
||||||
|
// with per-request debug lines ("RPC method: …", cookie-flag notes) —
|
||||||
|
// part of a >1 GB/day journal on a fresh node. Set RUST_LOG (e.g.
|
||||||
|
// RUST_LOG=archipelago=debug) to get debug logs back when debugging.
|
||||||
tracing_subscriber::fmt()
|
tracing_subscriber::fmt()
|
||||||
.with_env_filter(
|
.with_env_filter(
|
||||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
.unwrap_or_else(|_| "archipelago=debug,info".into()),
|
.unwrap_or_else(|_| "info".into()),
|
||||||
)
|
)
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
@ -149,13 +153,18 @@ async fn main() -> Result<()> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write PID marker early so we can detect crashes on next startup
|
// Check for a crash marker BEFORE writing our own. The old order wrote
|
||||||
|
// the marker first, so the check always read the CURRENT process's PID,
|
||||||
|
// found it alive, and skipped recovery — on every boot, forever.
|
||||||
|
let crash_containers = crash_recovery::check_for_crash(&config.data_dir).await;
|
||||||
|
|
||||||
|
// Now mark this instance as running so the next startup can detect a crash.
|
||||||
crash_recovery::write_pid_marker(&config.data_dir).await?;
|
crash_recovery::write_pid_marker(&config.data_dir).await?;
|
||||||
|
|
||||||
// Run crash recovery before starting the manifest reconciler. Both paths
|
// Run crash recovery before starting the manifest reconciler. Both paths
|
||||||
// mutate Podman; running them concurrently can corrupt transient runtime
|
// mutate Podman; running them concurrently can corrupt transient runtime
|
||||||
// state and leave netavark/conmon unable to start containers.
|
// state and leave netavark/conmon unable to start containers.
|
||||||
match crash_recovery::check_for_crash(&config.data_dir).await {
|
match crash_containers {
|
||||||
Ok(Some(containers)) => {
|
Ok(Some(containers)) => {
|
||||||
info!(
|
info!(
|
||||||
"🔧 Recovering {} containers from previous crash...",
|
"🔧 Recovering {} containers from previous crash...",
|
||||||
|
|||||||
@ -1203,6 +1203,21 @@ fn merge_preserving_transitional(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Package ids whose `Restarting` state was written by the scanner's
|
||||||
|
/// pending-boot-start overlay (not by an RPC restart task). For these, the
|
||||||
|
/// scan is the owner: once podman reports a settled state and the id is no
|
||||||
|
/// longer queued for a boot start, the fresh state wins immediately instead
|
||||||
|
/// of being preserved for the transitional-stuck timeout.
|
||||||
|
static SCANNER_RESTARTING: std::sync::LazyLock<std::sync::Mutex<std::collections::HashSet<String>>> =
|
||||||
|
std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashSet::new()));
|
||||||
|
|
||||||
|
fn take_scanner_restarting(id: &str) -> bool {
|
||||||
|
SCANNER_RESTARTING
|
||||||
|
.lock()
|
||||||
|
.map(|mut set| set.remove(id))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
fn is_podman_scan_timeout(error: &anyhow::Error) -> bool {
|
fn is_podman_scan_timeout(error: &anyhow::Error) -> bool {
|
||||||
let msg = format!("{:#}", error);
|
let msg = format!("{:#}", error);
|
||||||
msg.contains("podman ps") && msg.contains("timed out")
|
msg.contains("podman ps") && msg.contains("timed out")
|
||||||
@ -1223,6 +1238,25 @@ async fn scan_and_update_packages(
|
|||||||
pkg.state = crate::data_model::PackageState::Stopped;
|
pkg.state = crate::data_model::PackageState::Stopped;
|
||||||
pkg.exit_code = None;
|
pkg.exit_code = None;
|
||||||
}
|
}
|
||||||
|
// A down container that boot recovery / the reconciler is queued to
|
||||||
|
// start is "Restarting", not "Stopped" — after a reboot the sequential
|
||||||
|
// recovery pass can take minutes to reach heavyweights, and telling
|
||||||
|
// the user their app stopped when it's about to come back is wrong.
|
||||||
|
// Ids overlaid here are recorded in SCANNER_RESTARTING so the merge
|
||||||
|
// below knows this Restarting is scanner-authored (resolve it as soon
|
||||||
|
// as podman reports a settled state) and not owned by an RPC restart
|
||||||
|
// task (whose transitional state must be preserved).
|
||||||
|
if matches!(
|
||||||
|
pkg.state,
|
||||||
|
crate::data_model::PackageState::Stopped | crate::data_model::PackageState::Exited
|
||||||
|
) && crate::crash_recovery::is_pending_boot_start(id)
|
||||||
|
{
|
||||||
|
pkg.state = crate::data_model::PackageState::Restarting;
|
||||||
|
pkg.exit_code = None;
|
||||||
|
if let Ok(mut set) = SCANNER_RESTARTING.lock() {
|
||||||
|
set.insert(id.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
normalize_reachable_package_health(&mut packages).await;
|
normalize_reachable_package_health(&mut packages).await;
|
||||||
|
|
||||||
@ -1273,6 +1307,19 @@ async fn scan_and_update_packages(
|
|||||||
absence_tracker.remove(id);
|
absence_tracker.remove(id);
|
||||||
let existing = merged.get(id);
|
let existing = merged.get(id);
|
||||||
let overwrite = match existing {
|
let overwrite = match existing {
|
||||||
|
// Scanner-authored Restarting (the pending-boot-start overlay)
|
||||||
|
// resolves as soon as the fresh scan reports anything else: the
|
||||||
|
// scan is its owner — no RPC task will ever write a final state
|
||||||
|
// back. Without this, a successfully recovered container would
|
||||||
|
// sit wedged in "Restarting" until the 20-minute stuck timeout.
|
||||||
|
Some(existing_entry)
|
||||||
|
if existing_entry.state == crate::data_model::PackageState::Restarting
|
||||||
|
&& pkg.state != crate::data_model::PackageState::Restarting
|
||||||
|
&& take_scanner_restarting(id) =>
|
||||||
|
{
|
||||||
|
transitional_since.remove(id);
|
||||||
|
true
|
||||||
|
}
|
||||||
Some(existing_entry) if is_transitional(&existing_entry.state) => {
|
Some(existing_entry) if is_transitional(&existing_entry.state) => {
|
||||||
let entered = *transitional_since.entry(id.clone()).or_insert(now);
|
let entered = *transitional_since.entry(id.clone()).or_insert(now);
|
||||||
let timeout = transitional_stuck_timeout(&existing_entry.state);
|
let timeout = transitional_stuck_timeout(&existing_entry.state);
|
||||||
|
|||||||
142
docs/HANDOVER-2026-07-02-iso-feedback.md
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
# Handover — fresh-ISO feedback bug-bash (2026-07-02)
|
||||||
|
|
||||||
|
**For: the agent building the next ISO + fleet deploy.** All fixes below are
|
||||||
|
**uncommitted in this working tree** (per the user's flow: you audit, build the
|
||||||
|
ISO, deploy). Source feedback: user's fresh ISO install on a Framework
|
||||||
|
(11th-gen Tiger Lake) machine, node `192.168.1.81` (SSH `archipelago` /
|
||||||
|
`archipelago`). Diagnostic bundle: `/home/archipelago/incoming-logs/node-logs-192.168.1.81/`.
|
||||||
|
|
||||||
|
## ⚠️ Outstanding user request for the deploy
|
||||||
|
|
||||||
|
- **Change .81's web-UI password to `ThisIsWeb54321@`** — the user forgot the
|
||||||
|
current one. Node was unreachable from .116 during this session (flaky WiFi
|
||||||
|
AP, IP flapped .68↔.81). Do this during deploy (SSH works from the user's
|
||||||
|
machine; `archipelago`/`archipelago`).
|
||||||
|
|
||||||
|
## What changed (by file)
|
||||||
|
|
||||||
|
### Backend (core/archipelago/src) — builds clean, targeted tests pass
|
||||||
|
- `api/handler/websocket.rs` — **subscribe BEFORE initial snapshot** (the
|
||||||
|
"everything needs ctrl-r" root cause: broadcasts in the snapshot→subscribe
|
||||||
|
gap were silently lost; a stale client never learned containers-scanned).
|
||||||
|
- `main.rs` — crash check now runs BEFORE writing the PID marker (**crash
|
||||||
|
recovery had never run on any node** — it always saw its own PID and
|
||||||
|
skipped); tracing default demoted debug→info (journal volume).
|
||||||
|
- `crash_recovery.rs` — PID-reuse guard (`process_is_archipelago`); new
|
||||||
|
**pending-boot-starts registry** (names queued for recovery/reconcile) with
|
||||||
|
writers in `recover_containers` + stack recovery.
|
||||||
|
- `server.rs` — scanner overlays Stopped/Exited → **Restarting** for
|
||||||
|
pending-boot-start ids (user ask: "status should be restarting if they are
|
||||||
|
being restarted"); `SCANNER_RESTARTING` ownership set so scanner-authored
|
||||||
|
Restarting resolves immediately instead of wedging in the 20-min
|
||||||
|
transitional-preserve.
|
||||||
|
- `container/prod_orchestrator.rs` — reconcile pass + `adopt_existing`
|
||||||
|
register/deregister pending boot-starts; LND pre-start hook passes detected
|
||||||
|
`bitcoin_host()` (Knots vs Core) into `lnd::ensure_config`; new
|
||||||
|
`fedimint-clientd` pre-start hook (mkdir + chown 1000:1000 of
|
||||||
|
`/var/lib/archipelago/fmcd` — self-heals the crash-loop).
|
||||||
|
- `container/lnd.rs` — `ensure_config(paths, rpc_pass, bitcoin_host)`;
|
||||||
|
bitcoind.rpchost no longer hardcoded `bitcoin-knots`; drift check rewrites
|
||||||
|
host changes; +unit test `ensure_config_repairs_bitcoin_host_drift`.
|
||||||
|
- `api/rpc/package/dependencies.rs` — bounded **dependency wait**
|
||||||
|
(`wait_for_install_deps`, 36×5s): installed-but-starting deps wait with
|
||||||
|
"Waiting for Bitcoin to start…" on the card; not-installed deps fail fast
|
||||||
|
with `DependencyGateError` marker; +5 unit tests.
|
||||||
|
- `api/rpc/package/install.rs`, `stacks.rs` — call sites wired to
|
||||||
|
`gate_install_deps` (lnd/electrumx/mempool/btcpay).
|
||||||
|
- `api/rpc/package/async_lifecycle.rs` — `DependencyGateError` removes the
|
||||||
|
optimistic entry (**no more phantom "Stopped" LND tile**) + pushes an Error
|
||||||
|
notification with the reason.
|
||||||
|
- `api/rpc/package/progress.rs` — `set_install_message` helper.
|
||||||
|
- `api/rpc/seed_rpc.rs` — `save_pending_seed_encrypted`; seed.restore also
|
||||||
|
stashes the mnemonic; `auth.rs` — **auth.setup persists the encrypted seed
|
||||||
|
backup** (recovery-phrase reveal previously failed on EVERY node because
|
||||||
|
nothing ever wrote `master_seed.enc`).
|
||||||
|
- `api/rpc/middleware.rs` — sanitizer allowlist extended (seed/2FA/auth
|
||||||
|
errors reach the user instead of "Check server logs"); +2 tests.
|
||||||
|
- `bitcoin_status.rs` — friendly status for "connection reset" (bitcoind
|
||||||
|
starting); raw URL/os-error chains no longer shown; +3 tests.
|
||||||
|
- `bootstrap.rs` — journald drop-in self-heal (OTA nodes get log caps);
|
||||||
|
bitcoin.conf printtoconsole heal. (Log-spam agent's work; verified.)
|
||||||
|
- `api/rpc/package/config.rs` — bitcoin args `-printtoconsole=0`.
|
||||||
|
|
||||||
|
### Manifests / scripts / configs
|
||||||
|
- `apps/lnd/manifest.yml` — BITCOIND_HOST now `derived_env {{BITCOIN_HOST}}`.
|
||||||
|
- `apps/bitcoin-knots/manifest.yml`, `apps/bitcoin-core/manifest.yml` —
|
||||||
|
`-printtoconsole=0` (90.6% of the journal was IBD UpdateTip spam;
|
||||||
|
debug.log in the datadir keeps full logs).
|
||||||
|
- `scripts/first-boot-containers.sh` — chown 1000:1000 of
|
||||||
|
`/var/lib/archipelago/fmcd` in BOTH fmcd blocks (root-owned dir was the
|
||||||
|
fedimint-clientd "Permission denied os error 13" crash-loop);
|
||||||
|
printtoconsole=0.
|
||||||
|
- `scripts/container-doctor.sh`, `scripts/reconcile-containers.sh` —
|
||||||
|
printtoconsole=0.
|
||||||
|
- `image-recipe/configs/journald-archipelago.conf` (NEW) — SystemMaxUse=500M,
|
||||||
|
rate limits; baked by ISO builder + bootstrap self-heal.
|
||||||
|
- `image-recipe/configs/nginx-archipelago.conf` — `/assets/` 404s no longer
|
||||||
|
cacheable (the `always` immutable header could pin a missing background for
|
||||||
|
a YEAR); HTTPS block gained the missing `/assets/` location (was silently
|
||||||
|
serving index.html as images).
|
||||||
|
- `image-recipe/configs/archipelago-kiosk.service` — MemoryMax 1500→2800M,
|
||||||
|
MemoryHigh 1200→2200M (kiosk was riding reclaim-throttle = the lag).
|
||||||
|
- `image-recipe/_archived/build-auto-installer-iso.sh` — kiosk launcher/service
|
||||||
|
now spliced from `image-recipe/configs/` at build time (was a stale inline
|
||||||
|
heredoc that force-disabled GPU); **+ `firmware-intel-graphics` +
|
||||||
|
`firmware-amd-graphics`** (Debian trixie split the i915 DMC blobs out of
|
||||||
|
firmware-misc-nonfree; the .81 kernel logged tgl_dmc missing).
|
||||||
|
|
||||||
|
### Frontend (neode-ui) — vue-tsc clean, vitest green
|
||||||
|
- `views/Login.vue` — Enter in field 1 → focus confirm; Enter in confirm →
|
||||||
|
submit; submit button always clickable (shows inline mismatch/length error
|
||||||
|
instead of being silently disabled); errors clear on input; **Restart
|
||||||
|
Onboarding needs a confirming second click** (5s window) — this button is
|
||||||
|
the likely cause of the "onboarding restarted after mismatch" report.
|
||||||
|
+`login.restartConfirm` key in en/es locales.
|
||||||
|
- `stores/sync.ts` — 30s staleness reconciliation (server.get-state) while
|
||||||
|
connected; already-connected fast path now refetches too.
|
||||||
|
- `composables/useContainersScanTimeout.ts` (NEW, +tests) — 20s escape hatch;
|
||||||
|
wired into `Apps.vue` / `Discover.vue` / `Marketplace.vue`; fresh empty node
|
||||||
|
reaches the real "no apps yet" empty state; "Checking…" can never persist.
|
||||||
|
- Backgrounds: 10 heaviest bg JPEGs → **WebP q90** (9.4MB→6.6MB; refs updated
|
||||||
|
in OnboardingWrapper/Dashboard/useRouteTransitions); 7 remaining images
|
||||||
|
stayed JPEG (WebP came out LARGER on those — noisy sources; deliberate).
|
||||||
|
- `public/assets/video/video-intro.mp4` — re-encoded CRF20 (SSIM 0.988) with
|
||||||
|
**+faststart** (moov was at EOF → browser had to download all 15MB before
|
||||||
|
playing = the intro lag). 12.7MB now, streams immediately.
|
||||||
|
- LND icon: stale dist artifact; any fresh `npm run build` ships
|
||||||
|
`app-icons/lnd.png` correctly.
|
||||||
|
|
||||||
|
## Verification done here
|
||||||
|
- `cargo build -p archipelago` + `cargo check` clean; targeted tests
|
||||||
|
(bitcoin_status, middleware sanitize, dep_wait, lnd, crash_recovery,
|
||||||
|
boot_reconciler, bitcoin_host, prod_orchestrator lnd hooks): **52 passed,
|
||||||
|
0 failed**. Full suite: **898 passed, 0 failed, 1 ignored** (22s).
|
||||||
|
- `npm run build` green; dist verified: 10 bg-*.webp present, `lnd.png`
|
||||||
|
icon present, `restartConfirm` string in bundle, optimized faststart
|
||||||
|
video (12,740,782 bytes) in place. Note: main had a latent build breaker
|
||||||
|
(unused template ref in `Web5ConnectedNodes.vue` from commit 8256fde1,
|
||||||
|
vue-tsc TS6133) — fixed here by removing the dead ref/binding; without
|
||||||
|
this fix `npm run build` fails on current main.
|
||||||
|
- vitest: new composable tests + related suites pass.
|
||||||
|
- `bash -n` clean on all touched scripts; nginx conf live-verified by agent
|
||||||
|
(200/404/cache headers on both HTTP+HTTPS blocks).
|
||||||
|
- ISO kiosk splice byte-verified against configs/ by agent simulation.
|
||||||
|
|
||||||
|
## NOT done / left for you
|
||||||
|
1. **Full test-suite run + gate**: run the complete `cargo test` and (after
|
||||||
|
deploy) `tests/lifecycle/run-gate.sh` ON .228 per CLAUDE.md before any tag.
|
||||||
|
2. **Frontend bundle grep before shipping** (per memory/feedback): verify new
|
||||||
|
strings (e.g. `restartConfirm`, `bg-home.webp`) in the built tarball.
|
||||||
|
3. **Diagnostics collector** (`data-dir-listing.txt` = 15MB of podman overlay
|
||||||
|
internals; dmidecode empty) — collector script wasn't found in this repo
|
||||||
|
(likely lives on-node or in the user's collection script); fix when found.
|
||||||
|
4. **podman healthcheck cgroup EPERM spam** (1,250 journal errors, healthchecks
|
||||||
|
unreliable fleet-wide) — real open bug, Quadlet-phase territory, NOT fixed.
|
||||||
|
5. **DP link-training failures on .81** (display corruption) — likely
|
||||||
|
cable/dock/port hardware; firmware fix may help; tell user to try another
|
||||||
|
cable/port if corruption recurs.
|
||||||
|
6. **LoRa/RNode onboarding surface** — never scoped; user may want it as a
|
||||||
|
feature (mesh device-found modal exists only on Mesh page post-login).
|
||||||
|
7. The concurrent audit agent's files (`docs/1.8.0-RELEASE-HARDENING-PLAN.md`,
|
||||||
|
`core/.../trust/*`, parts of `bootstrap.rs`) are ALSO uncommitted here —
|
||||||
|
coordinate before committing; don't mix attribution.
|
||||||
@ -354,6 +354,8 @@ RUN apt-get update && apt-get -y full-upgrade && apt-get install -y --no-install
|
|||||||
firmware-iwlwifi \
|
firmware-iwlwifi \
|
||||||
firmware-misc-nonfree \
|
firmware-misc-nonfree \
|
||||||
firmware-linux-nonfree \
|
firmware-linux-nonfree \
|
||||||
|
firmware-intel-graphics \
|
||||||
|
firmware-amd-graphics \
|
||||||
intel-microcode \
|
intel-microcode \
|
||||||
amd64-microcode \
|
amd64-microcode \
|
||||||
xorg \
|
xorg \
|
||||||
@ -528,11 +530,15 @@ RUN mkdir -p /var/lib/archipelago/data /var/lib/archipelago/config /var/lib/arch
|
|||||||
# Persist journalctl across reboots — without /var/log/journal systemd
|
# Persist journalctl across reboots — without /var/log/journal systemd
|
||||||
# journal uses tmpfs and everything before the last boot is lost. We
|
# journal uses tmpfs and everything before the last boot is lost. We
|
||||||
# need the full history to diagnose first-boot / install / onboarding
|
# need the full history to diagnose first-boot / install / onboarding
|
||||||
# issues after the fact. Size cap keeps it from eating the disk.
|
# issues after the fact. Size cap keeps it from eating the disk, and the
|
||||||
|
# explicit rate limit stops a single chatty service (e.g. a container
|
||||||
|
# spamming conmon->journald during Bitcoin IBD) from drowning the journal.
|
||||||
|
# Keep this byte-identical to image-recipe/configs/journald-archipelago.conf —
|
||||||
|
# the backend self-heals the same file onto deployed nodes (bootstrap.rs).
|
||||||
RUN mkdir -p /var/log/journal && \
|
RUN mkdir -p /var/log/journal && \
|
||||||
systemd-tmpfiles --create --prefix /var/log/journal 2>/dev/null || true && \
|
systemd-tmpfiles --create --prefix /var/log/journal 2>/dev/null || true && \
|
||||||
install -d -m 0755 /etc/systemd/journald.conf.d && \
|
install -d -m 0755 /etc/systemd/journald.conf.d && \
|
||||||
printf '[Journal]\nStorage=persistent\nSystemMaxUse=500M\nRuntimeMaxUse=100M\nForwardToSyslog=no\n' > /etc/systemd/journald.conf.d/10-archipelago-persistent.conf
|
printf '[Journal]\nStorage=persistent\nSystemMaxUse=500M\nRuntimeMaxUse=100M\nForwardToSyslog=no\nRateLimitIntervalSec=30s\nRateLimitBurst=10000\n' > /etc/systemd/journald.conf.d/10-archipelago-persistent.conf
|
||||||
|
|
||||||
# Clean up
|
# Clean up
|
||||||
RUN apt-get clean && \
|
RUN apt-get clean && \
|
||||||
@ -2651,98 +2657,48 @@ RestartSec=5
|
|||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
CLAUDESVC
|
CLAUDESVC
|
||||||
|
|
||||||
# Kiosk mode — X11 + Chromium fullscreen on attached display
|
INSTALLER_SCRIPT
|
||||||
# Not enabled by default; toggle via: sudo archipelago-kiosk enable/disable
|
|
||||||
cat > /mnt/target/usr/local/bin/archipelago-kiosk-launcher <<'KIOSKLAUNCHER'
|
|
||||||
#!/bin/bash
|
|
||||||
# Start X server on VT7 (VT1 stays on MOTD/console)
|
|
||||||
/usr/bin/Xorg :0 vt7 -nolisten tcp -keeptty &
|
|
||||||
XPID=$!
|
|
||||||
sleep 3
|
|
||||||
|
|
||||||
# Switch to kiosk display
|
# -----------------------------------------------------------------------------
|
||||||
chvt 7 2>/dev/null || true
|
# Kiosk launcher + systemd service: spliced into auto-install.sh at BUILD time
|
||||||
|
# from image-recipe/configs/ — the single source of truth (the same files are
|
||||||
if ! kill -0 $XPID 2>/dev/null; then
|
# embedded in the Rust binary via include_str! in core/archipelago/src/bootstrap.rs
|
||||||
echo 'ERROR: Xorg failed to start'
|
# and self-healed onto nodes by ensure_kiosk_hardened()).
|
||||||
|
#
|
||||||
|
# A previous inline heredoc copy here had silently diverged (unconditional
|
||||||
|
# --disable-gpu, no CPU/memory limits, VT7 scheme) and shipped stale kiosk
|
||||||
|
# behavior on fresh ISOs. Never re-inline these payloads.
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
KIOSK_LAUNCHER_SRC="$SCRIPT_DIR/../configs/archipelago-kiosk-launcher.sh"
|
||||||
|
KIOSK_SERVICE_SRC="$SCRIPT_DIR/../configs/archipelago-kiosk.service"
|
||||||
|
for _kiosk_src in "$KIOSK_LAUNCHER_SRC" "$KIOSK_SERVICE_SRC"; do
|
||||||
|
if [ ! -f "$_kiosk_src" ]; then
|
||||||
|
echo "ERROR: kiosk config file missing: $_kiosk_src" >&2
|
||||||
|
echo " The ISO must ship the maintained kiosk launcher/service from" >&2
|
||||||
|
echo " image-recipe/configs/ — refusing to build (no stale fallback)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
# Guard: payloads must not contain the heredoc terminators we wrap them in.
|
||||||
|
if grep -qx 'KIOSKLAUNCHER' "$KIOSK_LAUNCHER_SRC" || grep -qx 'KIOSKSVC' "$KIOSK_SERVICE_SRC"; then
|
||||||
|
echo "ERROR: kiosk config contains a reserved heredoc terminator line (KIOSKLAUNCHER/KIOSKSVC)" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
{
|
||||||
|
echo "# Kiosk mode — X11 + Chromium fullscreen on attached display"
|
||||||
|
echo "# Not enabled by default; toggle via: sudo archipelago-kiosk enable/disable"
|
||||||
|
echo "# Payloads spliced at ISO-build time from image-recipe/configs/ (source of truth)."
|
||||||
|
echo "cat > /mnt/target/usr/local/bin/archipelago-kiosk-launcher <<'KIOSKLAUNCHER'"
|
||||||
|
cat "$KIOSK_LAUNCHER_SRC"
|
||||||
|
echo "KIOSKLAUNCHER"
|
||||||
|
echo "chmod +x /mnt/target/usr/local/bin/archipelago-kiosk-launcher"
|
||||||
|
echo ""
|
||||||
|
echo "cat > /mnt/target/etc/systemd/system/archipelago-kiosk.service <<'KIOSKSVC'"
|
||||||
|
cat "$KIOSK_SERVICE_SRC"
|
||||||
|
echo "KIOSKSVC"
|
||||||
|
} >> "$ARCH_DIR/auto-install.sh"
|
||||||
|
|
||||||
export DISPLAY=:0
|
cat >> "$ARCH_DIR/auto-install.sh" <<'INSTALLER_SCRIPT'
|
||||||
export HOME=/home/archipelago
|
|
||||||
|
|
||||||
xhost +SI:localuser:archipelago 2>/dev/null
|
|
||||||
xset s off 2>/dev/null
|
|
||||||
xset -dpms 2>/dev/null
|
|
||||||
xset s noblank 2>/dev/null
|
|
||||||
|
|
||||||
unclutter -idle 3 -root &
|
|
||||||
|
|
||||||
while true; do
|
|
||||||
# Get screen resolution for window sizing
|
|
||||||
SCREEN_RES=$(xdpyinfo 2>/dev/null | awk '/dimensions:/{print $2}')
|
|
||||||
SCREEN_RES=${SCREEN_RES:-1920x1080}
|
|
||||||
sudo -u archipelago env DISPLAY=:0 HOME=/home/archipelago chromium \
|
|
||||||
--kiosk \
|
|
||||||
--start-fullscreen \
|
|
||||||
--start-maximized \
|
|
||||||
--window-position=0,0 \
|
|
||||||
--window-size=${SCREEN_RES/x/,} \
|
|
||||||
--app=http://localhost/kiosk \
|
|
||||||
--noerrdialogs \
|
|
||||||
--disable-infobars \
|
|
||||||
--disable-translate \
|
|
||||||
--no-first-run \
|
|
||||||
--check-for-update-interval=31536000 \
|
|
||||||
--disable-features=TranslateUI,PasswordManagerOnboarding,AutofillServerCommunication,PasswordManagerEnabled \
|
|
||||||
--disable-session-crashed-bubble \
|
|
||||||
--disable-save-password-bubble \
|
|
||||||
--disable-suggestions-service \
|
|
||||||
--password-store=basic \
|
|
||||||
--disable-component-update \
|
|
||||||
--credentials_enable_service=false \
|
|
||||||
--disable-gpu \
|
|
||||||
--disable-breakpad \
|
|
||||||
--disable-metrics \
|
|
||||||
--disable-metrics-reporting \
|
|
||||||
--metrics-recording-only \
|
|
||||||
--disable-domain-reliability \
|
|
||||||
--disable-background-networking \
|
|
||||||
--disable-background-timer-throttling \
|
|
||||||
--disable-backgrounding-occluded-windows \
|
|
||||||
--user-data-dir=/var/lib/archipelago/chromium-kiosk
|
|
||||||
sleep 3
|
|
||||||
done
|
|
||||||
|
|
||||||
kill $XPID 2>/dev/null
|
|
||||||
KIOSKLAUNCHER
|
|
||||||
chmod +x /mnt/target/usr/local/bin/archipelago-kiosk-launcher
|
|
||||||
|
|
||||||
cat > /mnt/target/etc/systemd/system/archipelago-kiosk.service <<'KIOSKSVC'
|
|
||||||
[Unit]
|
|
||||||
Description=Archipelago Kiosk (X11 + Chromium)
|
|
||||||
After=archipelago.service systemd-user-sessions.service network-online.target
|
|
||||||
Wants=archipelago.service network-online.target
|
|
||||||
ConditionPathExists=/usr/local/bin/archipelago-kiosk-launcher
|
|
||||||
Conflicts=getty@tty1.service
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
# First-boot health-poll window is 300s (150 × 2s). Slow hardware
|
|
||||||
# (e.g. the atom-class box at .198) was blowing past the old 60s /
|
|
||||||
# 120s window, so Chromium launched against a not-yet-ready backend
|
|
||||||
# and showed a blank window that only recovered on reboot. At 300s
|
|
||||||
# even the unbundled-FileBrowser-pull + archipelago state sync + frontend
|
|
||||||
# settle fits with headroom. TimeoutStartSec is bumped in lockstep.
|
|
||||||
ExecStartPre=/bin/bash -c 'for i in $(seq 1 150); do curl -sf http://localhost/health >/dev/null 2>&1 && exit 0; sleep 2; done; exit 0'
|
|
||||||
ExecStart=/usr/local/bin/archipelago-kiosk-launcher
|
|
||||||
TimeoutStartSec=360
|
|
||||||
Restart=always
|
|
||||||
RestartSec=5
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
KIOSKSVC
|
|
||||||
|
|
||||||
# Toggle script: sudo archipelago-kiosk enable|disable|status
|
# Toggle script: sudo archipelago-kiosk enable|disable|status
|
||||||
cat > /mnt/target/usr/local/bin/archipelago-kiosk <<'KIOSKTOGGLE'
|
cat > /mnt/target/usr/local/bin/archipelago-kiosk <<'KIOSKTOGGLE'
|
||||||
@ -2790,8 +2746,8 @@ case "${1:-status}" in
|
|||||||
echo " status — Show current mode"
|
echo " status — Show current mode"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Keyboard shortcuts (from terminal):"
|
echo "Keyboard shortcuts (from terminal):"
|
||||||
echo " Ctrl+Alt+F7 — Switch to kiosk display"
|
echo " Ctrl+Alt+F1 — Kiosk display (when enabled; console login when disabled)"
|
||||||
echo " Ctrl+Alt+F1 — Switch to terminal"
|
echo " Ctrl+Alt+F2 — Text console"
|
||||||
exit 1
|
exit 1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
@ -27,8 +27,12 @@ RestartSec=5
|
|||||||
# also binds the chromium/Xorg children in this unit's cgroup.
|
# also binds the chromium/Xorg children in this unit's cgroup.
|
||||||
Delegate=yes
|
Delegate=yes
|
||||||
CPUQuota=75%
|
CPUQuota=75%
|
||||||
MemoryMax=1500M
|
# Raised from 1500M/1200M: a Framework (Tiger Lake) kiosk sat at 806M used /
|
||||||
MemoryHigh=1200M
|
# 1.1G peak, riding the old MemoryHigh reclaim-throttle line — the throttling
|
||||||
|
# itself was the perceived UI lag. Keep Max well above real peaks; High stays
|
||||||
|
# the soft reclaim line so a runaway kiosk still can't take the machine down.
|
||||||
|
MemoryMax=2800M
|
||||||
|
MemoryHigh=2200M
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
|
|||||||
7
image-recipe/configs/journald-archipelago.conf
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
[Journal]
|
||||||
|
Storage=persistent
|
||||||
|
SystemMaxUse=500M
|
||||||
|
RuntimeMaxUse=100M
|
||||||
|
ForwardToSyslog=no
|
||||||
|
RateLimitIntervalSec=30s
|
||||||
|
RateLimitBurst=10000
|
||||||
@ -115,11 +115,19 @@ server {
|
|||||||
# Versioned Vite assets must never fall through to index.html. During OTA
|
# Versioned Vite assets must never fall through to index.html. During OTA
|
||||||
# a browser can keep an old HTML shell that references now-removed hashed
|
# a browser can keep an old HTML shell that references now-removed hashed
|
||||||
# chunks; returning HTML for /assets/*.js triggers strict MIME failures.
|
# chunks; returning HTML for /assets/*.js triggers strict MIME failures.
|
||||||
# A real 404 plus immutable/no-cache split lets the app/browser recover on
|
# The immutable header must NOT use `always`: with `always` a transient
|
||||||
# refresh without caching the wrong content type.
|
# 404 (e.g. mid web-ui swap on first boot) gets cached by the browser for
|
||||||
|
# a year and the asset stays "missing" until a hard cache clear. Without
|
||||||
|
# `always` the header applies only to 2xx/3xx; 404s are routed to a
|
||||||
|
# named location that marks them no-store so the browser retries.
|
||||||
location /assets/ {
|
location /assets/ {
|
||||||
try_files $uri =404;
|
try_files $uri =404;
|
||||||
add_header Cache-Control "public, max-age=31536000, immutable" always;
|
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||||
|
error_page 404 = @asset_missing;
|
||||||
|
}
|
||||||
|
location @asset_missing {
|
||||||
|
add_header Cache-Control "no-store" always;
|
||||||
|
return 404;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~* ^/(registerSW\.js|sw\.js|workbox-[^/]+\.js)$ {
|
location ~* ^/(registerSW\.js|sw\.js|workbox-[^/]+\.js)$ {
|
||||||
@ -994,6 +1002,19 @@ server {
|
|||||||
try_files $uri =404;
|
try_files $uri =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Versioned Vite assets must never fall through to index.html (mirrors the
|
||||||
|
# HTTP block). No `always` on the immutable header: a transient 404 must
|
||||||
|
# not be cached for a year — 404s go to @asset_missing (no-store) instead.
|
||||||
|
location /assets/ {
|
||||||
|
try_files $uri =404;
|
||||||
|
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||||
|
error_page 404 = @asset_missing;
|
||||||
|
}
|
||||||
|
location @asset_missing {
|
||||||
|
add_header Cache-Control "no-store" always;
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -31,13 +31,13 @@ To change the intro splash and dashboard tab backgrounds **without touching any
|
|||||||
|
|
||||||
| Filename | Tab |
|
| Filename | Tab |
|
||||||
|----------|-----|
|
|----------|-----|
|
||||||
| **`bg-home.jpg`** | Home |
|
| **`bg-home.webp`** | Home |
|
||||||
| **`bg-web5.jpg`** | Web5 |
|
| **`bg-web5.jpg`** | Web5 |
|
||||||
| **`bg-network.jpg`** | Server / Network |
|
| **`bg-network.jpg`** | Server / Network |
|
||||||
| **`bg-settings.jpg`** | Settings |
|
| **`bg-settings.webp`** | Settings |
|
||||||
| **`bg-myapps.jpg`** | My Apps |
|
| **`bg-myapps.webp`** | My Apps |
|
||||||
| **`bg-appstore.jpg`** | App Store / Marketplace |
|
| **`bg-appstore.webp`** | App Store / Marketplace |
|
||||||
| **`bg-cloud.jpg`** | Cloud |
|
| **`bg-cloud.webp`** | Cloud |
|
||||||
| **`bg-intro.jpg`** | Default (also intro) |
|
| **`bg-intro.jpg`** | Default (also intro) |
|
||||||
| **`bg-intro-3.jpg`** | Alternate layer during transitions |
|
| **`bg-intro-3.jpg`** | Alternate layer during transitions |
|
||||||
|
|
||||||
@ -47,12 +47,12 @@ To change the intro splash and dashboard tab backgrounds **without touching any
|
|||||||
|
|
||||||
| Filename | Used for |
|
| Filename | Used for |
|
||||||
|----------|----------|
|
|----------|----------|
|
||||||
| **`bg-intro-1.jpg`** | Onboarding done, login |
|
| **`bg-intro-1.webp`** | Onboarding done, login |
|
||||||
| **`bg-intro-2.jpg`** | Onboarding verify |
|
| **`bg-intro-2.jpg`** | Onboarding verify |
|
||||||
| **`bg-intro-3.jpg`** | Onboarding path, dashboard transition layer |
|
| **`bg-intro-3.jpg`** | Onboarding path, dashboard transition layer |
|
||||||
| **`bg-intro-4.jpg`** | Onboarding options |
|
| **`bg-intro-4.webp`** | Onboarding options |
|
||||||
| **`bg-intro-5.jpg`** | Onboarding did |
|
| **`bg-intro-5.webp`** | Onboarding did |
|
||||||
| **`bg-intro-6.jpg`** | Onboarding backup |
|
| **`bg-intro-6.webp`** | Onboarding backup |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -62,16 +62,16 @@ To change the intro splash and dashboard tab backgrounds **without touching any
|
|||||||
|-------|-----------|
|
|-------|-----------|
|
||||||
| Intro image | `neode-ui/public/assets/img/bg-intro.jpg` |
|
| Intro image | `neode-ui/public/assets/img/bg-intro.jpg` |
|
||||||
| Intro video | `neode-ui/public/assets/video/video-intro.mp4` |
|
| Intro video | `neode-ui/public/assets/video/video-intro.mp4` |
|
||||||
| Home | `neode-ui/public/assets/img/bg-home.jpg` |
|
| Home | `neode-ui/public/assets/img/bg-home.webp` |
|
||||||
| Web5 | `neode-ui/public/assets/img/bg-web5.jpg` |
|
| Web5 | `neode-ui/public/assets/img/bg-web5.jpg` |
|
||||||
| Network | `neode-ui/public/assets/img/bg-network.jpg` |
|
| Network | `neode-ui/public/assets/img/bg-network.jpg` |
|
||||||
| Settings | `neode-ui/public/assets/img/bg-settings.jpg` |
|
| Settings | `neode-ui/public/assets/img/bg-settings.webp` |
|
||||||
| My Apps | `neode-ui/public/assets/img/bg-myapps.jpg` |
|
| My Apps | `neode-ui/public/assets/img/bg-myapps.webp` |
|
||||||
| App Store | `neode-ui/public/assets/img/bg-appstore.jpg` |
|
| App Store | `neode-ui/public/assets/img/bg-appstore.webp` |
|
||||||
| Cloud | `neode-ui/public/assets/img/bg-cloud.jpg` |
|
| Cloud | `neode-ui/public/assets/img/bg-cloud.webp` |
|
||||||
| Default | `neode-ui/public/assets/img/bg-intro.jpg` |
|
| Default | `neode-ui/public/assets/img/bg-intro.jpg` |
|
||||||
| Transition | `neode-ui/public/assets/img/bg-intro-3.jpg` |
|
| Transition | `neode-ui/public/assets/img/bg-intro-3.jpg` |
|
||||||
| Intro 1–6 | `neode-ui/public/assets/img/bg-intro-1.jpg` … `bg-intro-6.jpg` |
|
| Intro 1–6 | `neode-ui/public/assets/img/bg-intro-1.webp` … `bg-intro-6.webp` (intro-2 and intro-3 remain `.jpg` — WebP came out larger for those) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 965 KiB |
BIN
neode-ui/public/assets/img/bg-appstore.webp
Normal file
|
After Width: | Height: | Size: 799 KiB |
|
Before Width: | Height: | Size: 954 KiB |
BIN
neode-ui/public/assets/img/bg-cloud.webp
Normal file
|
After Width: | Height: | Size: 510 KiB |
|
Before Width: | Height: | Size: 943 KiB |
BIN
neode-ui/public/assets/img/bg-home.webp
Normal file
|
After Width: | Height: | Size: 434 KiB |
|
Before Width: | Height: | Size: 954 KiB |
BIN
neode-ui/public/assets/img/bg-intro-1.webp
Normal file
|
After Width: | Height: | Size: 510 KiB |
|
Before Width: | Height: | Size: 854 KiB |
BIN
neode-ui/public/assets/img/bg-intro-4.webp
Normal file
|
After Width: | Height: | Size: 555 KiB |
|
Before Width: | Height: | Size: 956 KiB |
BIN
neode-ui/public/assets/img/bg-intro-5.webp
Normal file
|
After Width: | Height: | Size: 688 KiB |
|
Before Width: | Height: | Size: 952 KiB |
BIN
neode-ui/public/assets/img/bg-intro-6.webp
Normal file
|
After Width: | Height: | Size: 674 KiB |
|
Before Width: | Height: | Size: 778 KiB |
BIN
neode-ui/public/assets/img/bg-mesh.webp
Normal file
|
After Width: | Height: | Size: 528 KiB |
|
Before Width: | Height: | Size: 965 KiB |
BIN
neode-ui/public/assets/img/bg-myapps.webp
Normal file
|
After Width: | Height: | Size: 799 KiB |
|
Before Width: | Height: | Size: 919 KiB |
BIN
neode-ui/public/assets/img/bg-settings.webp
Normal file
|
After Width: | Height: | Size: 915 KiB |
@ -0,0 +1,63 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { ref, nextTick } from 'vue'
|
||||||
|
import { useContainersScanTimeout } from '../useContainersScanTimeout'
|
||||||
|
|
||||||
|
describe('useContainersScanTimeout', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reflects the real scanned flag when it arrives before the timeout', async () => {
|
||||||
|
const scanned = ref(false)
|
||||||
|
const loaded = ref(true)
|
||||||
|
const { effectiveContainersScanned, scanTimedOut } = useContainersScanTimeout(scanned, loaded, 20_000)
|
||||||
|
|
||||||
|
expect(effectiveContainersScanned.value).toBe(false)
|
||||||
|
scanned.value = true
|
||||||
|
await nextTick()
|
||||||
|
expect(effectiveContainersScanned.value).toBe(true)
|
||||||
|
expect(scanTimedOut.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not start the timeout until initial data has loaded', async () => {
|
||||||
|
const scanned = ref(false)
|
||||||
|
const loaded = ref(false)
|
||||||
|
const { effectiveContainersScanned } = useContainersScanTimeout(scanned, loaded, 20_000)
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(60_000)
|
||||||
|
expect(effectiveContainersScanned.value).toBe(false)
|
||||||
|
|
||||||
|
loaded.value = true
|
||||||
|
await nextTick()
|
||||||
|
vi.advanceTimersByTime(20_000)
|
||||||
|
expect(effectiveContainersScanned.value).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls through after the timeout even if the flag never arrives', async () => {
|
||||||
|
const scanned = ref(false)
|
||||||
|
const loaded = ref(true)
|
||||||
|
const { effectiveContainersScanned, scanTimedOut } = useContainersScanTimeout(scanned, loaded, 20_000)
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(19_999)
|
||||||
|
expect(effectiveContainersScanned.value).toBe(false)
|
||||||
|
vi.advanceTimersByTime(1)
|
||||||
|
expect(effectiveContainersScanned.value).toBe(true)
|
||||||
|
expect(scanTimedOut.value).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cancels the escape hatch when the real flag arrives', async () => {
|
||||||
|
const scanned = ref(false)
|
||||||
|
const loaded = ref(true)
|
||||||
|
const { scanTimedOut } = useContainersScanTimeout(scanned, loaded, 20_000)
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(10_000)
|
||||||
|
scanned.value = true
|
||||||
|
await nextTick()
|
||||||
|
vi.advanceTimersByTime(60_000)
|
||||||
|
expect(scanTimedOut.value).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
56
neode-ui/src/composables/useContainersScanTimeout.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
// Escape hatch for the "Checking containers…" / "Checking..." states.
|
||||||
|
//
|
||||||
|
// If the server never flips `containers-scanned` to true (e.g. the UI missed
|
||||||
|
// a websocket broadcast), views gating on it would spin forever. This starts
|
||||||
|
// a timeout once initial data has loaded and, if the scan flag is still false
|
||||||
|
// when it fires, treats the scan as complete so the UI falls through to its
|
||||||
|
// real empty/install states. A periodic store-level resync exists too — this
|
||||||
|
// is the belt-and-suspenders guarantee that the spinner is always bounded.
|
||||||
|
|
||||||
|
import { computed, getCurrentInstance, onBeforeUnmount, ref, watch, type Ref } from 'vue'
|
||||||
|
|
||||||
|
const DEFAULT_SCAN_TIMEOUT_MS = 20_000
|
||||||
|
|
||||||
|
export function useContainersScanTimeout(
|
||||||
|
containersScanned: Ref<boolean>,
|
||||||
|
hasLoadedInitialData: Ref<boolean>,
|
||||||
|
timeoutMs: number = DEFAULT_SCAN_TIMEOUT_MS,
|
||||||
|
) {
|
||||||
|
const scanTimedOut = ref(false)
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
|
||||||
|
function clearTimer(): void {
|
||||||
|
if (timer !== undefined) {
|
||||||
|
clearTimeout(timer)
|
||||||
|
timer = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[containersScanned, hasLoadedInitialData],
|
||||||
|
([scanned, loaded]) => {
|
||||||
|
if (scanned) {
|
||||||
|
// Real signal arrived — cancel the escape hatch.
|
||||||
|
clearTimer()
|
||||||
|
scanTimedOut.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (loaded && timer === undefined && !scanTimedOut.value) {
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
scanTimedOut.value = true
|
||||||
|
timer = undefined
|
||||||
|
}, timeoutMs)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
if (getCurrentInstance()) onBeforeUnmount(clearTimer)
|
||||||
|
|
||||||
|
/** True once the server reports the scan done OR the timeout has elapsed. */
|
||||||
|
const effectiveContainersScanned = computed(
|
||||||
|
() => containersScanned.value || scanTimedOut.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
return { effectiveContainersScanned, scanTimedOut }
|
||||||
|
}
|
||||||
@ -96,6 +96,7 @@
|
|||||||
"serverStarting": "Server starting up...",
|
"serverStarting": "Server starting up...",
|
||||||
"replayIntro": "Replay Intro",
|
"replayIntro": "Replay Intro",
|
||||||
"onboarding": "Onboarding",
|
"onboarding": "Onboarding",
|
||||||
|
"restartConfirm": "Are you sure? This wipes onboarding progress — click again to confirm",
|
||||||
"resetting": "Resetting...",
|
"resetting": "Resetting...",
|
||||||
"recoveryNote": "Password recovery requires SSH access to the server.",
|
"recoveryNote": "Password recovery requires SSH access to the server.",
|
||||||
"errorMinLength": "Password must be at least 8 characters",
|
"errorMinLength": "Password must be at least 8 characters",
|
||||||
|
|||||||
@ -96,6 +96,7 @@
|
|||||||
"serverStarting": "El servidor est\u00e1 iniciando...",
|
"serverStarting": "El servidor est\u00e1 iniciando...",
|
||||||
"replayIntro": "Repetir introducci\u00f3n",
|
"replayIntro": "Repetir introducci\u00f3n",
|
||||||
"onboarding": "Configuraci\u00f3n inicial",
|
"onboarding": "Configuraci\u00f3n inicial",
|
||||||
|
"restartConfirm": "Are you sure? This wipes onboarding progress \u2014 click again to confirm",
|
||||||
"resetting": "Restableciendo...",
|
"resetting": "Restableciendo...",
|
||||||
"recoveryNote": "La recuperaci\u00f3n de contrase\u00f1a requiere acceso SSH al servidor.",
|
"recoveryNote": "La recuperaci\u00f3n de contrase\u00f1a requiere acceso SSH al servidor.",
|
||||||
"errorMinLength": "La contrase\u00f1a debe tener al menos 8 caracteres",
|
"errorMinLength": "La contrase\u00f1a debe tener al menos 8 caracteres",
|
||||||
|
|||||||
@ -14,6 +14,9 @@ export const useSyncStore = defineStore('sync', () => {
|
|||||||
const hasLoadedInitialData = ref(false)
|
const hasLoadedInitialData = ref(false)
|
||||||
let isWsSubscribed = false
|
let isWsSubscribed = false
|
||||||
let isWsConnecting = false
|
let isWsConnecting = false
|
||||||
|
let isRefreshingState = false
|
||||||
|
let stalenessTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
const STALENESS_INTERVAL_MS = 30_000
|
||||||
|
|
||||||
// Computed
|
// Computed
|
||||||
const serverInfo = computed(() => data.value?.['server-info'])
|
const serverInfo = computed(() => data.value?.['server-info'])
|
||||||
@ -21,6 +24,47 @@ export const useSyncStore = defineStore('sync', () => {
|
|||||||
const peerHealth = computed<Record<string, boolean>>(() => data.value?.['peer-health'] || {})
|
const peerHealth = computed<Record<string, boolean>>(() => data.value?.['peer-health'] || {})
|
||||||
const uiData = computed(() => data.value?.ui)
|
const uiData = computed(() => data.value?.ui)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refetch the full state snapshot via RPC and apply it through the same
|
||||||
|
* path as the post-connect fetch (revision/hasLoadedInitialData handling
|
||||||
|
* stays identical). Guarded against overlapping fetches.
|
||||||
|
*/
|
||||||
|
async function refreshStateFromServer(): Promise<void> {
|
||||||
|
if (isRefreshingState) return
|
||||||
|
isRefreshingState = true
|
||||||
|
try {
|
||||||
|
const freshState = await rpcClient.call<{ data: DataModel }>({ method: 'server.get-state' })
|
||||||
|
if (freshState?.data) {
|
||||||
|
data.value = freshState.data
|
||||||
|
hasLoadedInitialData.value = true
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Non-fatal: WebSocket patches will still work
|
||||||
|
if (import.meta.env.DEV) console.warn('[Store] Failed to refresh state from server')
|
||||||
|
} finally {
|
||||||
|
isRefreshingState = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Belt-and-suspenders against missed broadcasts: while the WebSocket is
|
||||||
|
* connected, periodically resync the full state so a dropped patch can
|
||||||
|
* never permanently strand the UI on stale data.
|
||||||
|
*/
|
||||||
|
function startStalenessReconciliation(): void {
|
||||||
|
if (stalenessTimer) return
|
||||||
|
stalenessTimer = setInterval(() => {
|
||||||
|
if (wsClient.isConnected()) void refreshStateFromServer()
|
||||||
|
}, STALENESS_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopStalenessReconciliation(): void {
|
||||||
|
if (stalenessTimer) {
|
||||||
|
clearInterval(stalenessTimer)
|
||||||
|
stalenessTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
async function connectWebSocket(): Promise<void> {
|
async function connectWebSocket(): Promise<void> {
|
||||||
// Prevent concurrent connection attempts
|
// Prevent concurrent connection attempts
|
||||||
@ -82,6 +126,10 @@ export const useSyncStore = defineStore('sync', () => {
|
|||||||
if (import.meta.env.DEV) console.log('[Store] WebSocket already connected')
|
if (import.meta.env.DEV) console.log('[Store] WebSocket already connected')
|
||||||
isConnected.value = true
|
isConnected.value = true
|
||||||
isReconnecting.value = false
|
isReconnecting.value = false
|
||||||
|
// Re-entrant call while already connected (e.g. after onboarding
|
||||||
|
// completes): resync state in case a broadcast was missed.
|
||||||
|
void refreshStateFromServer()
|
||||||
|
startStalenessReconciliation()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -89,16 +137,8 @@ export const useSyncStore = defineStore('sync', () => {
|
|||||||
if (import.meta.env.DEV) console.log('[Store] WebSocket connected')
|
if (import.meta.env.DEV) console.log('[Store] WebSocket connected')
|
||||||
|
|
||||||
// Fetch fresh state after reconnect to avoid stale patch application
|
// Fetch fresh state after reconnect to avoid stale patch application
|
||||||
try {
|
await refreshStateFromServer()
|
||||||
const freshState = await rpcClient.call<{ data: DataModel }>({ method: 'server.get-state' })
|
startStalenessReconciliation()
|
||||||
if (freshState?.data) {
|
|
||||||
data.value = freshState.data
|
|
||||||
hasLoadedInitialData.value = true
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Non-fatal: WebSocket patches will still work
|
|
||||||
if (import.meta.env.DEV) console.warn('[Store] Failed to fetch fresh state after reconnect')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Connection state will be updated via the callback
|
// Connection state will be updated via the callback
|
||||||
if (wsClient.isConnected()) {
|
if (wsClient.isConnected()) {
|
||||||
@ -158,6 +198,7 @@ export const useSyncStore = defineStore('sync', () => {
|
|||||||
|
|
||||||
/** Reset sync state on logout — called by auth store */
|
/** Reset sync state on logout — called by auth store */
|
||||||
function resetOnLogout(): void {
|
function resetOnLogout(): void {
|
||||||
|
stopStalenessReconciliation()
|
||||||
data.value = null
|
data.value = null
|
||||||
hasLoadedInitialData.value = false
|
hasLoadedInitialData.value = false
|
||||||
isWsSubscribed = false
|
isWsSubscribed = false
|
||||||
|
|||||||
@ -397,6 +397,7 @@ import { useAppsActions } from './apps/useAppsActions'
|
|||||||
import { validateSideloadRequest } from './apps/sideloadValidation'
|
import { validateSideloadRequest } from './apps/sideloadValidation'
|
||||||
import { useMarketplaceApp } from '@/composables/useMarketplaceApp'
|
import { useMarketplaceApp } from '@/composables/useMarketplaceApp'
|
||||||
import { useCollapsingHeaderTabs } from '@/composables/useCollapsingHeaderTabs'
|
import { useCollapsingHeaderTabs } from '@/composables/useCollapsingHeaderTabs'
|
||||||
|
import { useContainersScanTimeout } from '@/composables/useContainersScanTimeout'
|
||||||
import {
|
import {
|
||||||
type AppsTab, filterEntriesForTab, isWebOnlyApp, isWebsitePackage, opensInTab, resolveRuntimeLaunchUrl,
|
type AppsTab, filterEntriesForTab, isWebOnlyApp, isWebsitePackage, opensInTab, resolveRuntimeLaunchUrl,
|
||||||
WEB_ONLY_APPS, WEB_ONLY_APP_URLS, buildAllCategories, useCategoriesWithApps,
|
WEB_ONLY_APPS, WEB_ONLY_APP_URLS, buildAllCategories, useCategoriesWithApps,
|
||||||
@ -461,7 +462,14 @@ const ALL_CATEGORIES = computed(() => buildAllCategories(t))
|
|||||||
const SERVICE_CATEGORIES = computed(() => buildServiceCategories(t))
|
const SERVICE_CATEGORIES = computed(() => buildServiceCategories(t))
|
||||||
|
|
||||||
const livePackages = computed(() => store.packages || {})
|
const livePackages = computed(() => store.packages || {})
|
||||||
const containersScanned = computed(() => store.data?.['server-info']?.['status-info']?.['containers-scanned'] !== false)
|
// Field missing from server data = not scanned yet (consistent with Discover/Marketplace)
|
||||||
|
const containersScannedRaw = computed(() => store.data?.['server-info']?.['status-info']?.['containers-scanned'] ?? false)
|
||||||
|
// Escape hatch: never show "Checking containers…" forever — after a timeout,
|
||||||
|
// fall through to the real (empty) state even if the scanned flag never arrives.
|
||||||
|
const { effectiveContainersScanned: containersScanned } = useContainersScanTimeout(
|
||||||
|
containersScannedRaw,
|
||||||
|
computed(() => store.hasLoadedInitialData),
|
||||||
|
)
|
||||||
const {
|
const {
|
||||||
packages: stablePackages,
|
packages: stablePackages,
|
||||||
isUsingLastKnownPackages,
|
isUsingLastKnownPackages,
|
||||||
|
|||||||
@ -178,12 +178,12 @@ const backgroundImage = computed(() => {
|
|||||||
if (mapped) return mapped
|
if (mapped) return mapped
|
||||||
// Detail/sub pages inherit their parent tab's background so they stay
|
// Detail/sub pages inherit their parent tab's background so they stay
|
||||||
// visually "inside" the section instead of snapping to the home backdrop.
|
// visually "inside" the section instead of snapping to the home backdrop.
|
||||||
if (route.path.startsWith('/dashboard/cloud/')) return 'bg-cloud.jpg'
|
if (route.path.startsWith('/dashboard/cloud/')) return 'bg-cloud.webp'
|
||||||
if (route.path.startsWith('/dashboard/web5/')) return 'bg-web5.jpg'
|
if (route.path.startsWith('/dashboard/web5/')) return 'bg-web5.jpg'
|
||||||
if (route.path.startsWith('/dashboard/server/')) return 'bg-web5.jpg'
|
if (route.path.startsWith('/dashboard/server/')) return 'bg-web5.jpg'
|
||||||
if (route.path.startsWith('/dashboard/settings/')) return 'bg-settings.jpg'
|
if (route.path.startsWith('/dashboard/settings/')) return 'bg-settings.webp'
|
||||||
if (isDetailRoute(route.path)) return 'bg-intro.jpg'
|
if (isDetailRoute(route.path)) return 'bg-intro.jpg'
|
||||||
return 'bg-home.jpg'
|
return 'bg-home.webp'
|
||||||
})
|
})
|
||||||
|
|
||||||
const isDarkRoute = computed(() => {
|
const isDarkRoute = computed(() => {
|
||||||
|
|||||||
@ -235,6 +235,7 @@ import { useMarketplaceApp } from '@/composables/useMarketplaceApp'
|
|||||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
import { useCollapsingHeaderTabs } from '@/composables/useCollapsingHeaderTabs'
|
import { useCollapsingHeaderTabs } from '@/composables/useCollapsingHeaderTabs'
|
||||||
|
import { useContainersScanTimeout } from '@/composables/useContainersScanTimeout'
|
||||||
import { APP_STORE_SECTIONS } from './appStoreCategories'
|
import { APP_STORE_SECTIONS } from './appStoreCategories'
|
||||||
import DiscoverHero from './discover/DiscoverHero.vue'
|
import DiscoverHero from './discover/DiscoverHero.vue'
|
||||||
import FeaturedApps from './discover/FeaturedApps.vue'
|
import FeaturedApps from './discover/FeaturedApps.vue'
|
||||||
@ -335,7 +336,13 @@ function retryNostr() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const installedPackages = computed(() => store.data?.['package-data'] || {})
|
const installedPackages = computed(() => store.data?.['package-data'] || {})
|
||||||
const containersScanned = computed(() => store.data?.['server-info']?.['status-info']?.['containers-scanned'] ?? false)
|
const containersScannedRaw = computed(() => store.data?.['server-info']?.['status-info']?.['containers-scanned'] ?? false)
|
||||||
|
// Escape hatch: never leave app cards on "Checking..." forever — after a
|
||||||
|
// timeout, treat the scan as done so cards render their normal install state.
|
||||||
|
const { effectiveContainersScanned: containersScanned } = useContainersScanTimeout(
|
||||||
|
containersScannedRaw,
|
||||||
|
computed(() => store.hasLoadedInitialData),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
const allApps = computed(() => {
|
const allApps = computed(() => {
|
||||||
|
|||||||
@ -66,7 +66,8 @@
|
|||||||
data-form-type="other"
|
data-form-type="other"
|
||||||
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"
|
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')"
|
:placeholder="t('login.enterPasswordSetup')"
|
||||||
@keydown.enter="handleSetupWithSound"
|
@keydown.enter="confirmPasswordInputRef?.focus()"
|
||||||
|
@input="error = null"
|
||||||
:disabled="loading || formDisabled"
|
:disabled="loading || formDisabled"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -77,6 +78,7 @@
|
|||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="setup-confirm-password"
|
id="setup-confirm-password"
|
||||||
|
ref="confirmPasswordInputRef"
|
||||||
v-model="confirmPassword"
|
v-model="confirmPassword"
|
||||||
type="password"
|
type="password"
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
@ -84,14 +86,16 @@
|
|||||||
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"
|
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')"
|
:placeholder="t('login.confirmPasswordPlaceholder')"
|
||||||
@keydown.enter="handleSetupWithSound"
|
@keydown.enter="handleSetupWithSound"
|
||||||
|
@input="error = null"
|
||||||
:disabled="loading || formDisabled"
|
:disabled="loading || formDisabled"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@click="handleSetupWithSound"
|
@click="handleSetupWithSound"
|
||||||
:disabled="loading || formDisabled || !password || password.length < 8 || password !== confirmPassword"
|
:disabled="loading || formDisabled"
|
||||||
class="w-full glass-button px-6 py-3 rounded-lg font-medium transition-all hover:bg-black/70 hover:border-white/30 disabled:opacity-50 disabled:cursor-not-allowed"
|
class="w-full glass-button px-6 py-3 rounded-lg font-medium transition-all hover:bg-black/70 hover:border-white/30 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
:class="{ 'opacity-60': !password || password.length < 8 || password !== confirmPassword }"
|
||||||
>
|
>
|
||||||
<span v-if="!loading">{{ t('login.setupButton') }}</span>
|
<span v-if="!loading">{{ t('login.setupButton') }}</span>
|
||||||
<span v-else class="flex items-center justify-center">
|
<span v-else class="flex items-center justify-center">
|
||||||
@ -213,9 +217,10 @@
|
|||||||
<button
|
<button
|
||||||
@click="restartOnboarding"
|
@click="restartOnboarding"
|
||||||
:disabled="isResettingOnboarding"
|
:disabled="isResettingOnboarding"
|
||||||
class="text-xs text-white/50 hover:text-white/70 transition-colors underline-offset-2 hover:underline disabled:opacity-50 disabled:cursor-not-allowed"
|
class="text-xs transition-colors underline-offset-2 hover:underline disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
:class="confirmingRestartOnboarding ? 'text-orange-400 hover:text-orange-300' : 'text-white/50 hover:text-white/70'"
|
||||||
>
|
>
|
||||||
{{ isResettingOnboarding ? t('login.resetting') : t('login.onboarding') }}
|
{{ isResettingOnboarding ? t('login.resetting') : (confirmingRestartOnboarding ? t('login.restartConfirm') : t('login.onboarding')) }}
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@ -260,6 +265,7 @@ const requiresTotp = ref(false)
|
|||||||
const totpCode = ref('')
|
const totpCode = ref('')
|
||||||
const useBackupCode = ref(false)
|
const useBackupCode = ref(false)
|
||||||
const totpInputRef = ref<HTMLInputElement | null>(null)
|
const totpInputRef = ref<HTMLInputElement | null>(null)
|
||||||
|
const confirmPasswordInputRef = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
// Server startup state
|
// Server startup state
|
||||||
const serverReady = ref(false)
|
const serverReady = ref(false)
|
||||||
@ -338,6 +344,7 @@ onBeforeUnmount(() => {
|
|||||||
removeUnlockListeners()
|
removeUnlockListeners()
|
||||||
if (startupPollTimer) clearTimeout(startupPollTimer)
|
if (startupPollTimer) clearTimeout(startupPollTimer)
|
||||||
if (startupProgressInterval) clearInterval(startupProgressInterval)
|
if (startupProgressInterval) clearInterval(startupProgressInterval)
|
||||||
|
if (confirmRestartTimer) clearTimeout(confirmRestartTimer)
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@ -536,9 +543,26 @@ function replayIntro() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isResettingOnboarding = ref(false)
|
const isResettingOnboarding = ref(false)
|
||||||
|
const confirmingRestartOnboarding = ref(false)
|
||||||
|
let confirmRestartTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
async function restartOnboarding() {
|
async function restartOnboarding() {
|
||||||
if (isResettingOnboarding.value) return
|
if (isResettingOnboarding.value) return
|
||||||
|
// First click arms a confirmation state; only a second explicit click restarts.
|
||||||
|
if (!confirmingRestartOnboarding.value) {
|
||||||
|
confirmingRestartOnboarding.value = true
|
||||||
|
if (confirmRestartTimer) clearTimeout(confirmRestartTimer)
|
||||||
|
confirmRestartTimer = setTimeout(() => {
|
||||||
|
confirmingRestartOnboarding.value = false
|
||||||
|
confirmRestartTimer = null
|
||||||
|
}, 5000)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (confirmRestartTimer) {
|
||||||
|
clearTimeout(confirmRestartTimer)
|
||||||
|
confirmRestartTimer = null
|
||||||
|
}
|
||||||
|
confirmingRestartOnboarding.value = false
|
||||||
isResettingOnboarding.value = true
|
isResettingOnboarding.value = true
|
||||||
// Local-only reset — no RPC needed since user isn't logged in.
|
// Local-only reset — no RPC needed since user isn't logged in.
|
||||||
// Onboarding pages are all public, so clearing localStorage is enough.
|
// Onboarding pages are all public, so clearing localStorage is enough.
|
||||||
|
|||||||
@ -170,6 +170,7 @@ import { useMarketplaceApp } from '@/composables/useMarketplaceApp'
|
|||||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
import { useCollapsingHeaderTabs } from '@/composables/useCollapsingHeaderTabs'
|
import { useCollapsingHeaderTabs } from '@/composables/useCollapsingHeaderTabs'
|
||||||
|
import { useContainersScanTimeout } from '@/composables/useContainersScanTimeout'
|
||||||
import { APP_STORE_CATEGORIES, APP_STORE_SECTIONS } from './appStoreCategories'
|
import { APP_STORE_CATEGORIES, APP_STORE_SECTIONS } from './appStoreCategories'
|
||||||
import MarketplaceAppCard from './marketplace/MarketplaceAppCard.vue'
|
import MarketplaceAppCard from './marketplace/MarketplaceAppCard.vue'
|
||||||
import {
|
import {
|
||||||
@ -286,9 +287,15 @@ const installedPackages = computed(() => {
|
|||||||
return store.data?.['package-data'] || {}
|
return store.data?.['package-data'] || {}
|
||||||
})
|
})
|
||||||
|
|
||||||
const containersScanned = computed(() => {
|
const containersScannedRaw = computed(() => {
|
||||||
return store.data?.['server-info']?.['status-info']?.['containers-scanned'] ?? false
|
return store.data?.['server-info']?.['status-info']?.['containers-scanned'] ?? false
|
||||||
})
|
})
|
||||||
|
// Escape hatch: never leave app cards on "Checking..." forever — after a
|
||||||
|
// timeout, treat the scan as done so cards render their normal install state.
|
||||||
|
const { effectiveContainersScanned: containersScanned } = useContainersScanTimeout(
|
||||||
|
containersScannedRaw,
|
||||||
|
computed(() => store.hasLoadedInitialData),
|
||||||
|
)
|
||||||
|
|
||||||
// Combine curated apps with Nostr relay-discovered apps
|
// Combine curated apps with Nostr relay-discovered apps
|
||||||
const allApps = computed(() => {
|
const allApps = computed(() => {
|
||||||
|
|||||||
@ -109,16 +109,16 @@ const useVideoBackground = computed(() => {
|
|||||||
// Note: bg-intro.jpg is used for splash and /onboarding/intro for seamless transition
|
// Note: bg-intro.jpg is used for splash and /onboarding/intro for seamless transition
|
||||||
const routeBackgrounds: Record<string, string> = {
|
const routeBackgrounds: Record<string, string> = {
|
||||||
'/onboarding/intro': 'bg-intro.jpg', // Video will be used instead
|
'/onboarding/intro': 'bg-intro.jpg', // Video will be used instead
|
||||||
'/onboarding/options': 'bg-intro-4.jpg',
|
'/onboarding/options': 'bg-intro-4.webp',
|
||||||
'/onboarding/path': 'bg-intro-3.jpg',
|
'/onboarding/path': 'bg-intro-3.jpg',
|
||||||
'/onboarding/seed': 'bg-intro-1.jpg',
|
'/onboarding/seed': 'bg-intro-1.webp',
|
||||||
'/onboarding/seed-verify': 'bg-intro-1.jpg',
|
'/onboarding/seed-verify': 'bg-intro-1.webp',
|
||||||
'/onboarding/seed-restore': 'bg-intro-1.jpg',
|
'/onboarding/seed-restore': 'bg-intro-1.webp',
|
||||||
'/onboarding/did': 'bg-intro-4.jpg',
|
'/onboarding/did': 'bg-intro-4.webp',
|
||||||
'/onboarding/identity': 'bg-intro-1.jpg',
|
'/onboarding/identity': 'bg-intro-1.webp',
|
||||||
'/onboarding/backup': 'bg-intro-6.jpg',
|
'/onboarding/backup': 'bg-intro-6.webp',
|
||||||
'/onboarding/verify': 'bg-intro-2.jpg',
|
'/onboarding/verify': 'bg-intro-2.jpg',
|
||||||
'/onboarding/done': 'bg-intro-1.jpg',
|
'/onboarding/done': 'bg-intro-1.webp',
|
||||||
'/login': 'bg-intro.jpg' // Video loops from splash (same as intro)
|
'/login': 'bg-intro.jpg' // Video loops from splash (same as intro)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -126,12 +126,12 @@ const routeBackgrounds: Record<string, string> = {
|
|||||||
// identical on every logout. Cycles through bg-intro-1..6 using a
|
// identical on every logout. Cycles through bg-intro-1..6 using a
|
||||||
// counter persisted to localStorage so subsequent visits advance.
|
// counter persisted to localStorage so subsequent visits advance.
|
||||||
const LOGIN_BACKGROUNDS = [
|
const LOGIN_BACKGROUNDS = [
|
||||||
'bg-intro-1.jpg',
|
'bg-intro-1.webp',
|
||||||
'bg-intro-2.jpg',
|
'bg-intro-2.jpg',
|
||||||
'bg-intro-3.jpg',
|
'bg-intro-3.jpg',
|
||||||
'bg-intro-4.jpg',
|
'bg-intro-4.webp',
|
||||||
'bg-intro-5.jpg',
|
'bg-intro-5.webp',
|
||||||
'bg-intro-6.jpg',
|
'bg-intro-6.webp',
|
||||||
]
|
]
|
||||||
function pickNextLoginBackground(): string {
|
function pickNextLoginBackground(): string {
|
||||||
try {
|
try {
|
||||||
@ -307,7 +307,7 @@ watch(() => route.path, (newPath, oldPath) => {
|
|||||||
|
|
||||||
// Login route: set background immediately, no zoom, no transition (glitch is always-on)
|
// Login route: set background immediately, no zoom, no transition (glitch is always-on)
|
||||||
if (newPath === '/login') {
|
if (newPath === '/login') {
|
||||||
currentBackground.value = 'bg-intro-1.jpg'
|
currentBackground.value = 'bg-intro-1.webp'
|
||||||
isTransitioning.value = false
|
isTransitioning.value = false
|
||||||
isGlitching.value = false
|
isGlitching.value = false
|
||||||
return
|
return
|
||||||
|
|||||||
@ -19,19 +19,19 @@ const WEB5_TAB_ORDER = ['/dashboard/web5', '/dashboard/cloud', '/dashboard/serve
|
|||||||
|
|
||||||
/** Route-to-background image mapping */
|
/** Route-to-background image mapping */
|
||||||
export const ROUTE_BACKGROUNDS: Record<string, string> = {
|
export const ROUTE_BACKGROUNDS: Record<string, string> = {
|
||||||
'/dashboard': 'bg-home.jpg',
|
'/dashboard': 'bg-home.webp',
|
||||||
'/dashboard/': 'bg-home.jpg',
|
'/dashboard/': 'bg-home.webp',
|
||||||
'/dashboard/apps': 'bg-myapps.jpg',
|
'/dashboard/apps': 'bg-myapps.webp',
|
||||||
'/dashboard/discover': 'bg-appstore.jpg',
|
'/dashboard/discover': 'bg-appstore.webp',
|
||||||
'/dashboard/marketplace': 'bg-appstore.jpg',
|
'/dashboard/marketplace': 'bg-appstore.webp',
|
||||||
'/dashboard/cloud': 'bg-cloud.jpg',
|
'/dashboard/cloud': 'bg-cloud.webp',
|
||||||
'/dashboard/mesh': 'bg-mesh.jpg',
|
'/dashboard/mesh': 'bg-mesh.webp',
|
||||||
'/dashboard/server': 'bg-network.jpg',
|
'/dashboard/server': 'bg-network.jpg',
|
||||||
'/dashboard/web5': 'bg-web5.jpg',
|
'/dashboard/web5': 'bg-web5.jpg',
|
||||||
'/dashboard/server/federation': 'bg-web5.jpg',
|
'/dashboard/server/federation': 'bg-web5.jpg',
|
||||||
'/dashboard/monitoring': 'bg-web5.jpg',
|
'/dashboard/monitoring': 'bg-web5.jpg',
|
||||||
'/dashboard/fleet': 'bg-web5.jpg',
|
'/dashboard/fleet': 'bg-web5.jpg',
|
||||||
'/dashboard/settings': 'bg-settings.jpg',
|
'/dashboard/settings': 'bg-settings.webp',
|
||||||
'/dashboard/chat': 'bg-aiui.jpg',
|
'/dashboard/chat': 'bg-aiui.jpg',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<!-- Connected Nodes (P2P over Tor) -->
|
<!-- Connected Nodes (P2P over Tor) -->
|
||||||
<div ref="nodesContainerRef" data-controller-container tabindex="0" class="glass-card p-6 scroll-mt-24 flex flex-col">
|
<div data-controller-container tabindex="0" class="glass-card p-6 scroll-mt-24 flex flex-col">
|
||||||
<!-- Desktop: side-by-side layout -->
|
<!-- Desktop: side-by-side layout -->
|
||||||
<div class="hidden md:flex items-start gap-4 mb-4">
|
<div class="hidden md:flex items-start gap-4 mb-4">
|
||||||
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
|
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
|
||||||
@ -319,7 +319,6 @@ function writeConnectedNodesCache(state: ConnectedNodesCache) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const nodesContainerRef = ref<HTMLElement | null>(null)
|
|
||||||
const nodesContainerTab = ref<'trusted' | 'observers' | 'requests'>('trusted')
|
const nodesContainerTab = ref<'trusted' | 'observers' | 'requests'>('trusted')
|
||||||
const { loadReceivedMessages } = messageToast
|
const { loadReceivedMessages } = messageToast
|
||||||
|
|
||||||
|
|||||||
@ -269,7 +269,7 @@ rpcpassword=$BTC_RPC_PASS
|
|||||||
rpcallowip=127.0.0.1/32
|
rpcallowip=127.0.0.1/32
|
||||||
rpcallowip=10.88.0.0/16
|
rpcallowip=10.88.0.0/16
|
||||||
listen=1
|
listen=1
|
||||||
printtoconsole=1
|
printtoconsole=0
|
||||||
BCONF
|
BCONF
|
||||||
log "Updated bitcoin.conf with full RPC settings"
|
log "Updated bitcoin.conf with full RPC settings"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@ -518,7 +518,7 @@ deploy_node() {
|
|||||||
if [ -f /var/lib/archipelago/bitcoin/bitcoin.conf ]; then
|
if [ -f /var/lib/archipelago/bitcoin/bitcoin.conf ]; then
|
||||||
if grep -q 'rpcbind' /var/lib/archipelago/bitcoin/bitcoin.conf 2>/dev/null; then
|
if grep -q 'rpcbind' /var/lib/archipelago/bitcoin/bitcoin.conf 2>/dev/null; then
|
||||||
echo ' Cleaning old bitcoin.conf (conflicting rpcbind)...'
|
echo ' Cleaning old bitcoin.conf (conflicting rpcbind)...'
|
||||||
printf 'printtoconsole=1\n' | sudo tee /var/lib/archipelago/bitcoin/bitcoin.conf > /dev/null
|
printf 'printtoconsole=0\n' | sudo tee /var/lib/archipelago/bitcoin/bitcoin.conf > /dev/null
|
||||||
sudo chown 100101:100101 /var/lib/archipelago/bitcoin/bitcoin.conf 2>/dev/null
|
sudo chown 100101:100101 /var/lib/archipelago/bitcoin/bitcoin.conf 2>/dev/null
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|||||||
@ -180,6 +180,10 @@ FBEOF
|
|||||||
mkdir -p /var/lib/archipelago/fmcd
|
mkdir -p /var/lib/archipelago/fmcd
|
||||||
FMCD_PW_FILE=/var/lib/archipelago/fmcd/password
|
FMCD_PW_FILE=/var/lib/archipelago/fmcd/password
|
||||||
[ -s "$FMCD_PW_FILE" ] || head -c 24 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' > "$FMCD_PW_FILE"
|
[ -s "$FMCD_PW_FILE" ] || head -c 24 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' > "$FMCD_PW_FILE"
|
||||||
|
# This script runs as root but the container is rootless (container
|
||||||
|
# uid 0 → host 1000): a root-owned /data leaves fmcd crash-looping
|
||||||
|
# with "Permission denied (os error 13)". Match manifest data_uid.
|
||||||
|
chown -R 1000:1000 /var/lib/archipelago/fmcd
|
||||||
FMCD_PW="$(cat "$FMCD_PW_FILE")"
|
FMCD_PW="$(cat "$FMCD_PW_FILE")"
|
||||||
FMCD_DEFAULT_INVITE="fed11qgqyj3mfwfhksw309uuxywtxxfjrjc35xuexverpxdsnxcnrxucxvenzveskgc3kvvun2c34xp3k2ep38yunzdpexcekxe3hvd3rvvmx8pnrvdenx5mnzvtzqqqjqt0t6pc3s5z0ynqjw9s4njf6svwgu59kweawc0vvrddcjeemw6yyn4pcdp"
|
FMCD_DEFAULT_INVITE="fed11qgqyj3mfwfhksw309uuxywtxxfjrjc35xuexverpxdsnxcnrxucxvenzveskgc3kvvun2c34xp3k2ep38yunzdpexcekxe3hvd3rvvmx8pnrvdenx5mnzvtzqqqjqt0t6pc3s5z0ynqjw9s4njf6svwgu59kweawc0vvrddcjeemw6yyn4pcdp"
|
||||||
pull_with_fallback "${FMCD_IMAGE}"
|
pull_with_fallback "${FMCD_IMAGE}"
|
||||||
@ -366,7 +370,9 @@ rpcauth=${RPCAUTH}
|
|||||||
server=1
|
server=1
|
||||||
rpcallowip=0.0.0.0/0
|
rpcallowip=0.0.0.0/0
|
||||||
listen=1
|
listen=1
|
||||||
printtoconsole=1
|
# printtoconsole=0: journald log-volume fix — bitcoind's datadir debug.log
|
||||||
|
# already has everything; console duplication spammed journald during IBD.
|
||||||
|
printtoconsole=0
|
||||||
# ZMQ publishers for LND and other services that need real-time block/tx notifications
|
# ZMQ publishers for LND and other services that need real-time block/tx notifications
|
||||||
zmqpubrawblock=tcp://0.0.0.0:28332
|
zmqpubrawblock=tcp://0.0.0.0:28332
|
||||||
zmqpubrawtx=tcp://0.0.0.0:28333
|
zmqpubrawtx=tcp://0.0.0.0:28333
|
||||||
@ -649,7 +655,7 @@ if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -qE 'bitcoin-knots|arch
|
|||||||
-v /var/lib/archipelago/bitcoin:/home/bitcoin/.bitcoin \
|
-v /var/lib/archipelago/bitcoin:/home/bitcoin/.bitcoin \
|
||||||
"${BITCOIN_KNOTS_IMAGE}" \
|
"${BITCOIN_KNOTS_IMAGE}" \
|
||||||
$BTC_EXTRA_ARGS \
|
$BTC_EXTRA_ARGS \
|
||||||
-printtoconsole=1 -dbcache=$BTC_DBCACHE -par=0 -maxconnections=125 -rpcthreads=16 -rpcworkqueue=256 2>>"$LOG"; then
|
-printtoconsole=0 -dbcache=$BTC_DBCACHE -par=0 -maxconnections=125 -rpcthreads=16 -rpcworkqueue=256 2>>"$LOG"; then
|
||||||
log "Bitcoin Knots started"
|
log "Bitcoin Knots started"
|
||||||
else
|
else
|
||||||
log "Bitcoin Knots failed (may already exist)"
|
log "Bitcoin Knots failed (may already exist)"
|
||||||
@ -1055,6 +1061,9 @@ if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q '^fedimint-clientd$'
|
|||||||
mkdir -p /var/lib/archipelago/fmcd
|
mkdir -p /var/lib/archipelago/fmcd
|
||||||
FMCD_PW_FILE=/var/lib/archipelago/fmcd/password
|
FMCD_PW_FILE=/var/lib/archipelago/fmcd/password
|
||||||
[ -s "$FMCD_PW_FILE" ] || head -c 24 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' > "$FMCD_PW_FILE"
|
[ -s "$FMCD_PW_FILE" ] || head -c 24 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' > "$FMCD_PW_FILE"
|
||||||
|
# Root-created /data breaks the rootless container (uid 0 → host 1000):
|
||||||
|
# fmcd crash-loops with "Permission denied (os error 13)". Match data_uid.
|
||||||
|
chown -R 1000:1000 /var/lib/archipelago/fmcd
|
||||||
FMCD_PW="$(cat "$FMCD_PW_FILE")"
|
FMCD_PW="$(cat "$FMCD_PW_FILE")"
|
||||||
FMCD_DEFAULT_INVITE="fed11qgqyj3mfwfhksw309uuxywtxxfjrjc35xuexverpxdsnxcnrxucxvenzveskgc3kvvun2c34xp3k2ep38yunzdpexcekxe3hvd3rvvmx8pnrvdenx5mnzvtzqqqjqt0t6pc3s5z0ynqjw9s4njf6svwgu59kweawc0vvrddcjeemw6yyn4pcdp"
|
FMCD_DEFAULT_INVITE="fed11qgqyj3mfwfhksw309uuxywtxxfjrjc35xuexverpxdsnxcnrxucxvenzveskgc3kvvun2c34xp3k2ep38yunzdpexcekxe3hvd3rvvmx8pnrvdenx5mnzvtzqqqjqt0t6pc3s5z0ynqjw9s4njf6svwgu59kweawc0vvrddcjeemw6yyn4pcdp"
|
||||||
$DOCKER run -d --name fedimint-clientd --restart unless-stopped \
|
$DOCKER run -d --name fedimint-clientd --restart unless-stopped \
|
||||||
|
|||||||
@ -722,10 +722,11 @@ ensure_bitcoin_conf() {
|
|||||||
hash=$(echo -n "$BITCOIN_RPC_PASS" | openssl dgst -sha256 -hmac "$salt" -hex 2>/dev/null | awk '{print $NF}')
|
hash=$(echo -n "$BITCOIN_RPC_PASS" | openssl dgst -sha256 -hmac "$salt" -hex 2>/dev/null | awk '{print $NF}')
|
||||||
rpcauth="${BITCOIN_RPC_USER}:${salt}\$${hash}"
|
rpcauth="${BITCOIN_RPC_USER}:${salt}\$${hash}"
|
||||||
# Only rpcauth + printtoconsole here — all other options are in SPEC_CUSTOM_ARGS
|
# Only rpcauth + printtoconsole here — all other options are in SPEC_CUSTOM_ARGS
|
||||||
# to avoid duplicate bind conflicts
|
# to avoid duplicate bind conflicts. printtoconsole=0: datadir debug.log
|
||||||
|
# already has everything; console duplication spammed journald during IBD.
|
||||||
sudo tee "$BITCOIN_CONF" >/dev/null << BTCEOF
|
sudo tee "$BITCOIN_CONF" >/dev/null << BTCEOF
|
||||||
rpcauth=${rpcauth}
|
rpcauth=${rpcauth}
|
||||||
printtoconsole=1
|
printtoconsole=0
|
||||||
BTCEOF
|
BTCEOF
|
||||||
info "Generated bitcoin.conf"
|
info "Generated bitcoin.conf"
|
||||||
fi
|
fi
|
||||||
|
|||||||