chore(release): stage v1.7.52-alpha

This commit is contained in:
archipelago 2026-05-05 11:29:18 -04:00
parent 10fbb8f87c
commit 745cb1c626
86 changed files with 4084 additions and 966 deletions

View File

@ -1,5 +1,12 @@
# Changelog
## v1.7.52-alpha (2026-05-05)
- Tailscale now launches the local installed web UI on port `8240` and starts `tailscaled` before `tailscale web`, fixing unreachable installs after container creation.
- Grafana install/start/restart now repairs missing rootless host listeners on port `3000`, matching the existing SearXNG, Uptime Kuma, and Gitea recovery path.
- Debian 13/Trixie ISO and disk-install paths now force security updates from `trixie-security` during image/install creation so rebuilt release media includes patched base packages.
- Broad `.198` lifecycle audit passes with the current qualified app set; known absent blockers remain `electrumx`, `photoprism`, `dwn`, and `ollama`.
## v1.7.49-alpha (2026-04-30)
- Bitcoin Knots/Core UI now reports connection, reconnecting, syncing, and error states from a backend status bridge instead of showing a stale "Unable to connect" message while the node is warming up.

View File

@ -85,7 +85,7 @@
"title": "ElectrumX",
"version": "1.18.0",
"description": "Electrum protocol server. Index the blockchain for fast wallet lookups.",
"icon": "/assets/img/app-icons/electrumx.webp",
"icon": "/assets/img/app-icons/electrumx.png",
"author": "Luke Childs",
"category": "money",
"tier": "core",

View File

@ -27,12 +27,6 @@ app:
container: 8080
protocol: tcp
volumes:
- type: bind
source: /var/lib/archipelago/mempool/nginx.conf
target: /etc/nginx/conf.d/default.conf
options: [ro]
environment:
- FRONTEND_HTTP_PORT=8080
- BACKEND_MAINNET_HTTP_HOST=mempool-api

View File

@ -47,6 +47,8 @@ app:
- NBXPLORER_BIND=0.0.0.0:32838
- NBXPLORER_BTCRPCURL=http://bitcoin-knots:8332
- NBXPLORER_BTCRPCUSER=archipelago
- NBXPLORER_BTCNODEENDPOINT=bitcoin-knots:8333
- NBXPLORER_NOAUTH=1
- NBXPLORER_POSTGRES=Username=btcpay;Password=${BTCPAY_DB_PASS};Host=archy-btcpay-db;Port=5432;Database=nbxplorer
health_check:

View File

@ -27,9 +27,9 @@ app:
exit 127;
fi;
if [ "${DISK_GB:-0}" -lt 1000 ]; then
exec "$BITCOIND" -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 -rpcuser="${BITCOIN_RPC_USER}" -rpcpassword="${BITCOIN_RPC_PASS}";
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 -rpcuser="${BITCOIN_RPC_USER}" -rpcpassword="${BITCOIN_RPC_PASS}";
else
exec "$BITCOIND" -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 -rpcuser="${BITCOIN_RPC_USER}" -rpcpassword="${BITCOIN_RPC_PASS}";
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 -rpcuser="${BITCOIN_RPC_USER}" -rpcpassword="${BITCOIN_RPC_PASS}";
fi
derived_env:
- key: DISK_GB

View File

@ -27,9 +27,9 @@ app:
exit 127;
fi;
if [ "${DISK_GB:-0}" -lt 1000 ]; then
exec "$BITCOIND" -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 -rpcuser="${BITCOIN_RPC_USER}" -rpcpassword="${BITCOIN_RPC_PASS}";
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 -rpcuser="${BITCOIN_RPC_USER}" -rpcpassword="${BITCOIN_RPC_PASS}";
else
exec "$BITCOIND" -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 -rpcuser="${BITCOIN_RPC_USER}" -rpcpassword="${BITCOIN_RPC_PASS}";
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 -rpcuser="${BITCOIN_RPC_USER}" -rpcpassword="${BITCOIN_RPC_PASS}";
fi
derived_env:
- key: DISK_GB

View File

@ -13,6 +13,9 @@ app:
secret_file: bitcoin-rpc-password
- key: BTCPAY_DB_PASS
secret_file: btcpay-db-password
derived_env:
- key: BTCPAY_HOST
template: "{{HOST_IP}}:23000"
dependencies:
- app_id: bitcoin-core
@ -46,7 +49,6 @@ app:
environment:
- ASPNETCORE_URLS=http://0.0.0.0:49392
- BTCPAY_PROTOCOL=http
- BTCPAY_HOST=127.0.0.1:23000
- BTCPAY_CHAINS=btc
- BTCPAY_BTCEXPLORERURL=http://archy-nbxplorer:32838
- BTCPAY_BTCRPCURL=http://bitcoin-knots:8332
@ -68,3 +70,12 @@ app:
lightning_integration:
payment_processing: false
invoice_management: true
interfaces:
main:
name: Web UI
description: BTCPay Server dashboard
type: ui
port: 23000
protocol: http
path: /

View File

@ -8,6 +8,7 @@ app:
image: git.tx1138.com/lfg2025/electrumx:v1.18.0
pull_policy: if-not-present
network: archy-net
data_uid: "1000:1000"
entrypoint: ["sh", "-lc"]
custom_args:
- >-
@ -18,7 +19,7 @@ app:
secret_file: bitcoin-rpc-password
dependencies:
- app_id: bitcoin-core
- app_id: bitcoin-knots
version: ">=26.0"
- storage: 50Gi
@ -58,3 +59,4 @@ app:
bitcoin_integration:
rpc_access: read-only
sync_required: true
pruning_support: false

View File

@ -12,16 +12,16 @@ app:
custom_args:
- >-
if [ -f /lnd/tls.cert ] && [ -f /lnd/data/chain/bitcoin/mainnet/admin.macaroon ]; then
exec gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash "$FEDI_HASH" --network bitcoin --bitcoind-url http://bitcoin-knots:8332 --bitcoind-username "$FM_BITCOIND_USERNAME" --bitcoind-password "$FM_BITCOIND_PASSWORD" lnd --lnd-rpc-host lnd:10009 --lnd-tls-cert /lnd/tls.cert --lnd-macaroon /lnd/data/chain/bitcoin/mainnet/admin.macaroon;
exec gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash "$FEDI_HASH" --network bitcoin --bitcoind-url http://host.archipelago:8332 --bitcoind-username "$FM_BITCOIND_USERNAME" --bitcoind-password "$FM_BITCOIND_PASSWORD" lnd --lnd-rpc-host lnd:10009 --lnd-tls-cert /lnd/tls.cert --lnd-macaroon /lnd/data/chain/bitcoin/mainnet/admin.macaroon;
else
exec gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash "$FEDI_HASH" --network bitcoin --bitcoind-url http://bitcoin-knots:8332 --bitcoind-username "$FM_BITCOIND_USERNAME" --bitcoind-password "$FM_BITCOIND_PASSWORD" ldk --ldk-lightning-port 9737 --ldk-alias archipelago-gateway;
exec gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash "$FEDI_HASH" --network bitcoin --bitcoind-url http://host.archipelago:8332 --bitcoind-username "$FM_BITCOIND_USERNAME" --bitcoind-password "$FM_BITCOIND_PASSWORD" ldk --ldk-lightning-port 9737 --ldk-alias archipelago-gateway;
fi
secret_env:
- key: FM_BITCOIND_PASSWORD
secret_file: bitcoin-rpc-password
- key: FEDI_HASH
secret_file: fedimint-gateway-hash
data_uid: "100000:100000"
data_uid: "1000:1000"
dependencies:
- app_id: bitcoin-core

View File

@ -16,7 +16,7 @@ app:
secret_env:
- key: FM_BITCOIND_PASSWORD
secret_file: bitcoin-rpc-password
data_uid: "100000:100000"
data_uid: "1000:1000"
dependencies:
- app_id: bitcoin-core
@ -52,7 +52,7 @@ app:
environment:
- FM_DATA_DIR=/data
- FM_BITCOIND_URL=http://bitcoin-knots:8332
- FM_BITCOIND_URL=http://host.archipelago:8332
- FM_BITCOIND_USERNAME=archipelago
- FM_BITCOIN_NETWORK=bitcoin
- FM_BIND_P2P=0.0.0.0:8173

View File

@ -29,9 +29,8 @@ environment:
GITEA__repository__ENABLE_PUSH_CREATE_USER: "true"
GITEA__repository__ENABLE_PUSH_CREATE_ORG: "true"
# Gitea hardcodes X-Frame-Options: SAMEORIGIN which blocks iframe embedding.
# Container binds to internal_port (3001), nginx proxies public port (3000)
# stripping the X-Frame-Options header so the app works in Archipelago's iframe.
# Gitea hardcodes X-Frame-Options: SAMEORIGIN, so Archipelago opens it in a
# new tab on host port 3001 instead of embedding it in an iframe.
nginx_proxy:
listen: 3000
proxy_pass: "http://127.0.0.1:3001"

View File

@ -27,7 +27,7 @@ app:
apparmor_profile: grafana
ports:
- host: 3001
- host: 3000
container: 3000
protocol: tcp # Web UI
@ -40,12 +40,12 @@ app:
environment:
- GF_SECURITY_ADMIN_USER=admin
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD}
- GF_SERVER_ROOT_URL=http://localhost:3001
- GF_SERVER_ROOT_URL=http://localhost:3000
- GF_INSTALL_PLUGINS=
health_check:
type: http
endpoint: http://localhost:3001
endpoint: http://localhost:3000
path: /api/health
interval: 30s
timeout: 5s

View File

@ -8,6 +8,7 @@ app:
container:
image: 146.59.87.168:3000/lfg2025/indeedhub:latest
pull_policy: always # Pull from registry; falls back to local build
network: indeedhub-net
dependencies:
- storage: 1Gi
@ -38,6 +39,12 @@ app:
- type: tmpfs
target: /app/.next/cache
options: [rw,noexec,nosuid,size=128m]
- type: tmpfs
target: /run
options: [rw,nosuid,nodev,size=16m]
- type: tmpfs
target: /var/cache/nginx
options: [rw,nosuid,nodev,size=32m]
environment:
- NODE_ENV=production

View File

@ -25,9 +25,9 @@ app:
network_policy: bridge
# Bridge networking via archy-net. Container nginx listens on 80;
# host nginx proxies /app/lnd/ -> 127.0.0.1:8081 -> container:80.
# host nginx proxies /app/lnd/ -> 127.0.0.1:18083 -> container:80.
ports:
- host: 8081
- host: 18083
container: 80
protocol: tcp
@ -37,7 +37,7 @@ app:
health_check:
type: http
endpoint: http://127.0.0.1:8081
endpoint: http://127.0.0.1:18083
path: /
interval: 30s
timeout: 5s

View File

@ -15,7 +15,7 @@ app:
secret_file: mempool-db-password
dependencies:
- app_id: bitcoin-core
- app_id: bitcoin-knots
version: ">=26.0"
- app_id: electrumx
version: ">=1.18.0"
@ -66,3 +66,4 @@ app:
bitcoin_integration:
rpc_access: read-only
sync_required: true
pruning_support: false

View File

@ -8,6 +8,7 @@ app:
image: scsibug/nostr-rs-relay:0.8.9
image_signature: cosign://...
pull_policy: verify-signature
data_uid: "1000:1000"
dependencies:
- storage: 10Gi # For event storage
@ -34,7 +35,7 @@ app:
volumes:
- type: bind
source: /var/lib/archipelago/nostr-relay
target: /app/db
target: /usr/src/app/db
options: [rw]
environment:

View File

@ -1,12 +1,11 @@
app:
id: searxng
name: SearXNG
version: 2024.1.0
version: latest
description: Privacy-respecting metasearch engine. Search the web without tracking.
container:
image: searxng/searxng:2024.1.0
image_signature: cosign://...
image: 146.59.87.168:3000/lfg2025/searxng:latest
pull_policy: if-not-present
dependencies:

2
core/Cargo.lock generated
View File

@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "archipelago"
version = "1.7.51-alpha"
version = "1.7.52-alpha"
dependencies = [
"anyhow",
"archipelago-container",

View File

@ -1,6 +1,6 @@
[package]
name = "archipelago"
version = "1.7.51-alpha"
version = "1.7.52-alpha"
edition = "2021"
description = "Archipelago Bitcoin Node OS - Native backend"
authors = ["Archipelago Team"]

View File

@ -379,13 +379,24 @@ impl RpcHandler {
// If app_id is provided, get health for that app.
if let Some(params) = params {
if let Some(app_id) = params.get("app_id").and_then(|v| v.as_str()) {
let health = orchestrator
.health(app_id)
.await
.context("Failed to get container health")?;
let mut last_err: Option<anyhow::Error> = None;
for candidate in status_app_id_candidates(app_id) {
match orchestrator.health(&candidate).await {
Ok(health) => return Ok(serde_json::json!({ app_id: health })),
Err(e) => last_err = Some(e),
}
}
for name in status_container_name_candidates(app_id) {
if let Some(health) = inspect_container_health_value(&name).await {
return Ok(serde_json::json!({ app_id: health }));
}
}
if let Some(e) = last_err {
return Err(e.context("Failed to get container health"));
}
return Err(anyhow::anyhow!("Failed to get container health"));
}
}
// Otherwise, get health for all containers.
let containers = orchestrator
@ -449,6 +460,14 @@ fn status_app_id_candidates(app_id: &str) -> Vec<String> {
push("mempool-electrs");
push("electrumx");
}
"mempool" | "mempool-web" => {
push("mempool");
push("archy-mempool-web");
}
"immich" => {
push("immich");
push("immich_server");
}
_ => push(app_id),
}
@ -469,6 +488,8 @@ fn status_container_name_candidates(app_id: &str) -> Vec<String> {
"lnd-ui" => push("archy-lnd-ui"),
"electrs-ui" => push("archy-electrs-ui"),
"electrs" | "mempool-electrs" => push("electrumx"),
"mempool" | "mempool-web" | "archy-mempool-web" => push("mempool"),
"immich" => push("immich_server"),
_ => {}
}
@ -511,3 +532,14 @@ async fn inspect_container_state_value(name: &str) -> Option<serde_json::Value>
"running": running,
}))
}
async fn inspect_container_health_value(name: &str) -> Option<String> {
let v = inspect_container_state_value(name).await?;
match v.get("state").and_then(|s| s.as_str()).unwrap_or("unknown") {
"running" => Some("healthy".to_string()),
"created" => Some("starting".to_string()),
"paused" => Some("paused".to_string()),
"exited" | "stopped" => Some("unhealthy".to_string()),
other => Some(format!("unknown:{other}")),
}
}

View File

@ -13,6 +13,7 @@ impl RpcHandler {
match method {
"echo" => self.handle_echo(params).await,
"server.echo" => self.handle_echo(params).await,
"server.get-state" => self.handle_server_get_state().await,
"health" => self.handle_health().await,
"auth.login" => self.handle_auth_login(params).await,
"auth.logout" => self.handle_auth_logout().await,
@ -530,6 +531,11 @@ impl RpcHandler {
Ok(serde_json::json!({ "message": "Hello from Archipelago!" }))
}
async fn handle_server_get_state(&self) -> Result<serde_json::Value> {
let (data, rev) = self.state_manager.get_snapshot().await;
Ok(serde_json::json!({ "data": data, "rev": rev }))
}
pub(super) async fn handle_health(&self) -> Result<serde_json::Value> {
let recovery_complete = crate::crash_recovery::is_recovery_complete();
let uptime = crate::crash_recovery::uptime_seconds();

View File

@ -309,16 +309,23 @@ pub(super) fn all_container_names(package_id: &str) -> Vec<String> {
let archy = format!("archy-{}", package_id);
match package_id {
// Bitcoin: multiple historical names
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => vec![
// Bitcoin variants share the UI but not the backend process. Keep
// backend names precise so stopping one implementation does not clear
// stop markers or issue podman operations for the other.
"bitcoin" | "bitcoin-knots" => vec![
"bitcoin-knots".into(),
"bitcoin".into(),
"bitcoin-core".into(),
"archy-bitcoin-knots".into(),
"archy-bitcoin".into(),
"bitcoin-ui".into(),
"archy-bitcoin-ui".into(),
],
"bitcoin-core" => vec![
"bitcoin-core".into(),
"archy-bitcoin-core".into(),
"bitcoin-ui".into(),
"archy-bitcoin-ui".into(),
],
// LND + UI
"lnd" => vec!["lnd".into(), "archy-lnd".into(), "archy-lnd-ui".into()],
// Electrumx: multiple aliases
@ -377,6 +384,15 @@ pub(super) fn all_container_names(package_id: &str) -> Vec<String> {
"penpot-exporter".into(),
"penpot-frontend".into(),
],
"indeedhub" => vec![
"indeedhub-postgres".into(),
"indeedhub-redis".into(),
"indeedhub-minio".into(),
"indeedhub-relay".into(),
"indeedhub-api".into(),
"indeedhub-ffmpeg".into(),
"indeedhub".into(),
],
"nostr-vpn" => vec![
"nostr-vpn".into(),
"archy-nostr-vpn".into(),
@ -411,6 +427,22 @@ pub(super) async fn get_containers_for_app(package_id: &str) -> Result<Vec<Strin
Ok(result)
}
#[cfg(test)]
mod tests {
use super::all_container_names;
#[test]
fn bitcoin_variant_container_names_are_precise() {
let core = all_container_names("bitcoin-core");
assert!(core.contains(&"bitcoin-core".to_string()));
assert!(!core.contains(&"bitcoin-knots".to_string()));
let knots = all_container_names("bitcoin-knots");
assert!(knots.contains(&"bitcoin-knots".to_string()));
assert!(!knots.contains(&"bitcoin-core".to_string()));
}
}
/// Get data directories to clean for an app.
/// Caller must validate package_id before calling.
pub(super) fn get_data_dirs_for_app(package_id: &str) -> Vec<String> {
@ -802,7 +834,11 @@ pub(super) async fn get_app_config(
vec!["/var/lib/archipelago/uptime-kuma:/app/data".to_string()],
vec!["TZ=UTC".to_string()],
None,
None,
Some(vec![
"--".to_string(),
"node".to_string(),
"server/server.js".to_string(),
]),
),
"tailscale" => (
vec!["8240:8240".to_string()],
@ -817,7 +853,7 @@ pub(super) async fn get_app_config(
Some(vec![
"sh".to_string(),
"-c".to_string(),
"tailscale web --listen 0.0.0.0:8240 & exec tailscaled".to_string(),
"tailscaled --tun=userspace-networking & sleep 2; tailscale web --listen 0.0.0.0:8240 & wait".to_string(),
]),
),
"fedimint" => (
@ -978,8 +1014,8 @@ pub(super) async fn get_app_config(
None,
)
}
// Gitea binds to 3001 internally. Nginx on port 3000 strips X-Frame-Options
// so Gitea works in Archipelago's iframe. See nginx-gitea-iframe.conf.
// Gitea listens on container port 3000 and is launched directly on
// host port 3001 because it blocks iframe embedding.
"gitea" => (
vec!["3001:3000".to_string(), "2222:22".to_string()],
vec![

View File

@ -1,5 +1,7 @@
use super::config::get_containers_for_app;
use anyhow::Result;
use crate::data_model::{PackageDataEntry, PackageState};
use anyhow::{Context, Result};
use std::collections::HashMap;
use tracing::info;
/// Names of container variants that represent a running Bitcoin node
@ -8,6 +10,13 @@ const BITCOIN_NAMES: &[&str] = &["bitcoin-knots", "bitcoin-core", "bitcoin"];
/// Names of container variants that represent a running Electrum indexer
const ELECTRUM_NAMES: &[&str] = &["electrumx", "mempool-electrs", "electrs"];
fn requires_unpruned_bitcoin(package_id: &str) -> bool {
matches!(
package_id,
"electrumx" | "mempool-electrs" | "electrs" | "mempool" | "mempool-web"
)
}
/// Snapshot of which dependency services are currently running.
pub(super) struct RunningDeps {
pub has_bitcoin: bool,
@ -15,14 +24,44 @@ pub(super) struct RunningDeps {
pub has_lnd: bool,
}
pub(super) fn detect_running_deps_from_package_data(
packages: &HashMap<String, PackageDataEntry>,
) -> RunningDeps {
let is_running = |names: &[&str]| {
names.iter().any(|name| {
packages
.get(*name)
.map(|pkg| pkg.state == PackageState::Running)
.unwrap_or(false)
})
};
RunningDeps {
has_bitcoin: is_running(BITCOIN_NAMES),
has_electrumx: is_running(ELECTRUM_NAMES),
has_lnd: is_running(&["lnd"]),
}
}
/// Query podman for currently running containers and return dependency status.
pub(super) async fn detect_running_deps() -> Result<RunningDeps> {
let dep_check = tokio::process::Command::new("podman")
let dep_check = tokio::time::timeout(
std::time::Duration::from_secs(30),
tokio::process::Command::new("podman")
.args(["ps", "--format", "{{.Names}}"])
.output()
.output(),
)
.await
.map_err(|_| anyhow::anyhow!("Timed out checking running containers"))?
.map_err(|e| anyhow::anyhow!("Failed to check running containers: {}", e))?;
if !dep_check.status.success() {
anyhow::bail!(
"Failed to check running containers: {}",
String::from_utf8_lossy(&dep_check.stderr).trim()
);
}
let running = String::from_utf8_lossy(&dep_check.stdout);
let is_running = |names: &[&str]| {
running.lines().any(|l| {
@ -76,6 +115,65 @@ pub(super) fn check_install_deps(package_id: &str, deps: &RunningDeps) -> Result
}
}
/// ElectrumX and Mempool's Electrum backend need historical blocks from an
/// unpruned node while building their indexes. A pruned Bitcoin node can be
/// running and RPC-reachable but still leave them stuck with closed ports.
pub(super) async fn check_bitcoin_pruning_compatibility(package_id: &str) -> Result<()> {
if !requires_unpruned_bitcoin(package_id) {
return Ok(());
}
let (rpc_user, rpc_pass) = crate::bitcoin_rpc::bitcoin_rpc_credentials().await;
let body = serde_json::json!({
"jsonrpc": "1.0",
"id": "package-install-prune-check",
"method": "getblockchaininfo",
"params": [],
});
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.context("building Bitcoin RPC client")?;
let resp = client
.post(crate::constants::BITCOIN_RPC_URL)
.basic_auth(rpc_user, Some(rpc_pass))
.header("Content-Type", "application/json")
.json(&body)
.send()
.await
.context("checking Bitcoin pruning status")?;
let status = resp.status();
let json: serde_json::Value = resp.json().await.context("decode Bitcoin RPC response")?;
if !status.is_success() {
anyhow::bail!(
"Bitcoin RPC returned {} while checking pruning status",
status
);
}
if let Some(error) = json.get("error").filter(|e| !e.is_null()) {
anyhow::bail!("Bitcoin RPC error while checking pruning status: {}", error);
}
let Some(result) = json.get("result") else {
anyhow::bail!("Bitcoin RPC response missing result while checking pruning status");
};
if result
.get("pruned")
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
anyhow::bail!(
"{} requires an unpruned Bitcoin node while indexing. Current Bitcoin is pruned; use a full node with enough disk for txindex/full block history, then reinstall/restart {}.",
package_id,
package_id
);
}
Ok(())
}
/// Log informational messages about optional dependencies.
pub(super) fn log_optional_dep_info(package_id: &str, deps: &RunningDeps) {
if matches!(package_id, "btcpay-server" | "btcpayserver") && !deps.has_lnd {
@ -129,6 +227,18 @@ pub(super) fn startup_order(package_id: &str) -> &'static [&'static str] {
"mempool",
],
"immich" => &["immich_postgres", "immich_redis", "immich_server"],
"indeedhub" => &[
"indeedhub-postgres",
"indeedhub-redis",
"indeedhub-minio",
"indeedhub-relay",
"indeedhub-api",
"indeedhub-ffmpeg",
"indeedhub",
],
"btcpay-server" | "btcpayserver" | "btcpay" => {
&["archy-btcpay-db", "archy-nbxplorer", "btcpay-server"]
}
"penpot" | "penpot-frontend" => &[
"penpot-postgres",
"penpot-valkey",
@ -211,3 +321,24 @@ pub(super) fn configure_fedimint_lnd(
]);
}
}
#[cfg(test)]
mod tests {
use super::requires_unpruned_bitcoin;
#[test]
fn unpruned_bitcoin_required_for_electrum_indexers_and_mempool() {
for package_id in [
"electrumx",
"mempool-electrs",
"electrs",
"mempool",
"mempool-web",
] {
assert!(requires_unpruned_bitcoin(package_id), "{package_id}");
}
for package_id in ["bitcoin-knots", "btcpay-server", "lnd", "fedimint"] {
assert!(!requires_unpruned_bitcoin(package_id), "{package_id}");
}
}
}

View File

@ -3,8 +3,9 @@ use super::config::{
is_readonly_compatible, is_valid_docker_image,
};
use super::dependencies::{
check_install_deps, configure_fedimint_lnd, detect_running_deps, log_optional_dep_info,
needs_archy_net,
check_bitcoin_pruning_compatibility, check_install_deps, configure_fedimint_lnd,
detect_running_deps, detect_running_deps_from_package_data, log_optional_dep_info,
needs_archy_net, RunningDeps,
};
use super::progress::parse_pull_progress;
use super::validation::validate_app_id;
@ -32,6 +33,130 @@ pub(in crate::api::rpc) async fn install_log(msg: &str) {
}
}
pub(super) async fn patch_indeedhub_nostr_provider() {
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
let _ = tokio::process::Command::new("podman")
.args([
"exec",
"indeedhub",
"sed",
"-i",
"/X-Frame-Options/d",
"/etc/nginx/conf.d/default.conf",
])
.output()
.await;
let provider_src = "/opt/archipelago/web-ui/nostr-provider.js";
if tokio::fs::metadata(provider_src).await.is_ok() {
let _ = tokio::process::Command::new("podman")
.args([
"cp",
provider_src,
"indeedhub:/usr/share/nginx/html/nostr-provider.js",
])
.output()
.await;
}
let check = tokio::process::Command::new("podman")
.args([
"exec",
"indeedhub",
"grep",
"-q",
"nostr-provider",
"/etc/nginx/conf.d/default.conf",
])
.output()
.await;
let already_patched = check.map(|o| o.status.success()).unwrap_or(false);
if !already_patched {
let cat_out = tokio::process::Command::new("podman")
.args(["exec", "indeedhub", "cat", "/etc/nginx/conf.d/default.conf"])
.output()
.await;
if let Ok(out) = cat_out {
if out.status.success() {
let conf = String::from_utf8_lossy(&out.stdout).to_string();
let conf = conf.replace(
"location = /sw.js {",
"location = /nostr-provider.js {\n\
add_header Cache-Control \"no-cache, no-store, must-revalidate\";\n\
expires off;\n\
}\n\n\
location = /sw.js {",
);
let conf = if conf.contains("try_files") && !conf.contains("sub_filter") {
conf.replacen(
"try_files $uri $uri/ /index.html;",
"try_files $uri $uri/ /index.html;\n\
sub_filter_once on;\n\
sub_filter '</head>' '<script src=\"/nostr-provider.js\"></script></head>';",
1,
)
} else {
conf
};
let tmp_path = "/tmp/indeedhub-nginx-patch.conf";
if tokio::fs::write(tmp_path, &conf).await.is_ok() {
let _ = tokio::process::Command::new("podman")
.args(["cp", tmp_path, "indeedhub:/etc/nginx/conf.d/default.conf"])
.output()
.await;
let _ = tokio::fs::remove_file(tmp_path).await;
}
}
}
}
let _ = tokio::process::Command::new("podman")
.args([
"exec",
"indeedhub",
"sed",
"-i",
"s|proxy_set_header X-Forwarded-Prefix /api;|proxy_set_header X-Forwarded-Prefix $http_x_forwarded_prefix/api;|",
"/etc/nginx/conf.d/default.conf",
])
.output()
.await;
let reload = tokio::process::Command::new("podman")
.args(["exec", "indeedhub", "nginx", "-s", "reload"])
.output()
.await;
match reload {
Ok(o) if o.status.success() => {
info!("IndeeHub: NIP-07 provider injected, nginx patched and reloaded");
}
Ok(o) => {
tracing::warn!(
"IndeeHub nginx reload failed: {}",
String::from_utf8_lossy(&o.stderr)
);
}
Err(e) => {
tracing::warn!("IndeeHub nginx reload error: {}", e);
}
}
}
fn dependency_cache_satisfies(package_id: &str, deps: &RunningDeps) -> bool {
match package_id {
"electrumx" | "mempool-electrs" | "electrs" | "lnd" | "btcpay-server" | "btcpayserver" => {
deps.has_bitcoin
}
"mempool" | "mempool-web" => deps.has_bitcoin && deps.has_electrumx,
"fedimint" => true,
_ => true,
}
}
impl RpcHandler {
/// Install a package from a Docker image.
/// Security: Image verification, resource limits, network isolation.
@ -62,6 +187,8 @@ impl RpcHandler {
package_id, docker_image
);
cleanup_stale_package_ports(package_id).await;
if !is_valid_docker_image(docker_image) {
install_log(&format!(
"INSTALL FAIL: {} — invalid image format",
@ -108,11 +235,22 @@ impl RpcHandler {
return self.install_indeedhub_stack().await;
}
// Dependency checks
let deps = detect_running_deps().await?;
// Dependency checks. Prefer the scanner's cached package state so a
// congested Podman API does not turn an already-running dependency into
// a false install failure. Fall back to a bounded direct Podman probe
// only when the cache does not show the dependency.
let deps = {
let (data, _) = self.state_manager.get_snapshot().await;
let cached = detect_running_deps_from_package_data(&data.package_data);
if dependency_cache_satisfies(package_id, &cached) {
cached
} else {
detect_running_deps().await?
}
};
check_install_deps(package_id, &deps)?;
check_bitcoin_pruning_compatibility(package_id).await?;
log_optional_dep_info(package_id, &deps);
check_bitcoin_implementation_conflict(package_id).await?;
let repaired_bitcoin_conf =
if matches!(package_id, "bitcoin" | "bitcoin-core" | "bitcoin-knots") {
// Materialise the RPC password file before any install path
@ -243,6 +381,7 @@ impl RpcHandler {
package_id
))
.await;
ensure_host_port_listener(package_id, package_id).await?;
return Ok(serde_json::json!({
"success": true,
"package_id": package_id,
@ -268,6 +407,8 @@ impl RpcHandler {
Ok(container_name) => {
self.set_install_phase(package_id, InstallPhase::WaitingHealthy)
.await;
crate::api::rpc::package::runtime::reconcile_companions_for(package_id)
.await;
install_log(&format!(
"INSTALL ORCH OK: {} (app={}) — container={}",
package_id, orchestrator_app_id, container_name
@ -368,17 +509,15 @@ impl RpcHandler {
"--restart=unless-stopped",
];
let is_tailscale = package_id == "tailscale";
// Explicit DNS alias for aardvark-dns (must outlive run_args)
let network_alias_flag = format!("--network-alias={}", container_name);
// Network mode
if is_tailscale {
run_args.push("--network=host");
run_args.push("--privileged");
run_args.push("--cap-add=NET_ADMIN");
run_args.push("--cap-add=NET_RAW");
run_args.push("--device=/dev/net/tun");
if package_id == "uptime-kuma" || package_id == "gitea" || package_id == "tailscale" {
// These standalone web UIs have repeatedly lost host listeners
// under Podman's rootless pasta backend while staying healthy internally.
// Use slirp4netns/rootlessport for this standalone web UI.
run_args.push("--network=slirp4netns:allow_host_loopback=true");
} else if needs_archy_net(package_id) {
// Create archy-net if it doesn't exist (idempotent — "already exists" is fine)
match tokio::process::Command::new("podman")
@ -420,14 +559,9 @@ impl RpcHandler {
run_args.push(&host_gateway_flag);
// Security hardening (skip for privileged containers)
let security_caps: Vec<String> = if !is_tailscale {
get_app_capabilities(package_id)
} else {
vec![]
};
let readonly_compatible = !is_tailscale && is_readonly_compatible(package_id);
let security_caps: Vec<String> = get_app_capabilities(package_id);
let readonly_compatible = is_readonly_compatible(package_id);
if !is_tailscale {
run_args.push("--cap-drop=ALL");
run_args.push("--security-opt=no-new-privileges:true");
run_args.push("--pids-limit=4096");
@ -444,7 +578,6 @@ impl RpcHandler {
if package_id == "jellyfin" {
run_args.push("--tmpfs=/tmp:rw,exec,size=256m");
}
}
// Create data directories (mkdir only — chown happens AFTER config files are written)
for volume in &volumes {
@ -490,13 +623,10 @@ impl RpcHandler {
// NOW chown data directories to container UID (after all config files are written)
self.create_data_dirs(package_id, &volumes).await;
// Port mappings (skip for host-network containers)
if !is_tailscale {
for port in &ports {
run_args.push("-p");
run_args.push(port);
}
}
// Volume mounts
for volume in &volumes {
@ -570,7 +700,14 @@ impl RpcHandler {
cmd.args(args);
}
let run_output = cmd.output().await.context("Failed to run container")?;
let mut run_output = cmd.output().await.context("Failed to run container")?;
if !run_output.status.success() {
let stderr = String::from_utf8_lossy(&run_output.stderr).to_string();
if cleanup_start_conflict(package_id, &stderr).await {
run_output = cmd.output().await.context("Failed to rerun container")?;
}
}
if !run_output.status.success() {
let stderr = String::from_utf8_lossy(&run_output.stderr);
@ -680,6 +817,12 @@ impl RpcHandler {
// Post-install hooks — await completion before returning success
self.run_post_install_hooks(package_id).await;
if package_id == "nextcloud" {
repair_nextcloud_permissions().await;
}
ensure_host_port_listener(package_id, container_name).await?;
install_log(&format!(
"INSTALL OK: {} (container: {})",
package_id,
@ -744,36 +887,16 @@ impl RpcHandler {
Ok(has_local_fallback)
}
/// Pull image with retry and exponential backoff (3 attempts: 5s, 15s, 45s).
/// Pull image once through the configured registry list. Each registry URL
/// already has a bounded timeout, so retrying the full list can leave the UI
/// in Installing for close to an hour when a large image is unavailable or
/// a registry stalls.
async fn pull_image_with_progress(&self, package_id: &str, docker_image: &str) -> Result<()> {
const MAX_ATTEMPTS: u32 = 3;
const BACKOFF_SECS: [u64; 3] = [5, 15, 45];
for attempt in 1..=MAX_ATTEMPTS {
match self.do_pull_image(package_id, docker_image).await {
Ok(()) => return Ok(()),
Err(e) if attempt < MAX_ATTEMPTS => {
let delay = BACKOFF_SECS[(attempt - 1) as usize];
tracing::warn!(
"Image pull failed for {} (attempt {}/{}): {}. Retrying in {}s...",
docker_image,
attempt,
MAX_ATTEMPTS,
e,
delay
);
tokio::time::sleep(std::time::Duration::from_secs(delay)).await;
}
Err(e) => {
if let Err(e) = self.do_pull_image(package_id, docker_image).await {
self.clear_install_progress(package_id).await;
return Err(e.context(format!(
"Failed to pull {} after {} attempts",
docker_image, MAX_ATTEMPTS
)));
return Err(e.context(format!("Failed to pull {}", docker_image)));
}
}
}
unreachable!()
Ok(())
}
/// Pull one image URL with live progress streamed through
@ -799,10 +922,13 @@ impl RpcHandler {
.spawn()
.context("Failed to start image pull")?;
// 10-minute per-URL budget — large layers (Minio, Postgres,
// ffmpeg) regularly take several minutes and we'd rather wait
// than bounce to the next mirror mid-download.
let pull_result = tokio::time::timeout(std::time::Duration::from_secs(600), async {
// 5-minute per-URL budget. A full install tries each configured mirror
// once, so a two-registry setup fails visibly in roughly 10 minutes
// instead of staying in Installing for up to an hour.
const PULL_URL_TIMEOUT_SECS: u64 = 300;
let pull_result = tokio::time::timeout(
std::time::Duration::from_secs(PULL_URL_TIMEOUT_SECS),
async {
if let Some(stderr) = child.stderr.take() {
let reader = BufReader::new(stderr);
let mut lines = reader.lines();
@ -811,12 +937,14 @@ impl RpcHandler {
while let Ok(Some(line)) = lines.next_line().await {
if let Some((downloaded, total)) = parse_pull_progress(&line) {
Self::update_install_progress(&state_mgr, &pkg_id, downloaded, total).await;
Self::update_install_progress(&state_mgr, &pkg_id, downloaded, total)
.await;
}
}
}
child.wait().await
})
},
)
.await;
match pull_result {
@ -826,7 +954,11 @@ impl RpcHandler {
Ok(false)
}
Err(_) => {
tracing::warn!("Image pull timed out after 600s: {}", url);
tracing::warn!(
"Image pull timed out after {}s: {}",
PULL_URL_TIMEOUT_SECS,
url
);
let _ = child.kill().await;
let _ = child.wait().await; // reap zombie
Ok(false)
@ -963,7 +1095,7 @@ impl RpcHandler {
// Current BTCPay Postgres image runs as uid 999 inside the
// container, so its rootless host-mapped uid is 100998.
"btcpay-postgres" | "archy-btcpay-db" => 999,
"electrumx" | "electrs" => 1000,
"electrumx" | "electrs" => 0,
_ => 0, // Most containers run as root (UID 0)
};
if container_uid == 0 {
@ -1392,18 +1524,18 @@ autopilot.active=false\n",
}
}
// Gitea: keep it on its native host port (3001) and serve it under
// /app/gitea/ via the main Archipelago nginx config. Avoids colliding
// with Grafana, which also uses host port 3000.
// Gitea: keep it on its native host port (3001). The UI opens Gitea
// in a new tab on that direct port so absolute asset URLs must be
// rooted at the host port rather than Archipelago's /app/gitea/ path.
if package_id == "gitea" {
let _ = tokio::fs::remove_file("/etc/nginx/conf.d/gitea-iframe.conf").await;
// Set ROOT_URL to the UI path-based route so links/assets stay
// anchored under Archipelago's app proxy endpoint.
// Set ROOT_URL to the direct launch route so links/assets stay
// anchored under the same origin Gitea is launched from.
let host_ip = &self.config.host_ip;
let _ = tokio::process::Command::new("podman")
.args(["exec", "gitea", "sh", "-c",
&format!("grep -q ROOT_URL /data/gitea/conf/app.ini && sed -i 's|ROOT_URL.*|ROOT_URL = http://{}/app/gitea/|' /data/gitea/conf/app.ini || true", host_ip)])
&format!("grep -q ROOT_URL /data/gitea/conf/app.ini && sed -i 's|ROOT_URL.*|ROOT_URL = http://{}:3001/|' /data/gitea/conf/app.ini || true", host_ip)])
.output()
.await;
// Also ensure X_FRAME_OPTIONS is empty so Gitea doesn't send the header
@ -1413,14 +1545,8 @@ autopilot.active=false\n",
.output()
.await;
// Reload main nginx so /app/gitea/ routing changes take effect.
let _ = tokio::process::Command::new("nginx")
.args(["-s", "reload"])
.output()
.await;
info!(
"Gitea: ROOT_URL set to http://{}/app/gitea/, X_FRAME_OPTIONS cleared",
"Gitea: ROOT_URL set to http://{}:3001/, X_FRAME_OPTIONS cleared",
host_ip
);
}
@ -1661,6 +1787,159 @@ autopilot.active=false\n",
}
}
async fn cleanup_stale_package_ports(package_id: &str) {
match package_id {
"grafana" => cleanup_stale_pasta_port("3000").await,
"searxng" => cleanup_stale_pasta_port("8888").await,
"uptime-kuma" => cleanup_stale_pasta_port("3002").await,
"gitea" => {
cleanup_stale_pasta_port("3001").await;
cleanup_stale_pasta_port("2222").await;
cleanup_stale_pasta_port("3000").await;
}
_ => {}
}
}
async fn cleanup_start_conflict(package_id: &str, stderr: &str) -> bool {
match package_id {
"grafana"
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
{
cleanup_stale_pasta_port("3000").await;
true
}
"searxng"
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
{
cleanup_stale_pasta_port("8888").await;
true
}
"uptime-kuma"
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
{
cleanup_stale_pasta_port("3002").await;
true
}
"gitea" if stderr.contains("pasta failed") || stderr.contains("address already in use") => {
cleanup_stale_pasta_port("3001").await;
cleanup_stale_pasta_port("2222").await;
cleanup_stale_pasta_port("3000").await;
true
}
_ => false,
}
}
async fn cleanup_stale_pasta_port(port: &str) {
let kill_listener = format!(
"ss -ltnp 'sport = :{}' 2>/dev/null | sed -n 's/.*pid=\\([0-9]*\\).*/\\1/p' | xargs -r kill 2>/dev/null || true",
port
);
let _ = tokio::process::Command::new("sh")
.args(["-c", &kill_listener])
.output()
.await;
let pattern = format!("pasta.*{}", port);
let _ = tokio::process::Command::new("pkill")
.args(["-f", &pattern])
.output()
.await;
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
async fn repair_nextcloud_permissions() {
let script = "chmod 755 /var/www/html /var/www/html/config /var/www/html/data 2>/dev/null || true; chmod 644 /var/www/html/.htaccess /var/www/html/index.php /var/www/html/status.php /var/www/html/config/.htaccess 2>/dev/null || true; chmod 640 /var/www/html/config/config.php 2>/dev/null || true";
let output = tokio::process::Command::new("podman")
.args(["exec", "nextcloud", "sh", "-lc", script])
.output()
.await;
match output {
Ok(out) if out.status.success() => {}
Ok(out) => {
let stderr = String::from_utf8_lossy(&out.stderr);
tracing::warn!("Nextcloud permission repair failed: {}", stderr.trim());
}
Err(err) => tracing::warn!("Failed to run Nextcloud permission repair: {}", err),
}
}
async fn ensure_host_port_listener(package_id: &str, container_name: &str) -> Result<()> {
let Some(port) = required_host_port(package_id) else {
return Ok(());
};
if wait_for_host_port(port, 10).await {
return Ok(());
}
install_log(&format!(
"INSTALL REPAIR: {} — host port {} missing after start; restarting container",
package_id, port
))
.await;
cleanup_stale_package_ports(package_id).await;
let output = tokio::process::Command::new("podman")
.args(["restart", container_name])
.output()
.await
.context("failed to restart container after missing host port")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow::anyhow!(
"Container {} host port {} was not listening and restart failed: {}",
container_name,
port,
stderr.trim()
));
}
if wait_for_host_port(port, 60).await {
install_log(&format!(
"INSTALL REPAIR OK: {} — host port {} is listening after restart",
package_id, port
))
.await;
return Ok(());
}
Err(anyhow::anyhow!(
"Container {} is running but host port {} is not listening",
container_name,
port
))
}
fn required_host_port(package_id: &str) -> Option<u16> {
match package_id {
"grafana" => Some(3000),
"searxng" => Some(8888),
"uptime-kuma" => Some(3002),
"gitea" => Some(3001),
_ => None,
}
}
async fn wait_for_host_port(port: u16, timeout_secs: u64) -> bool {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
loop {
if tokio::net::TcpStream::connect(("127.0.0.1", port))
.await
.is_ok()
{
return true;
}
if std::time::Instant::now() >= deadline {
return false;
}
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
}
/// Resolve the host gateway IP for --add-host flag.
/// Resolve the default gateway IP from the routing table for --add-host flag.
/// Explicit IP avoids issues with "host-gateway" in rootless Podman.
@ -1792,73 +2071,6 @@ fn should_try_orchestrator_install(package_id: &str, orchestrator_available: boo
orchestrator_available && uses_orchestrator_install_flow(package_id)
}
async fn check_bitcoin_implementation_conflict(package_id: &str) -> Result<()> {
let other = match package_id {
"bitcoin-core" => "bitcoin-knots",
"bitcoin-knots" => "bitcoin-core",
_ => return Ok(()),
};
// Three cases for the OTHER variant:
// - missing → no conflict, continue
// - running → real conflict, refuse install
// - any other state (created/exited/configured/...) → stuck from a
// prior failed install. Auto-remove so reinstall is reachable
// without a manual `podman rm`. This is what unblocks the .198
// "bitcoin-core stuck in created, port 8332 held by bitcoin-knots"
// deadlock that no UI path could exit.
let inspect = tokio::process::Command::new("podman")
.args(["inspect", other, "--format", "{{.State.Status}}"])
.output()
.await
.context("Failed to inspect conflicting Bitcoin container")?;
if !inspect.status.success() {
return Ok(());
}
let state = String::from_utf8_lossy(&inspect.stdout).trim().to_string();
if state == "running" {
let current = pretty_bitcoin_name(other);
let requested = pretty_bitcoin_name(package_id);
return Err(anyhow::anyhow!(
"{} is currently running. Stop and uninstall {} before installing {}; both implementations use the same Bitcoin data directory and ports.",
current, current, requested
));
}
info!(
"Removing stuck {} container (state={}) before installing {}",
other, state, package_id
);
install_log(&format!(
"INSTALL UNSTUCK: removing {} (state={}) before installing {}",
other, state, package_id
))
.await;
let rm = tokio::process::Command::new("podman")
.args(["rm", "-f", other])
.output()
.await
.context("Failed to remove stuck Bitcoin container")?;
if !rm.status.success() {
let stderr = String::from_utf8_lossy(&rm.stderr);
return Err(anyhow::anyhow!(
"Failed to remove stuck {} container: {}",
other,
stderr.trim()
));
}
Ok(())
}
fn pretty_bitcoin_name(id: &str) -> &'static str {
match id {
"bitcoin-core" => "Bitcoin Core",
"bitcoin-knots" => "Bitcoin Knots",
_ => "another Bitcoin node",
}
}
fn orchestrator_install_app_id(package_id: &str) -> &str {
match package_id {
"electrs" | "mempool-electrs" => "electrumx",
@ -1903,6 +2115,7 @@ mod tests {
orchestrator_install_app_id, should_try_orchestrator_install,
uses_orchestrator_install_flow,
};
use crate::api::rpc::package::runtime::orchestrator_uninstall_app_ids;
#[test]
fn orchestrator_install_allowlist_includes_ported_backends() {
@ -1955,4 +2168,44 @@ mod tests {
assert_eq!(orchestrator_install_app_id("mempool-electrs"), "electrumx");
assert_eq!(orchestrator_install_app_id("lnd"), "lnd");
}
#[test]
fn uninstall_aliases_map_to_exact_manifest_app_ids() {
assert_eq!(
orchestrator_uninstall_app_ids("bitcoin-knots"),
vec!["bitcoin-knots", "bitcoin-ui"]
);
assert_eq!(
orchestrator_uninstall_app_ids("electrs"),
vec!["electrumx", "electrs-ui"]
);
assert_eq!(
orchestrator_uninstall_app_ids("btcpay-server"),
vec!["btcpay-server", "archy-nbxplorer", "archy-btcpay-db"]
);
}
#[tokio::test]
async fn companion_reconcile_aliases_include_ui_app_ids() {
use crate::api::rpc::package::runtime::reconcile_companions_for;
// Smoke only: unknown/non-companion apps are a no-op. Full companion
// behavior is covered in container::companion tests; this guards that
// the helper remains callable from install/start/restart paths.
reconcile_companions_for("filebrowser").await;
}
#[test]
fn missing_companion_is_ok_only_for_known_ui_companions() {
use crate::api::rpc::package::runtime::is_missing_companion_ok;
assert!(is_missing_companion_ok(
"archy-bitcoin-ui",
"Error: no container with name or ID \"archy-bitcoin-ui\" found"
));
assert!(!is_missing_companion_ok(
"bitcoin-knots",
"Error: no container with name or ID \"bitcoin-knots\" found"
));
}
}

View File

@ -59,6 +59,8 @@ impl RpcHandler {
}
let package_id_owned = package_id.to_string();
let companion_app_id = package_id_owned.clone();
let orchestrator = self.orchestrator.clone();
let state_manager = Arc::clone(&self.state_manager);
let pre_state =
flip_package_state(&state_manager, &package_id_owned, PackageState::Starting).await;
@ -70,8 +72,14 @@ impl RpcHandler {
.await;
tokio::spawn(async move {
match do_package_start(&to_start).await {
let result = if let Some(orchestrator) = orchestrator.as_ref() {
do_orchestrator_package_start(orchestrator.as_ref(), &to_start).await
} else {
do_package_start(&to_start).await
};
match result {
Ok(()) => {
reconcile_companions_for(&companion_app_id).await;
set_package_state(&state_manager, &package_id_owned, PackageState::Running)
.await;
}
@ -123,6 +131,8 @@ impl RpcHandler {
}
let package_id_owned = package_id.to_string();
let to_stop = containers.clone();
let orchestrator = self.orchestrator.clone();
let state_manager = Arc::clone(&self.state_manager);
let pre_state =
flip_package_state(&state_manager, &package_id_owned, PackageState::Stopping).await;
@ -134,7 +144,12 @@ impl RpcHandler {
.await;
tokio::spawn(async move {
match do_package_stop(&containers).await {
let result = if let Some(orchestrator) = orchestrator.as_ref() {
do_orchestrator_package_stop(orchestrator.as_ref(), &to_stop).await
} else {
do_package_stop(&containers).await
};
match result {
Ok(()) => {
set_package_state(&state_manager, &package_id_owned, PackageState::Stopped)
.await;
@ -182,7 +197,10 @@ impl RpcHandler {
}
let package_id_owned = package_id.to_string();
let companion_app_id = package_id_owned.clone();
let to_restart = ordered_containers_for_start(package_id).await?;
let state_manager = Arc::clone(&self.state_manager);
let orchestrator = self.orchestrator.clone();
let pre_state =
flip_package_state(&state_manager, &package_id_owned, PackageState::Restarting).await;
@ -193,8 +211,14 @@ impl RpcHandler {
.await;
tokio::spawn(async move {
match do_package_restart(&containers).await {
let result = if let Some(orchestrator) = orchestrator.as_ref() {
do_orchestrator_package_restart(orchestrator.as_ref(), &to_restart).await
} else {
do_package_restart(&containers).await
};
match result {
Ok(()) => {
reconcile_companions_for(&companion_app_id).await;
set_package_state(&state_manager, &package_id_owned, PackageState::Running)
.await;
}
@ -232,6 +256,15 @@ impl RpcHandler {
// within ~10s of `podman rm`, leaving them orphaned post-uninstall.
crate::container::companion::remove_for(package_id).await;
// Keep the production reconciler from recreating an app immediately
// after uninstall. The reconciler owns a manifest map independent of
// podman state, so a raw `podman rm` alone is not enough.
if let Some(orchestrator) = &self.orchestrator {
for app_id in orchestrator_uninstall_app_ids(package_id) {
let _ = orchestrator.remove(&app_id, preserve_data).await;
}
}
let containers_to_remove = get_containers_for_app(package_id).await?;
if containers_to_remove.is_empty() {
tracing::warn!("Uninstall {}: no containers found", package_id);
@ -576,6 +609,7 @@ async fn do_package_start(to_start: &[String]) -> Result<()> {
if i > 0 {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
}
repair_before_package_start(name).await;
tracing::info!("Starting container: {}", name);
let out = tokio::process::Command::new("podman")
.args(["start", name])
@ -585,6 +619,7 @@ async fn do_package_start(to_start: &[String]) -> Result<()> {
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
tracing::error!("Failed to start {}: {}", name, stderr);
cleanup_start_conflict(name, &stderr).await;
install_log(&format!("START FAIL: {}{}", name, stderr)).await;
errors.push(format!("{}: {}", name, stderr));
}
@ -630,9 +665,86 @@ async fn do_package_start(to_start: &[String]) -> Result<()> {
errors.join("; ")
));
}
for name in to_start {
ensure_runtime_host_port_listener(name).await?;
}
Ok(())
}
async fn do_orchestrator_package_start(
orchestrator: &dyn crate::container::traits::ContainerOrchestrator,
to_start: &[String],
) -> Result<()> {
let mut errors = Vec::new();
for (i, name) in to_start.iter().enumerate() {
if i > 0 {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
}
match orchestrator.start(name).await {
Ok(()) => wait_after_orchestrator_start(name).await,
Err(e) if is_unknown_app_id_error(&e) => {
do_package_start(&[name.clone()]).await?;
}
Err(e) => {
tracing::error!(container = %name, error = %e, "orchestrator start failed");
install_log(&format!("START FAIL: {}{:#}", name, e)).await;
errors.push(format!("{}: {:#}", name, e));
}
}
}
if errors.is_empty() {
Ok(())
} else {
Err(anyhow::anyhow!("Start failed: {}", errors.join("; ")))
}
}
async fn wait_after_orchestrator_start(name: &str) {
let delay = match name {
"archy-btcpay-db" => 5,
"archy-nbxplorer" => 8,
_ => 0,
};
if delay > 0 {
tokio::time::sleep(std::time::Duration::from_secs(delay)).await;
}
}
async fn do_orchestrator_package_stop(
orchestrator: &dyn crate::container::traits::ContainerOrchestrator,
containers: &[String],
) -> Result<()> {
let mut errors = Vec::new();
for name in containers.iter().rev() {
match orchestrator.stop(name).await {
Ok(()) => {}
Err(e) if is_unknown_app_id_error(&e) => {
if let Err(e) = do_package_stop(&[name.clone()]).await {
errors.push(format!("{}: {:#}", name, e));
}
}
Err(e) => {
tracing::error!(container = %name, error = %e, "orchestrator stop failed");
errors.push(format!("{}: {:#}", name, e));
}
}
}
if errors.is_empty() {
Ok(())
} else {
Err(anyhow::anyhow!("Stop failed: {}", errors.join("; ")))
}
}
async fn do_orchestrator_package_restart(
orchestrator: &dyn crate::container::traits::ContainerOrchestrator,
to_restart: &[String],
) -> Result<()> {
do_orchestrator_package_stop(orchestrator, to_restart).await?;
do_orchestrator_package_start(orchestrator, to_restart).await
}
/// Stop all containers with their per-container graceful-shutdown timeout.
async fn do_package_stop(containers: &[String]) -> Result<()> {
let mut errors = Vec::new();
@ -649,6 +761,10 @@ async fn do_package_stop(containers: &[String]) -> Result<()> {
.context(format!("Failed to exec podman stop {}", name))?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
if is_missing_companion_ok(name, &stderr) {
tracing::debug!(container = %name, "companion already absent during stop");
continue;
}
tracing::error!("Failed to stop {}: {}", name, stderr);
errors.push(format!("{}: {}", name, stderr));
}
@ -665,6 +781,7 @@ async fn do_package_restart(containers: &[String]) -> Result<()> {
let mut errors = Vec::new();
for name in containers {
tracing::info!("Restarting container: {}", name);
repair_before_package_start(name).await;
let out = tokio::process::Command::new("podman")
.args(["restart", "-t", stop_timeout_secs(name), name])
.output()
@ -673,6 +790,10 @@ async fn do_package_restart(containers: &[String]) -> Result<()> {
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
if is_missing_companion_ok(name, &stderr) {
tracing::debug!(container = %name, "companion absent during restart; reconcile will recreate it");
continue;
}
tracing::warn!(
"podman restart {} failed: {}, trying stop+start",
name,
@ -692,12 +813,18 @@ async fn do_package_restart(containers: &[String]) -> Result<()> {
let start_err = String::from_utf8_lossy(&start_out.stderr)
.trim()
.to_string();
cleanup_start_conflict(name, &start_err).await;
if is_missing_companion_ok(name, &start_err) {
tracing::debug!(container = %name, "companion absent during restart fallback; reconcile will recreate it");
continue;
}
tracing::error!("stop+start {} also failed: {}", name, start_err);
errors.push(format!("{}: {}", name, start_err));
} else {
tracing::info!("Restarted {} via stop+start fallback", name);
}
}
ensure_runtime_host_port_listener(name).await?;
}
if !errors.is_empty() {
return Err(anyhow::anyhow!("Restart failed: {}", errors.join("; ")));
@ -705,6 +832,239 @@ async fn do_package_restart(containers: &[String]) -> Result<()> {
Ok(())
}
fn is_unknown_app_id_error(err: &anyhow::Error) -> bool {
err.chain()
.any(|cause| cause.to_string().contains("unknown app_id"))
}
async fn repair_before_package_start(container_name: &str) {
match container_name {
"btcpay-server" | "archy-nbxplorer" => repair_btcpay_dirs().await,
"indeedhub-postgres" | "indeedhub-redis" | "indeedhub-minio" | "indeedhub-relay"
| "indeedhub-api" | "indeedhub-ffmpeg" | "indeedhub" => repair_indeedhub_network().await,
"grafana" => cleanup_stale_pasta_port("3000").await,
"gitea" => cleanup_gitea_stale_ports().await,
_ => {}
}
}
async fn ensure_runtime_host_port_listener(container_name: &str) -> Result<()> {
let Some(port) = runtime_required_host_port(container_name) else {
return Ok(());
};
if wait_for_runtime_host_port(port, 10).await {
return Ok(());
}
install_log(&format!(
"START REPAIR: {} — host port {} missing after start; restarting container",
container_name, port
))
.await;
let output = tokio::process::Command::new("podman")
.args(["restart", container_name])
.output()
.await
.context("failed to restart container after missing host port")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow::anyhow!(
"Container {} host port {} was not listening and restart failed: {}",
container_name,
port,
stderr.trim()
));
}
if wait_for_runtime_host_port(port, 60).await {
install_log(&format!(
"START REPAIR OK: {} — host port {} is listening after restart",
container_name, port
))
.await;
return Ok(());
}
Err(anyhow::anyhow!(
"Container {} is running but host port {} is not listening",
container_name,
port
))
}
fn runtime_required_host_port(container_name: &str) -> Option<u16> {
match container_name {
"grafana" => Some(3000),
"searxng" => Some(8888),
"uptime-kuma" => Some(3002),
"gitea" => Some(3001),
_ => None,
}
}
async fn wait_for_runtime_host_port(port: u16, timeout_secs: u64) -> bool {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
loop {
if tokio::net::TcpStream::connect(("127.0.0.1", port))
.await
.is_ok()
{
return true;
}
if std::time::Instant::now() >= deadline {
return false;
}
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
}
async fn repair_btcpay_dirs() {
let _ = tokio::process::Command::new("sudo")
.args([
"mkdir",
"-p",
"/var/lib/archipelago/btcpay/Main",
"/var/lib/archipelago/nbxplorer/Main",
])
.output()
.await;
for dir in [
"/var/lib/archipelago/btcpay",
"/var/lib/archipelago/nbxplorer",
] {
let _ = tokio::process::Command::new("sudo")
.args(["chown", "-R", "1000:1000", dir])
.output()
.await;
}
repair_btcpay_database_password().await;
}
async fn repair_btcpay_database_password() {
let Ok(db_pass) =
tokio::fs::read_to_string("/var/lib/archipelago/secrets/btcpay-db-password").await
else {
return;
};
let db_pass = db_pass.trim();
if db_pass.is_empty() {
return;
}
let _ = tokio::process::Command::new("podman")
.args(["start", "archy-btcpay-db"])
.output()
.await;
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
let escaped = db_pass.replace('\'', "''");
let sql = format!("ALTER USER btcpay WITH PASSWORD '{}';", escaped);
let _ = tokio::process::Command::new("podman")
.args([
"exec",
"archy-btcpay-db",
"psql",
"-U",
"btcpay",
"-d",
"btcpay",
"-c",
&sql,
])
.output()
.await;
let _ = tokio::process::Command::new("podman")
.args([
"exec",
"archy-btcpay-db",
"createdb",
"-U",
"btcpay",
"nbxplorer",
])
.output()
.await;
}
async fn repair_indeedhub_network() {
super::stacks::repair_indeedhub_network_aliases().await;
}
async fn cleanup_start_conflict(container_name: &str, stderr: &str) {
if !stderr.contains("address already in use") && !stderr.contains("pasta failed") {
return;
}
if container_name == "gitea" {
cleanup_gitea_stale_ports().await;
return;
}
if container_name != "grafana" {
return;
}
cleanup_stale_pasta_port("3000").await;
}
async fn cleanup_stale_pasta_port(port: &str) {
let kill_listener = format!(
"ss -ltnp 'sport = :{}' 2>/dev/null | sed -n 's/.*pid=\\([0-9]*\\).*/\\1/p' | xargs -r kill 2>/dev/null || true",
port
);
let _ = tokio::process::Command::new("sh")
.args(["-c", &kill_listener])
.output()
.await;
let pattern = format!("pasta.*{}", port);
let _ = tokio::process::Command::new("pkill")
.args(["-f", &pattern])
.output()
.await;
let pattern = format!("rootlessport.*{}", port);
let _ = tokio::process::Command::new("pkill")
.args(["-f", &pattern])
.output()
.await;
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
async fn cleanup_gitea_stale_ports() {
for port in ["3001", "2222", "3000"] {
let kill_listener = format!(
"ss -ltnp 'sport = :{}' 2>/dev/null | sed -n 's/.*pid=\\([0-9]*\\).*/\\1/p' | xargs -r kill 2>/dev/null || true",
port
);
let _ = tokio::process::Command::new("sh")
.args(["-c", &kill_listener])
.output()
.await;
let pattern = format!("pasta.*{}", port);
let _ = tokio::process::Command::new("pkill")
.args(["-f", &pattern])
.output()
.await;
let pattern = format!("rootlessport.*{}", port);
let _ = tokio::process::Command::new("pkill")
.args(["-f", &pattern])
.output()
.await;
}
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
pub(super) fn is_missing_companion_ok(name: &str, stderr: &str) -> bool {
matches!(
name,
"archy-bitcoin-ui" | "archy-lnd-ui" | "archy-electrs-ui"
) && stderr.contains("no container with name or ID")
}
/// Flip the primary package entry's state and return the pre-transition
/// state for revert on error. Mirrors `transitional::flip_to_transitional`
/// but lives here because the package path keys by `package_id` (which may
@ -738,3 +1098,41 @@ async fn set_package_state(
}
}
}
pub(super) async fn reconcile_companions_for(package_id: &str) {
let app_ids = match package_id {
"bitcoin" | "bitcoin-core" => vec!["bitcoin-core".to_string(), "bitcoin-ui".to_string()],
"bitcoin-knots" => vec!["bitcoin-knots".to_string(), "bitcoin-ui".to_string()],
"lnd" => vec!["lnd".to_string(), "lnd-ui".to_string()],
"electrumx" | "electrs" | "mempool-electrs" => {
vec!["electrumx".to_string(), "electrs-ui".to_string()]
}
_ => return,
};
for (companion, err) in crate::container::companion::reconcile(&app_ids).await {
tracing::warn!(companion = %companion, error = %err, "companion reconcile failed");
}
}
pub(super) fn orchestrator_uninstall_app_ids(package_id: &str) -> Vec<String> {
match package_id {
"bitcoin" | "bitcoin-core" => vec!["bitcoin-core".into(), "bitcoin-ui".into()],
"bitcoin-knots" => vec!["bitcoin-knots".into(), "bitcoin-ui".into()],
"lnd" => vec!["lnd".into(), "lnd-ui".into()],
"electrumx" | "electrs" | "mempool-electrs" => {
vec!["electrumx".into(), "electrs-ui".into()]
}
"mempool" | "mempool-web" => vec![
"mempool-api".into(),
"archy-mempool-web".into(),
"archy-mempool-db".into(),
],
"btcpay-server" | "btcpayserver" | "btcpay" => vec![
"btcpay-server".into(),
"archy-nbxplorer".into(),
"archy-btcpay-db".into(),
],
"fedimint" => vec!["fedimint".into(), "fedimint-gateway".into()],
_ => vec![package_id.to_string()],
}
}

View File

@ -8,7 +8,7 @@ use crate::data_model::InstallPhase;
use anyhow::{Context, Result};
use tracing::info;
use super::install::install_log;
use super::install::{install_log, patch_indeedhub_nostr_provider};
/// Adopt an existing container stack: start all named containers and return success.
/// Returns `Ok(Some(json))` if the primary container was found (adopted),
@ -40,6 +40,8 @@ async fn adopt_stack_if_exists(
))
.await;
repair_stack_before_adopt(stack_name).await;
for container in all_containers {
if names.iter().any(|n| n == container) {
let _ = tokio::process::Command::new("podman")
@ -55,6 +57,10 @@ async fn adopt_stack_if_exists(
.collect();
wait_for_stack_containers(stack_name, &existing, 60).await?;
if stack_name == "indeedhub" {
patch_indeedhub_nostr_provider().await;
}
install_log(&format!(
"INSTALL ADOPT OK: {} — started existing containers",
stack_name
@ -67,6 +73,76 @@ async fn adopt_stack_if_exists(
})))
}
async fn repair_stack_before_adopt(stack_name: &str) {
match stack_name {
"btcpay" | "btcpay-server" => {
let _ = tokio::process::Command::new("sudo")
.args([
"mkdir",
"-p",
"/var/lib/archipelago/btcpay/Main",
"/var/lib/archipelago/nbxplorer/Main",
])
.output()
.await;
let user = std::env::var("USER").unwrap_or_else(|_| "archipelago".to_string());
for dir in [
"/var/lib/archipelago/btcpay",
"/var/lib/archipelago/nbxplorer",
] {
let _ = tokio::process::Command::new("sudo")
.args(["chown", "-R", &format!("{}:{}", user, user), dir])
.output()
.await;
}
}
"indeedhub" => repair_indeedhub_network_aliases().await,
_ => {}
}
}
pub(in crate::api::rpc::package) async fn repair_indeedhub_network_aliases() {
let _ = tokio::process::Command::new("podman")
.args(["network", "create", "indeedhub-net"])
.output()
.await;
for (container, alias) in [
("indeedhub-postgres", "postgres"),
("indeedhub-redis", "redis"),
("indeedhub-minio", "minio"),
("indeedhub-relay", "relay"),
("indeedhub-api", "api"),
("indeedhub", "indeedhub"),
] {
let exists = tokio::process::Command::new("podman")
.args(["container", "exists", container])
.status()
.await
.map(|s| s.success())
.unwrap_or(false);
if !exists {
continue;
}
let _ = tokio::process::Command::new("podman")
.args(["network", "disconnect", "-f", "indeedhub-net", container])
.output()
.await;
let _ = tokio::process::Command::new("podman")
.args([
"network",
"connect",
"--alias",
alias,
"indeedhub-net",
container,
])
.output()
.await;
}
}
async fn run_required_stack_command(
stack_name: &str,
label: &str,
@ -480,6 +556,12 @@ impl RpcHandler {
/// Install BTCPay stack (postgres + nbxplorer + btcpay-server).
pub(super) async fn install_btcpay_stack(&self) -> Result<serde_json::Value> {
if let Some(orchestrated) =
install_stack_via_orchestrator(self, "btcpay-server", btcpay_stack_app_ids()).await?
{
return Ok(orchestrated);
}
if let Some(adopted) = adopt_stack_if_exists(
"btcpay-server",
"btcpay",
@ -490,12 +572,6 @@ impl RpcHandler {
return Ok(adopted);
}
if let Some(orchestrated) =
install_stack_via_orchestrator(self, "btcpay-server", btcpay_stack_app_ids()).await?
{
return Ok(orchestrated);
}
// Dependency check: Bitcoin must be running
let deps = super::dependencies::detect_running_deps().await?;
super::dependencies::check_install_deps("btcpay-server", &deps)?;
@ -1231,6 +1307,10 @@ impl RpcHandler {
"indeedhub-net",
"--restart",
"unless-stopped",
"--tmpfs",
"/run:rw,nosuid,nodev,size=16m",
"--tmpfs",
"/var/cache/nginx:rw,nosuid,nodev,size=32m",
"-p",
"7778:7777",
&format!("{}/indeedhub:1.0.0", registry),
@ -1265,6 +1345,8 @@ impl RpcHandler {
.await;
self.clear_install_progress("indeedhub").await;
patch_indeedhub_nostr_provider().await;
install_log("INSTALL OK: indeedhub stack").await;
info!("IndeedHub stack installed");
Ok(serde_json::json!({

View File

@ -71,88 +71,35 @@ pub async fn ensure_doctor_installed() {
Ok(_) => debug!("Secrets directory already at expected mode"),
Err(e) => warn!("Secrets dir tightening failed (non-fatal): {:#}", e),
}
// Podman self-heal MUST be the last bootstrap stage. If podman's
// runtime state is wedged, the orchestrator's first reconcile tick
// (which fires seconds after bootstrap returns) will hang or error
// on every container. Cleaning the runroot here gives the rest of
// the process a healthy podman to talk to.
// Podman probing MUST be the last bootstrap stage. We used to delete
// transient runroot state here when `podman info` failed, but live nodes
// can still have rootlessport/conmon processes holding that state. Removing
// it automatically makes failures worse: containers lose `.containerenv`,
// ports stay bound, and later starts fail. Report the fault instead; repair
// must be deliberate/operator-driven.
match heal_podman_state().await {
Ok(PodmanHealOutcome::Healthy) => debug!("podman runtime state healthy"),
Ok(PodmanHealOutcome::Cleaned) => warn!(
"podman runtime state was wedged at startup — cleaned runroot and re-probed (CRITICAL)"
Ok(PodmanHealOutcome::Unhealthy) => warn!(
"podman runtime state is unhealthy at startup — skipping automatic runroot cleanup"
),
Err(e) => warn!(
"podman self-heal failed (non-fatal, will retry next boot): {:#}",
e
),
Err(e) => warn!("podman self-heal failed (non-fatal, will retry next boot): {:#}", e),
}
}
#[derive(Debug, PartialEq, Eq)]
enum PodmanHealOutcome {
Healthy,
Cleaned,
Unhealthy,
}
/// Probe `podman info`. If it succeeds the daemon's runtime state is
/// fine and we return `Healthy` immediately. If it times out, fails to
/// spawn, or returns an "invalid internal status" / "database state"
/// error, the runtime state under `$XDG_RUNTIME_DIR/{containers,libpod}`
/// is likely wedged. We delete those two dirs and re-probe — podman
/// rebuilds runtime state from persistent storage under
/// `$HOME/.local/share/containers/storage/`.
///
/// `$XDG_RUNTIME_DIR/podman/` is **deliberately not touched**: that's
/// where systemd's socket-activated `podman.sock` listener lives. If we
/// removed it, every libpod HTTP call from the orchestrator would fail
/// with "connection refused" until `systemctl --user restart
/// podman.socket` ran — far worse than the wedge we'd be trying to fix.
///
/// Why this is safe at startup:
/// - We run BEFORE the orchestrator starts its reconcile loop, so no
/// archipelago code is currently calling podman.
/// - Persistent container metadata lives under
/// `~/.local/share/containers/`, which we never touch.
/// - `unless-stopped` containers and Quadlet-supervised services are
/// parented under user.slice, not archipelago.service, so they keep
/// running even while we clean podman's runtime view of them. After
/// the cleanup + re-probe podman re-discovers them.
///
/// What this does NOT cover:
/// - Storage corruption under `~/.local/share/containers/storage/`.
/// That requires a destructive `podman system reset`, which we will
/// never do automatically — operator must intervene.
/// - Networking corruption (netavark cache). Currently `podman info`
/// doesn't diagnose that; if cleanup doesn't fix it, the operator
/// will see the warning in the journal.
/// Subdirectories of `$XDG_RUNTIME_DIR` that hold podman's transient
/// state and are safe to remove when `podman info` is wedged. The
/// `podman/` subdir is **deliberately absent** — that's where systemd's
/// socket-activated `podman.sock` listener lives. Removing it would
/// silently break every libpod HTTP call from the orchestrator until
/// `systemctl --user restart podman.socket`. See
/// `heal_podman_state` docstring for the full rationale and the
/// `heal_podman_state_does_not_clean_socket_dir` regression test.
const HEAL_RUNTIME_SUBDIRS: &[&str] = &["containers", "libpod"];
async fn heal_podman_state() -> Result<PodmanHealOutcome> {
if probe_podman_ok().await {
return Ok(PodmanHealOutcome::Healthy);
}
let xdg = std::env::var("XDG_RUNTIME_DIR")
.context("XDG_RUNTIME_DIR not set; can't locate podman runtime state to clean")?;
for sub in HEAL_RUNTIME_SUBDIRS {
let path = PathBuf::from(&xdg).join(sub);
match fs::remove_dir_all(&path).await {
Ok(()) => debug!(path = %path.display(), "removed podman runtime state dir"),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => warn!(path = %path.display(), "remove failed: {}", e),
}
}
if probe_podman_ok().await {
Ok(PodmanHealOutcome::Cleaned)
} else {
Err(anyhow::anyhow!(
"podman info still failing after runtime cleanup; storage may be corrupt — operator must intervene"
))
}
Ok(PodmanHealOutcome::Unhealthy)
}
/// True iff `podman info` returns 0 within 5s. Any timeout, spawn
@ -298,7 +245,6 @@ async fn run_runtime_assets() -> Result<bool> {
if changed {
let _ = host_sudo(&["systemctl", "daemon-reload"]).await;
let _ = host_sudo(&["systemctl", "enable", "--now", "archipelago-doctor.timer"]).await;
}
Ok(changed)
}
@ -453,21 +399,14 @@ async fn run() -> Result<bool> {
let timer_changed = write_root_if_needed(DOCTOR_TIMER_PATH, DOCTOR_TIMER).await?;
changed = changed || service_changed || timer_changed;
// 3. Reload + enable. Only when we actually touched units, or when the
// timer isn't enabled yet (catches fresh upgrades of boxes that predate
// the doctor entirely).
let timer_enabled = is_timer_enabled().await;
if service_changed || timer_changed || !timer_enabled {
// 3. Reload if units changed. Do not enable/start the timer here: lifecycle
// qualification and explicit app operations need deterministic Podman
// ownership, and the doctor can race those flows. Operators can enable it
// separately when they want periodic host repair.
if service_changed || timer_changed {
if let Err(e) = host_sudo(&["systemctl", "daemon-reload"]).await {
warn!("daemon-reload failed: {:#}", e);
}
if let Err(e) =
host_sudo(&["systemctl", "enable", "--now", "archipelago-doctor.timer"]).await
{
warn!("enable archipelago-doctor.timer failed: {:#}", e);
} else if !timer_enabled {
info!("Enabled archipelago-doctor.timer");
}
}
Ok(changed)
@ -508,15 +447,6 @@ async fn write_root_if_needed(path: &str, content: &str) -> Result<bool> {
Ok(true)
}
async fn is_timer_enabled() -> bool {
tokio::process::Command::new("systemctl")
.args(["is-enabled", "--quiet", "archipelago-doctor.timer"])
.status()
.await
.map(|s| s.success())
.unwrap_or(false)
}
/// Patch the nginx site config to add a `/api/app-catalog` proxy block if
/// it's missing. The original ISO shipped individual per-endpoint `location`
/// blocks and no catch-all `/api/`, so `/api/app-catalog` silently fell
@ -615,22 +545,9 @@ async fn run_nginx() -> Result<bool> {
mod tests {
use super::*;
/// Regression gate for the 2026-05-01 bootstrap bug: heal_podman_state
/// was removing $XDG_RUNTIME_DIR/podman/ alongside containers/ and
/// libpod/, which silently broke the systemd-bound podman.sock and
/// every libpod HTTP call from the orchestrator. If anyone re-adds
/// "podman" to HEAL_RUNTIME_SUBDIRS this test fires before we ship.
#[test]
fn heal_podman_state_does_not_clean_socket_dir() {
assert!(
!HEAL_RUNTIME_SUBDIRS.contains(&"podman"),
"HEAL_RUNTIME_SUBDIRS must not include 'podman' — that dir holds \
systemd's podman.sock listener; removing it breaks every libpod \
HTTP call from the orchestrator. See bootstrap.rs commit bb421803."
);
// Sanity: the actually-runtime-state dirs are still in the list so
// we don't accidentally turn the heal into a no-op.
assert!(HEAL_RUNTIME_SUBDIRS.contains(&"containers"));
assert!(HEAL_RUNTIME_SUBDIRS.contains(&"libpod"));
fn podman_heal_outcome_no_longer_has_cleanup_variant() {
let outcome = PodmanHealOutcome::Unhealthy;
assert_ne!(outcome, PodmanHealOutcome::Healthy);
}
}

View File

@ -3,8 +3,8 @@
//!
//! Step 5 of the rust-orchestrator migration. Spawned once from `main.rs`
//! (Step 6) after the initial `adopt_existing()` pass. Every `interval` it
//! calls `ProdContainerOrchestrator::reconcile_all()`, which ensures every
//! loaded manifest has a running container, installing fresh ones as needed.
//! calls `ProdContainerOrchestrator::reconcile_existing()`, which repairs
//! containers that already exist without installing every catalog manifest.
//!
//! Per answered design Q3, `interval` defaults to 30 seconds.
//!
@ -96,7 +96,7 @@ impl BootReconciler {
}
async fn tick(&self) {
let report = self.orchestrator.reconcile_all().await;
let report = self.orchestrator.reconcile_existing().await;
Self::log_report(&report);
if !self.companion_stage {
@ -240,10 +240,11 @@ mod tests {
async fn orch_with_one_running_manifest(
rt: Arc<CountingRuntime>,
) -> Arc<ProdContainerOrchestrator> {
let orch = Arc::new(ProdContainerOrchestrator::with_runtime(
rt,
PathBuf::from("/nonexistent-for-tests"),
));
let mut orch =
ProdContainerOrchestrator::with_runtime(rt, PathBuf::from("/nonexistent-for-tests"));
let tmp = tempfile::tempdir().unwrap().keep();
orch.set_data_dir(tmp);
let orch = Arc::new(orch);
orch.insert_manifest_for_test(
pull_manifest("bitcoin-knots", "docker.io/bitcoin/knots:28"),
PathBuf::from("/tmp/bk"),
@ -337,10 +338,13 @@ mod tests {
// will run, and the next pass will see a new state. We care about
// "loop keeps ticking even when the report has actions".
let rt = Arc::new(CountingRuntime::default());
let orch = Arc::new(ProdContainerOrchestrator::with_runtime(
let mut orch = ProdContainerOrchestrator::with_runtime(
rt.clone(),
PathBuf::from("/nonexistent-for-tests"),
));
);
let tmp = tempfile::tempdir().unwrap().keep();
orch.set_data_dir(tmp);
let orch = Arc::new(orch);
orch.insert_manifest_for_test(
pull_manifest("bitcoin-knots", "docker.io/bitcoin/knots:28"),
PathBuf::from("/tmp/bk"),

View File

@ -50,6 +50,10 @@ pub struct CompanionSpec {
/// Bind mounts. Always read-only — companions don't write to
/// host paths.
pub bind_mounts: &'static [(&'static str, &'static str)],
/// Host-to-container TCP ports for non-host-network companions.
pub ports: &'static [(u16, u16)],
/// Whether the companion must share the host network namespace.
pub host_network: bool,
}
pub type PreStartHook = fn() -> futures_util::future::BoxFuture<'static, Result<()>>;
@ -78,6 +82,8 @@ const BITCOIN_UI: &[CompanionSpec] = &[CompanionSpec {
"/var/lib/archipelago/bitcoin-ui/nginx.conf",
"/etc/nginx/conf.d/default.conf",
)],
ports: &[],
host_network: true,
}];
const LND_UI: &[CompanionSpec] = &[CompanionSpec {
@ -90,6 +96,8 @@ const LND_UI: &[CompanionSpec] = &[CompanionSpec {
],
pre_start: None,
bind_mounts: &[],
ports: &[(18083, 80)],
host_network: false,
}];
const ELECTRS_UI: &[CompanionSpec] = &[CompanionSpec {
@ -102,6 +110,8 @@ const ELECTRS_UI: &[CompanionSpec] = &[CompanionSpec {
],
pre_start: None,
bind_mounts: &[],
ports: &[],
host_network: true,
}];
fn render_bitcoin_ui() -> futures_util::future::BoxFuture<'static, Result<()>> {
@ -183,9 +193,12 @@ async fn ensure_image_present(spec: &CompanionSpec) -> Result<String> {
for dir in spec.build_dir_candidates {
let dockerfile = PathBuf::from(dir).join("Dockerfile");
if fs::try_exists(&dockerfile).await.unwrap_or(false) {
if image_exists(&local_image).await {
return Ok(local_image);
}
info!(companion = spec.name, "building locally from {dir}");
let out = Command::new("podman")
.args(["build", "--no-cache", "-t", &local_image, dir])
.args(["build", "-t", &local_image, dir])
.output()
.await
.context("spawn podman build")?;
@ -220,15 +233,24 @@ async fn ensure_image_present(spec: &CompanionSpec) -> Result<String> {
Ok(registry_image)
}
async fn image_exists(image: &str) -> bool {
Command::new("podman")
.args(["image", "exists", image])
.status()
.await
.is_ok_and(|status| status.success())
}
fn build_unit(spec: &CompanionSpec, image: &str) -> QuadletUnit {
QuadletUnit {
name: spec.name.into(),
description: format!("Archipelago companion UI: {}", spec.name),
image: image.into(),
// Companions proxy to localhost — backend is on :5678, bitcoin
// RPC on :8332. Host network is the simplest way to reach them
// without per-app gateway plumbing.
network: NetworkMode::Host,
network: if spec.host_network {
NetworkMode::Host
} else {
NetworkMode::Bridge("bridge".into())
},
// Run as root inside the container so nginx can chown its
// worker dirs. Rootless podman maps this to a high host UID,
// so it is unprivileged on the host.
@ -251,6 +273,11 @@ fn build_unit(spec: &CompanionSpec, image: &str) -> QuadletUnit {
read_only: true,
})
.collect(),
ports: spec
.ports
.iter()
.map(|(host, container)| (*host, *container, "tcp".into()))
.collect(),
extra_podman_args: vec![],
depends_on: vec![],
// Companions don't use the backend-manifest extension fields;
@ -353,4 +380,13 @@ mod tests {
);
assert!(u.bind_mounts[0].read_only);
}
#[test]
fn lnd_ui_uses_port_mapping_not_host_port_80() {
let spec = &LND_UI[0];
let u = build_unit(spec, "localhost/lnd-ui:latest");
assert_eq!(u.name, "archy-lnd-ui");
assert!(matches!(u.network, NetworkMode::Bridge(ref n) if n == "bridge"));
assert_eq!(u.ports, vec![(18083, 80, "tcp".into())]);
}
}

View File

@ -63,10 +63,14 @@ impl DockerPackageScanner {
"indeedhub-build_ffmpeg-worker_1",
];
// First pass: collect UI containers
// First pass: collect running UI containers. Custom UI-backed apps must
// not advertise a launch URL unless their companion is actually alive.
let mut ui_containers: HashMap<String, String> = HashMap::new();
for container in &containers {
if container.name.ends_with("-ui") {
if !matches!(container.state, ContainerState::Running) {
continue;
}
// Map fedimint-ui -> fedimint, lnd-ui -> lnd (normalize archy- prefix for lookup)
let parent_app = container
.name
@ -76,13 +80,13 @@ impl DockerPackageScanner {
.strip_prefix("archy-")
.unwrap_or(parent_app)
.to_string();
if !container.ports.is_empty() {
if let Some(ui_address) = extract_lan_address(&container.ports) {
let ui_address = extract_lan_address(&container.ports)
.or_else(|| companion_lan_address(&canonical_id));
if let Some(ui_address) = ui_address {
ui_containers.insert(canonical_id, ui_address);
}
}
}
}
debug!("Found {} UI containers", ui_containers.len());
@ -133,12 +137,6 @@ impl DockerPackageScanner {
// Apps with separate UI containers (e.g. archy-bitcoin-ui, archy-lnd-ui)
debug!("Using UI container for {}: {}", app_id, ui_address);
Some(ui_address.clone())
} else if app_id == "bitcoin-knots" {
Some("http://localhost:8334".to_string())
} else if app_id == "lnd" {
Some("http://localhost:8081".to_string())
} else if app_id == "electrumx" || app_id == "mempool-electrs" || app_id == "electrs" {
Some("http://localhost:50002".to_string())
} else {
// Dynamic: use actual port bindings from container, fall back to static map
extract_lan_address(&container.ports)
@ -633,6 +631,14 @@ fn extract_lan_address(ports: &[String]) -> Option<String> {
None
}
fn companion_lan_address(app_id: &str) -> Option<String> {
match app_id {
"bitcoin" | "bitcoin-knots" | "bitcoin-core" => Some("http://localhost:8334".to_string()),
"electrumx" | "mempool-electrs" | "electrs" => Some("http://localhost:50002".to_string()),
_ => None,
}
}
fn convert_state(container_state: &ContainerState) -> (PackageState, ServiceStatus) {
match container_state {
ContainerState::Running => (PackageState::Running, ServiceStatus::Running),

View File

@ -0,0 +1,425 @@
//! lnd config bootstrap helper.
use anyhow::{Context, Result};
use base64::Engine;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use tokio::fs;
use crate::update::host_sudo;
pub const DEFAULT_DATA_DIR: &str = "/var/lib/archipelago/lnd";
pub const DEFAULT_CONF_PATH: &str = "/var/lib/archipelago/lnd/lnd.conf";
pub const WALLET_PASSWORD: &str = "hellohello";
#[derive(Debug, Clone)]
pub struct EnsurePaths {
pub data_dir: PathBuf,
pub conf_path: PathBuf,
}
impl Default for EnsurePaths {
fn default() -> Self {
Self {
data_dir: PathBuf::from(DEFAULT_DATA_DIR),
conf_path: PathBuf::from(DEFAULT_CONF_PATH),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EnsureOutcome {
Written,
Unchanged,
}
pub async fn ensure_config(paths: &EnsurePaths, rpc_pass: &str) -> Result<EnsureOutcome> {
fs::create_dir_all(&paths.data_dir)
.await
.with_context(|| format!("creating {}", paths.data_dir.display()))?;
if paths.conf_path.exists() {
let existing = fs::read_to_string(&paths.conf_path)
.await
.with_context(|| format!("reading {}", paths.conf_path.display()))?;
if has_required_lnd_flags(&existing) {
return Ok(EnsureOutcome::Unchanged);
}
}
let conf = format!(
"debuglevel=info\n\
maxpendingchannels=10\n\
alias=Archipelago Node\n\
color=#f7931a\n\
listen=0.0.0.0:9735\n\
rpclisten=0.0.0.0:10009\n\
restlisten=0.0.0.0:8080\n\
bitcoin.active=true\n\
bitcoin.mainnet=true\n\
bitcoin.node=bitcoind\n\
bitcoind.rpchost=bitcoin-knots:8332\n\
bitcoind.rpcuser=archipelago\n\
bitcoind.rpcpass={}\n\
bitcoind.rpcpolling=true\n\
bitcoind.estimatemode=ECONOMICAL\n",
rpc_pass
);
write_config_atomically(paths, &conf).await?;
Ok(EnsureOutcome::Written)
}
pub async fn ensure_wallet_initialized() -> Result<()> {
let admin_macaroon = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
let wallet_db = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/wallet.db";
if file_exists_as_root(wallet_db).await {
if file_exists_as_root(admin_macaroon).await && lnd_getinfo_ready(admin_macaroon).await {
return Ok(());
}
unlock_existing_wallet().await?;
wait_for_admin_macaroon(admin_macaroon).await?;
return Ok(());
}
init_wallet_via_rest().await?;
wait_for_admin_macaroon(admin_macaroon).await
}
async fn file_exists_as_root(path: &str) -> bool {
if std::path::Path::new(path).exists() {
return true;
}
tokio::process::Command::new("sudo")
.args(["test", "-f", path])
.status()
.await
.map(|status| status.success())
.unwrap_or(false)
}
async fn read_file_as_root(path: &str) -> Result<Vec<u8>> {
match fs::read(path).await {
Ok(bytes) => Ok(bytes),
Err(direct_err) => {
let out = tokio::process::Command::new("sudo")
.args(["cat", path])
.output()
.await
.with_context(|| format!("reading {path} via sudo"))?;
if out.status.success() {
Ok(out.stdout)
} else {
anyhow::bail!(
"reading {path} failed (direct: {direct_err}; sudo: {})",
String::from_utf8_lossy(&out.stderr).trim()
)
}
}
}
}
async fn unlock_existing_wallet() -> Result<()> {
let mut last_err = None;
for _ in 0..60 {
let mut cmd = tokio::process::Command::new("podman");
cmd.args(["exec", "-i", "lnd", "lncli", "unlock", "--stdin"]);
cmd.stdin(std::process::Stdio::piped());
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
let mut child = cmd.spawn().context("spawning lncli wallet unlock")?;
if let Some(mut stdin) = child.stdin.take() {
use tokio::io::AsyncWriteExt;
stdin
.write_all(format!("{}\n", WALLET_PASSWORD).as_bytes())
.await
.context("writing lncli password")?;
}
let out = child
.wait_with_output()
.await
.context("waiting for lncli")?;
if out.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&out.stderr);
let stdout = String::from_utf8_lossy(&out.stdout);
let msg = format!("{stderr}{stdout}");
if msg.contains("wallet already unlocked") || msg.contains("already unlocked") {
return Ok(());
}
last_err = Some(msg);
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
anyhow::bail!(
"lncli wallet unlock failed: {}",
last_err.unwrap_or_else(|| "unknown error".to_string())
)
}
#[derive(Debug, Deserialize)]
struct GenSeedResponse {
cipher_seed_mnemonic: Vec<String>,
}
#[derive(Debug)]
enum UnlockerResponse<T> {
Value(T),
WalletAlreadyExists,
}
#[derive(Debug, Serialize)]
struct InitWalletRequest {
wallet_password: String,
cipher_seed_mnemonic: Vec<String>,
}
async fn init_wallet_via_rest() -> Result<()> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(20))
.danger_accept_invalid_certs(true)
.build()
.context("building LND REST client")?;
let seed: GenSeedResponse = match get_lnd_unlocker_json(&client, "/v1/genseed")
.await
.context("generating LND wallet seed")?
{
UnlockerResponse::Value(seed) => seed,
UnlockerResponse::WalletAlreadyExists => {
unlock_existing_wallet().await?;
return Ok(());
}
};
if seed.cipher_seed_mnemonic.is_empty() {
anyhow::bail!("LND genseed returned no seed words");
}
let wallet_password = base64::engine::general_purpose::STANDARD.encode(WALLET_PASSWORD);
let req = InitWalletRequest {
wallet_password,
cipher_seed_mnemonic: seed.cipher_seed_mnemonic,
};
match post_lnd_unlocker_json::<serde_json::Value>(
&client,
"/v1/initwallet",
serde_json::to_value(req)?,
)
.await
.context("initializing LND wallet")?
{
UnlockerResponse::Value(_) => {}
UnlockerResponse::WalletAlreadyExists => unlock_existing_wallet().await?,
}
Ok(())
}
async fn get_lnd_unlocker_json<T: for<'de> Deserialize<'de>>(
client: &reqwest::Client,
path: &str,
) -> Result<UnlockerResponse<T>> {
let url = format!("https://127.0.0.1:8080{path}");
let mut last_err = None;
for _ in 0..60 {
match client.get(&url).send().await {
Ok(resp) => match decode_lnd_unlocker_response(resp, path).await {
Ok(value) => return Ok(value),
Err(e) => last_err = Some(e.to_string()),
},
Err(e) => last_err = Some(e.to_string()),
}
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
anyhow::bail!(
"LND REST {path} unavailable: {}",
last_err.unwrap_or_else(|| "unknown error".to_string())
)
}
async fn post_lnd_unlocker_json<T: for<'de> Deserialize<'de>>(
client: &reqwest::Client,
path: &str,
body: serde_json::Value,
) -> Result<UnlockerResponse<T>> {
let url = format!("https://127.0.0.1:8080{path}");
let mut last_err = None;
for _ in 0..60 {
match client.post(&url).json(&body).send().await {
Ok(resp) => match decode_lnd_unlocker_response(resp, path).await {
Ok(value) => return Ok(value),
Err(e) => last_err = Some(e.to_string()),
},
Err(e) => last_err = Some(e.to_string()),
}
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
anyhow::bail!(
"LND REST {path} unavailable: {}",
last_err.unwrap_or_else(|| "unknown error".to_string())
)
}
async fn decode_lnd_unlocker_response<T: for<'de> Deserialize<'de>>(
resp: reqwest::Response,
path: &str,
) -> Result<UnlockerResponse<T>> {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
if status.is_success() {
let value = serde_json::from_str(&text)
.with_context(|| format!("parsing LND REST response from {path}"))?;
return Ok(UnlockerResponse::Value(value));
}
if text.contains("wallet already exists") {
return Ok(UnlockerResponse::WalletAlreadyExists);
}
anyhow::bail!("LND REST {path} returned {status}: {text}")
}
async fn lnd_getinfo_ready(admin_macaroon: &str) -> bool {
let Ok(macaroon) = read_file_as_root(admin_macaroon).await else {
return false;
};
let Ok(client) = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.danger_accept_invalid_certs(true)
.build()
else {
return false;
};
client
.get("https://127.0.0.1:8080/v1/getinfo")
.header("Grpc-Metadata-macaroon", hex::encode(macaroon))
.send()
.await
.map(|resp| resp.status().is_success())
.unwrap_or(false)
}
async fn wait_for_admin_macaroon(admin_macaroon: &str) -> Result<()> {
for _ in 0..60 {
if file_exists_as_root(admin_macaroon).await {
return Ok(());
}
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
anyhow::bail!("LND admin macaroon not created after wallet init")
}
async fn write_config_atomically(paths: &EnsurePaths, conf: &str) -> Result<()> {
let tmp = paths.conf_path.with_extension("tmp");
match fs::write(&tmp, conf).await {
Ok(()) => {
fs::rename(&tmp, &paths.conf_path).await.with_context(|| {
format!(
"renaming {} -> {}",
tmp.display(),
paths.conf_path.display()
)
})?;
Ok(())
}
Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
let script = format!(
"set -eu\ncat > '{}' <<'LNDCONF'\n{}LNDCONF\n",
shell_quote(&paths.conf_path.to_string_lossy()),
conf
);
let status = host_sudo(&["sh", "-lc", &script])
.await
.context("writing lnd.conf via sudo")?;
if !status.success() {
anyhow::bail!("writing lnd.conf via sudo exited with {status}");
}
Ok(())
}
Err(e) => Err(e).with_context(|| format!("writing tmp {}", tmp.display())),
}
}
fn shell_quote(s: &str) -> String {
s.replace('\'', "'\\''")
}
fn has_required_lnd_flags(conf: &str) -> bool {
[
"bitcoin.active=true",
"bitcoin.mainnet=true",
"bitcoin.node=bitcoind",
"bitcoind.rpchost=bitcoin-knots:8332",
]
.iter()
.all(|needle| conf.lines().any(|line| line.trim() == *needle))
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn ensure_config_writes_required_bitcoin_network_flags() {
let tmp = tempfile::TempDir::new().unwrap();
let paths = EnsurePaths {
data_dir: tmp.path().join("lnd"),
conf_path: tmp.path().join("lnd/lnd.conf"),
};
let out = ensure_config(&paths, "secret").await.unwrap();
assert_eq!(out, EnsureOutcome::Written);
let conf = fs::read_to_string(&paths.conf_path).await.unwrap();
assert!(conf.contains("bitcoin.active=true"));
assert!(conf.contains("bitcoin.mainnet=true"));
assert!(conf.contains("bitcoin.node=bitcoind"));
assert!(conf.contains("bitcoind.rpchost=bitcoin-knots:8332"));
assert!(conf.contains("bitcoind.rpcpass=secret"));
}
#[tokio::test]
async fn ensure_config_is_idempotent() {
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, "first").await.unwrap(),
EnsureOutcome::Written
);
assert_eq!(
ensure_config(&paths, "second").await.unwrap(),
EnsureOutcome::Unchanged
);
let conf = fs::read_to_string(&paths.conf_path).await.unwrap();
assert!(conf.contains("bitcoind.rpcpass=first"));
}
#[tokio::test]
async fn ensure_config_repairs_incomplete_existing_config() {
let tmp = tempfile::TempDir::new().unwrap();
let paths = EnsurePaths {
data_dir: tmp.path().join("lnd"),
conf_path: tmp.path().join("lnd/lnd.conf"),
};
fs::create_dir_all(&paths.data_dir).await.unwrap();
fs::write(&paths.conf_path, "debuglevel=info\n")
.await
.unwrap();
assert_eq!(
ensure_config(&paths, "repaired").await.unwrap(),
EnsureOutcome::Written
);
let conf = fs::read_to_string(&paths.conf_path).await.unwrap();
assert!(conf.contains("bitcoin.mainnet=true"));
assert!(conf.contains("bitcoind.rpcpass=repaired"));
}
#[test]
fn wallet_password_is_valid_for_lncli() {
assert!(WALLET_PASSWORD.len() > 8);
}
}

View File

@ -6,6 +6,7 @@ pub mod dev_orchestrator;
pub mod docker_packages;
pub mod filebrowser;
pub mod image_versions;
pub mod lnd;
pub mod prod_orchestrator;
pub mod quadlet;
pub mod registry;

File diff suppressed because it is too large Load Diff

View File

@ -202,7 +202,11 @@ impl QuadletUnit {
);
}
for (host, container, proto) in &self.ports {
let p = if proto.is_empty() { "tcp" } else { proto.as_str() };
let p = if proto.is_empty() {
"tcp"
} else {
proto.as_str()
};
let _ = writeln!(s, "PublishPort={host}:{container}/{p}");
}
for env in &self.environment {
@ -387,9 +391,7 @@ impl QuadletUnit {
/// `http://localhost:8175/`). Earlier we blindly prepended `http://` even
/// when one was already there, producing `http://http://...` HealthCmds
/// that pasted on .228 2026-05-02 and failed every probe.
fn translate_health_check(
hc: &archipelago_container::HealthCheck,
) -> Option<HealthSpec> {
fn translate_health_check(hc: &archipelago_container::HealthCheck) -> Option<HealthSpec> {
let cmd = match hc.check_type.as_str() {
"tcp" => {
let endpoint = hc.endpoint.as_deref()?;
@ -703,10 +705,7 @@ mod tests {
"bash -c \"echo hi\""
);
// Embedded quotes must escape:
assert_eq!(
shell_join(&[r#"say "hi""#.into()]),
r#""say \"hi\"""#
);
assert_eq!(shell_join(&[r#"say "hi""#.into()]), r#""say \"hi\"""#);
}
#[test]
@ -823,7 +822,10 @@ app:
assert!(!u.bind_mounts[0].read_only);
assert_eq!(u.entrypoint, Some(vec!["/usr/local/bin/bitcoind".into()]));
assert_eq!(u.command, vec!["-server=1", "-rpcbind=0.0.0.0"]);
assert!(u.add_hosts.iter().any(|(n, ip)| n == "host.archipelago" && ip == "10.89.0.1"));
assert!(u
.add_hosts
.iter()
.any(|(n, ip)| n == "host.archipelago" && ip == "10.89.0.1"));
assert_eq!(u.restart_policy, RestartPolicy::OnFailure);
}

View File

@ -412,6 +412,19 @@ pub async fn start_stopped_containers(data_dir: &Path) -> RecoveryReport {
};
}
let names: Vec<String> = names
.into_iter()
.filter(|n| should_auto_start_stopped_container(n))
.collect();
if names.is_empty() {
return RecoveryReport {
total: 0,
recovered: 0,
failed: Vec::new(),
};
}
// Sort by startup tier: databases first, then core, then dependent services, then apps
let mut records: Vec<RunningContainerRecord> = names
.iter()
@ -430,6 +443,13 @@ pub async fn start_stopped_containers(data_dir: &Path) -> RecoveryReport {
recover_containers(&records).await
}
fn should_auto_start_stopped_container(name: &str) -> bool {
// Keep generic boot recovery narrow. The Rust manifest reconciler owns
// managed app stacks; starting every exited Podman container here races
// it and resurrects legacy/orphan helper containers.
matches!(name, "filebrowser" | "nostr-rs-relay")
}
/// Simple tier ordering for boot recovery (mirrors health_monitor tiers).
fn container_boot_tier(name: &str) -> u8 {
let id = name.strip_prefix("archy-").unwrap_or(name);
@ -603,4 +623,13 @@ mod tests {
let result = check_for_crash(tmp.path()).await.unwrap();
assert!(result.is_none());
}
#[test]
fn generic_boot_recovery_skips_manifest_owned_and_legacy_stacks() {
assert!(should_auto_start_stopped_container("filebrowser"));
assert!(should_auto_start_stopped_container("nostr-rs-relay"));
assert!(!should_auto_start_stopped_container("bitcoin-knots"));
assert!(!should_auto_start_stopped_container("lnd"));
assert!(!should_auto_start_stopped_container("indeedhub-postgres"));
}
}

View File

@ -4,7 +4,7 @@
// handles "created" state containers, resets dependent counters when deps recover,
// and sends WebSocket notifications to the UI on failure.
use crate::data_model::{Notification, NotificationLevel};
use crate::data_model::{Notification, NotificationLevel, PackageState};
use crate::state::StateManager;
use crate::webhooks::{self, WebhookEvent};
use serde::{Deserialize, Serialize};
@ -67,14 +67,14 @@ fn container_dependencies(name: &str) -> &'static [&'static str] {
let id = name.strip_prefix("archy-").unwrap_or(name);
match id {
// Bitcoin-dependent chain
"lnd" => &["bitcoin-knots"],
"electrumx" | "mempool-electrs" | "electrs" => &["bitcoin-knots"],
"nbxplorer" => &["bitcoin-knots"],
"lnd" => &["bitcoin"],
"electrumx" | "mempool-electrs" | "electrs" => &["bitcoin"],
"nbxplorer" => &["bitcoin"],
"btcpay-server" => &["btcpay-db", "nbxplorer"],
"mempool-api" => &["mempool-db", "electrumx"],
"mempool-web" => &["mempool-api"],
"fedimint" => &["bitcoin-knots"],
"fedimint-gateway" => &["bitcoin-knots", "fedimint"],
"fedimint" => &["bitcoin"],
"fedimint-gateway" => &["bitcoin", "fedimint"],
// IndeedHub stack
"indeedhub-api" => &["indeedhub-postgres", "indeedhub-redis"],
@ -88,7 +88,7 @@ fn container_dependencies(name: &str) -> &'static [&'static str] {
"penpot-frontend" => &["penpot-backend"],
// UI containers
"bitcoin-ui" => &["bitcoin-knots"],
"bitcoin-ui" => &["bitcoin"],
"lnd-ui" => &["lnd"],
"electrs-ui" => &["electrumx"],
@ -103,6 +103,16 @@ fn deps_are_running(name: &str, containers: &[ContainerHealth]) -> bool {
return true;
}
for dep in deps {
if *dep == "bitcoin" {
let bitcoin_running = containers.iter().any(|c| {
let c_id = c.name.strip_prefix("archy-").unwrap_or(&c.name);
matches!(c_id, "bitcoin" | "bitcoin-knots" | "bitcoin-core") && c.state == "running"
});
if !bitcoin_running {
return false;
}
continue;
}
// Check both plain name and archy- prefixed name
let dep_running = containers.iter().any(|c| {
let c_id = c.name.strip_prefix("archy-").unwrap_or(&c.name);
@ -115,6 +125,24 @@ fn deps_are_running(name: &str, containers: &[ContainerHealth]) -> bool {
true
}
fn conflicting_bitcoin_variant(name: &str) -> Option<&'static str> {
match name.strip_prefix("archy-").unwrap_or(name) {
"bitcoin-core" => Some("bitcoin-knots"),
"bitcoin-knots" | "bitcoin" => Some("bitcoin-core"),
_ => None,
}
}
fn has_running_bitcoin_conflict(name: &str, containers: &[ContainerHealth]) -> bool {
let Some(conflict) = conflicting_bitcoin_variant(name) else {
return false;
};
containers.iter().any(|c| {
let id = c.name.strip_prefix("archy-").unwrap_or(&c.name);
id == conflict && c.state == "running"
})
}
/// Track restart attempts per container with exponential backoff and stability reset.
struct RestartTracker {
attempts: HashMap<String, u32>,
@ -539,6 +567,16 @@ pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
debug!("Skipping uninstalled container: {}", container.name);
continue;
}
if matches!(
pkg.state,
PackageState::Starting | PackageState::Stopping | PackageState::Restarting
) {
debug!(
"Skipping container during package lifecycle transition: {} ({:?})",
container.name, pkg.state
);
continue;
}
} else {
// Orphan: container exists in podman but archipelago has
// no package_data entry for it. Common after a variant
@ -650,6 +688,14 @@ pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
continue;
}
if has_running_bitcoin_conflict(&container.name, &containers) {
debug!(
"Skipping auto-restart for {} because the other Bitcoin implementation is running",
container.name
);
continue;
}
// When transitioning to a higher tier, wait briefly for previous tier to stabilize
if let Some(prev) = prev_tier {
if tier > prev {
@ -916,7 +962,7 @@ mod tests {
#[test]
fn test_container_dependencies() {
assert!(container_dependencies("lnd").contains(&"bitcoin-knots"));
assert!(container_dependencies("lnd").contains(&"bitcoin"));
assert!(container_dependencies("indeedhub-api").contains(&"indeedhub-postgres"));
assert!(container_dependencies("indeedhub-api").contains(&"indeedhub-redis"));
assert!(container_dependencies("mempool-api").contains(&"mempool-db"));
@ -957,6 +1003,59 @@ mod tests {
assert!(!deps_are_running("indeedhub-api", &partial));
}
#[test]
fn test_bitcoin_dependency_accepts_core_or_knots() {
let core = vec![ContainerHealth {
name: "bitcoin-core".into(),
app_id: "bitcoin-core".into(),
state: "running".into(),
healthy: true,
}];
assert!(deps_are_running("lnd", &core));
let knots = vec![ContainerHealth {
name: "bitcoin-knots".into(),
app_id: "bitcoin-knots".into(),
state: "running".into(),
healthy: true,
}];
assert!(deps_are_running("fedimint", &knots));
let stopped = vec![ContainerHealth {
name: "bitcoin-core".into(),
app_id: "bitcoin-core".into(),
state: "stopped".into(),
healthy: false,
}];
assert!(!deps_are_running("electrumx", &stopped));
}
#[test]
fn test_bitcoin_conflict_detection() {
let containers = vec![ContainerHealth {
name: "bitcoin-core".into(),
app_id: "bitcoin-core".into(),
state: "running".into(),
healthy: true,
}];
assert!(has_running_bitcoin_conflict("bitcoin-knots", &containers));
assert!(!has_running_bitcoin_conflict("bitcoin-core", &containers));
assert!(!has_running_bitcoin_conflict("lnd", &containers));
}
#[test]
fn test_bitcoin_conflict_ignores_stopped_sibling() {
let containers = vec![ContainerHealth {
name: "bitcoin-core".into(),
app_id: "bitcoin-core".into(),
state: "stopped".into(),
healthy: false,
}];
assert!(!has_running_bitcoin_conflict("bitcoin-knots", &containers));
}
#[test]
fn test_container_tier_core() {
assert_eq!(container_tier("bitcoin-knots"), StartupTier::CoreInfra);

View File

@ -137,12 +137,10 @@ async fn main() -> Result<()> {
// Write PID marker early so we can detect crashes on next startup
crash_recovery::write_pid_marker(&config.data_dir).await?;
// Crash recovery runs in background so health endpoint is available immediately
{
let data_dir = config.data_dir.clone();
tokio::spawn(async move {
// Check if previous instance shut down cleanly
match crash_recovery::check_for_crash(&data_dir).await {
// Run crash recovery before starting the manifest reconciler. Both paths
// mutate Podman; running them concurrently can corrupt transient runtime
// state and leave netavark/conmon unable to start containers.
match crash_recovery::check_for_crash(&config.data_dir).await {
Ok(Some(containers)) => {
info!(
"🔧 Recovering {} containers from previous crash...",
@ -160,20 +158,17 @@ async fn main() -> Result<()> {
}
}
// Start any stopped containers (handles clean reboot)
// Skips user-stopped containers, uses tier ordering
let boot_report = crash_recovery::start_stopped_containers(&data_dir).await;
// Start any stopped containers (handles clean reboot). This remains
// synchronous for the same reason: no concurrent reconciler during Podman
// startup/recovery operations.
let boot_report = crash_recovery::start_stopped_containers(&config.data_dir).await;
if boot_report.total > 0 {
info!(
"🔄 Boot startup: {}/{} containers started (failed: {:?})",
boot_report.recovered, boot_report.total, boot_report.failed
);
}
// Signal to health monitor that boot recovery is done
crash_recovery::mark_recovery_complete();
});
}
// Construct the container orchestrator once. In prod mode we load the
// on-disk app manifests, do an initial adoption pass, and spawn the

View File

@ -14,7 +14,7 @@ const RESERVED_PORTS: &[u16] = &[
80, 443, 81, // HTTP/HTTPS
8332, 8333, 8334, // Bitcoin RPC/P2P
9735, 10009, 8080, // LND P2P, gRPC, REST
8081, // LND UI (archy-lnd-ui)
18083, // LND UI (archy-lnd-ui)
4080, 8999, 50001, // Mempool stack
23000, // BTCPay
8173, 8174, 8175, // Fedimint

View File

@ -313,6 +313,7 @@ impl Server {
let scanner = create_docker_scanner(&config).await?;
let state = state_manager.clone();
let identity_clone = identity.clone();
let data_dir = config.data_dir.clone();
let scan_kick = api_handler.rpc_handler().scan_kick();
let scan_tick = api_handler.rpc_handler().scan_tick();
@ -334,6 +335,7 @@ impl Server {
&scanner,
&state,
identity_clone.as_ref(),
&data_dir,
&mut absence_tracker,
&mut transitional_since,
)
@ -371,6 +373,7 @@ impl Server {
&scanner,
&state,
identity_clone.as_ref(),
&data_dir,
&mut absence_tracker,
&mut transitional_since,
)
@ -865,8 +868,19 @@ fn merge_preserving_transitional(
existing: &crate::data_model::PackageDataEntry,
fresh: &crate::data_model::PackageDataEntry,
) -> crate::data_model::PackageDataEntry {
let state = match (&existing.state, &fresh.state) {
// Removing with a live running container is stale: uninstall either
// failed or Archipelago restarted before the spawned task could revert
// state. Let the scanner recover the UI immediately instead of
// keeping the app wedged in Removing for 20 minutes.
(crate::data_model::PackageState::Removing, crate::data_model::PackageState::Running) => {
fresh.state.clone()
}
_ => existing.state.clone(),
};
crate::data_model::PackageDataEntry {
state: existing.state.clone(),
state,
// install_progress and uninstall_stage are also owned by the
// initiating op (same reason as state) — keep them.
install_progress: existing.install_progress.clone(),
@ -885,10 +899,18 @@ async fn scan_and_update_packages(
scanner: &DockerPackageScanner,
state: &StateManager,
identity: &NodeIdentity,
data_dir: &std::path::Path,
absence_tracker: &mut HashMap<String, u32>,
transitional_since: &mut HashMap<String, Instant>,
) -> Result<()> {
let packages = scanner.scan_containers().await?;
let mut packages = scanner.scan_containers().await?;
let user_stopped = crate::crash_recovery::load_user_stopped(data_dir).await;
for (id, pkg) in packages.iter_mut() {
if pkg.state == crate::data_model::PackageState::Exited && user_stopped.contains(id) {
pkg.state = crate::data_model::PackageState::Stopped;
pkg.exit_code = None;
}
}
let (current_data, _) = state.get_snapshot().await;
let tor_addr = docker_packages::read_tor_address("archipelago").await;
@ -992,6 +1014,18 @@ async fn scan_and_update_packages(
// owner (spawn_task) is responsible for clearing state, not us.
if let Some(entry) = merged.get(&id) {
if is_transitional(&entry.state) {
let entered = *transitional_since.entry(id.clone()).or_insert(now);
if now.duration_since(entered) > TRANSITIONAL_STUCK_TIMEOUT {
warn!(
"Container {} stuck in {:?} and absent for >{}s; removing stale transitional state",
id,
entry.state,
TRANSITIONAL_STUCK_TIMEOUT.as_secs()
);
merged.remove(&id);
transitional_since.remove(&id);
changed = true;
}
absence_tracker.remove(&id);
continue;
}
@ -1170,6 +1204,15 @@ mod merge_tests {
assert_eq!(merged.exit_code, Some(0));
}
#[test]
fn stale_removing_recovers_when_container_is_running() {
let existing = make_entry(PackageState::Removing, Some("unknown"));
let fresh = make_entry(PackageState::Running, Some("healthy"));
let merged = merge_preserving_transitional(&existing, &fresh);
assert_eq!(merged.state, PackageState::Running);
assert_eq!(merged.health.as_deref(), Some("healthy"));
}
#[test]
fn is_transitional_covers_all_variants() {
for s in [

View File

@ -109,7 +109,7 @@ impl PodmanClient {
pub fn lan_address_for(name: &str) -> Option<String> {
let url = match name {
"bitcoin-knots" | "bitcoin-ui" => "http://localhost:8334",
"lnd" | "archy-lnd-ui" => "http://localhost:8081",
"lnd" | "archy-lnd-ui" => "http://localhost:18083",
"homeassistant" => "http://localhost:8123",
"archy-mempool-web" | "mempool" => "http://localhost:4080",
"btcpay-server" => "http://localhost:23000",
@ -374,7 +374,10 @@ impl PodmanClient {
"env": env_map,
"entrypoint": manifest.app.container.entrypoint.clone(),
"command": manifest.app.container.custom_args.clone(),
"hostadd": ["host.containers.internal:host-gateway"],
"hostadd": [
"host.containers.internal:host-gateway",
"host.archipelago:10.89.0.1",
],
"devices": manifest.app.devices.iter().map(|d| {
serde_json::json!({"path": d})
}).collect::<Vec<_>>(),
@ -392,7 +395,10 @@ impl PodmanClient {
if let Some(network) = custom_network {
body.as_object_mut()
.expect("container create body is a JSON object")
.insert("networks".to_string(), serde_json::json!({ network: {} }));
.insert(
"networks".to_string(),
serde_json::json!({ network: { "aliases": [name] } }),
);
}
let result = self

View File

@ -104,7 +104,20 @@ impl ContainerRuntime for PodmanRuntime {
}
async fn list_containers(&self) -> Result<Vec<ContainerStatus>> {
self.client.list_containers().await
match self.client.list_containers().await {
Ok(containers) => Ok(containers),
Err(api_err) => {
let output = self.podman_cli(&["ps", "-a", "--format", "json"]).await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(
api_err.context(format!("podman ps fallback failed: {}", stderr.trim()))
);
}
parse_podman_ps_json(&output.stdout)
.with_context(|| format!("podman API list failed: {api_err}"))
}
}
}
async fn image_exists(&self, image_ref: &str) -> Result<bool> {
@ -147,6 +160,83 @@ impl ContainerRuntime for PodmanRuntime {
}
}
fn parse_podman_ps_json(stdout: &[u8]) -> Result<Vec<ContainerStatus>> {
let text = String::from_utf8_lossy(stdout);
if text.trim().is_empty() {
return Ok(Vec::new());
}
let containers: Vec<serde_json::Value> = serde_json::from_str(&text)?;
Ok(containers
.into_iter()
.map(|c| {
let name = c
.get("Names")
.and_then(|v| v.as_array())
.and_then(|a| a.first())
.and_then(|v| v.as_str())
.or_else(|| c.get("Names").and_then(|v| v.as_str()))
.unwrap_or("")
.to_string();
let status = c.get("Status").and_then(|v| v.as_str()).unwrap_or("");
let state = c.get("State").and_then(|v| v.as_str()).unwrap_or("unknown");
ContainerStatus {
id: c
.get("Id")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
name: name.clone(),
state: ContainerState::from(state),
health: parse_health_from_status(status),
exit_code: c.get("ExitCode").and_then(|v| v.as_i64()).map(|c| c as i32),
started_at: c
.get("StartedAt")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
image: c
.get("Image")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
created: c
.get("Created")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
ports: parse_podman_ps_ports(c.get("Ports")),
lan_address: PodmanClient::lan_address_for(&name),
}
})
.collect())
}
fn parse_podman_ps_ports(ports: Option<&serde_json::Value>) -> Vec<String> {
ports
.and_then(|v| v.as_array())
.map(|ports| {
ports
.iter()
.filter_map(|port| {
let host = port.get("host_port").and_then(|v| v.as_u64())?;
let container = port.get("container_port").and_then(|v| v.as_u64())?;
let proto = port
.get("protocol")
.and_then(|v| v.as_str())
.unwrap_or("tcp");
Some(format!("0.0.0.0:{host}->{container}/{proto}"))
})
.collect()
})
.unwrap_or_default()
}
fn parse_health_from_status(status: &str) -> Option<String> {
let start = status.rfind('(')?;
let end = status.rfind(')')?;
(start < end).then(|| status[start + 1..end].to_string())
}
/// Build the argv for `podman build` from a BuildConfig.
///
/// Extracted so it can be unit-tested without actually invoking podman.
@ -646,4 +736,36 @@ mod tests {
let args = build_args_for_podman(&c);
assert_eq!(args.last().unwrap(), "/final/context");
}
#[test]
fn parse_podman_ps_json_handles_cli_output() {
let stdout = br#"[
{
"Id": "abc123",
"Names": ["mempool"],
"Image": "docker.io/mempool/frontend:latest",
"State": "running",
"Status": "Up 2 minutes (healthy)",
"Created": "2026-05-03T00:00:00Z",
"StartedAt": "2026-05-03T00:01:00Z",
"ExitCode": 0,
"Ports": [
{
"host_port": 4080,
"container_port": 8080,
"protocol": "tcp"
}
]
}
]"#;
let containers = parse_podman_ps_json(stdout).unwrap();
assert_eq!(containers.len(), 1);
assert_eq!(containers[0].id, "abc123");
assert_eq!(containers[0].name, "mempool");
assert_eq!(containers[0].state, ContainerState::Running);
assert_eq!(containers[0].health.as_deref(), Some("healthy"));
assert_eq!(containers[0].exit_code, Some(0));
assert_eq!(containers[0].ports, vec!["0.0.0.0:4080->8080/tcp"]);
}
}

View File

@ -35,7 +35,7 @@ See the Architecture documentation for detailed system information.
## What's Included
- **Debian Linux Base**: Stable Debian 13 (Trixie) distribution
- **Debian Linux Base**: Debian 13 (Trixie) with security updates applied during ISO/install creation
- **Podman**: Container runtime for apps (rootless by default)
- **Archipelago Backend**: Rust-based API server
- **Archipelago Frontend**: Vue.js web interface
@ -44,7 +44,7 @@ See the Architecture documentation for detailed system information.
## Build Output
- `results/archipelago-debian-13-x86_64.iso` - Bootable hybrid ISO image
- `results/archipelago-installer-x86_64.iso` - Bootable hybrid ISO image
## Supported Platforms

View File

@ -292,7 +292,7 @@ RUN echo "deb http://deb.debian.org/debian trixie main non-free-firmware" > /etc
rm -f /etc/apt/sources.list.d/debian.sources
# Install all packages we need including nginx, podman, tor, and openssl (for self-signed certs)
RUN apt-get update && apt-get install -y --no-install-recommends \
RUN apt-get update && apt-get -y full-upgrade && apt-get install -y --no-install-recommends \
${LINUX_IMAGE_PKG} \
${GRUB_EFI_PKG} \
${GRUB_EFI_SIGNED_PKG} \
@ -359,13 +359,13 @@ RUN find /usr/share/doc -depth -type f ! -name copyright -delete 2>/dev/null ||
# Install Tailscale from official repo
RUN curl -fsSL https://pkgs.tailscale.com/stable/debian/trixie.noarmor.gpg | tee /usr/share/keyrings/tailscale-archive-keyring.gpg >/dev/null && \
curl -fsSL https://pkgs.tailscale.com/stable/debian/trixie.tailscale-keyring.list | tee /etc/apt/sources.list.d/tailscale.list && \
apt-get update && apt-get install -y --no-install-recommends tailscale && \
apt-get update && apt-get -y full-upgrade && apt-get install -y --no-install-recommends tailscale && \
apt-get clean && rm -rf /var/lib/apt/lists/*
# Install FIPS mesh daemon from the .deb built in stage 1. apt-get install
# resolves dependencies from trixie so a cross-dist build still lands cleanly.
COPY --from=fips-builder /tmp/fips.deb /tmp/fips.deb
RUN apt-get update && apt-get install -y --no-install-recommends /tmp/fips.deb && \
RUN apt-get update && apt-get -y full-upgrade && apt-get install -y --no-install-recommends /tmp/fips.deb && \
apt-get clean && rm -rf /var/lib/apt/lists/* && rm /tmp/fips.deb
# Configure locale
@ -693,6 +693,7 @@ mount --bind /proc /installer/proc
mount --bind /sys /installer/sys
mount --bind /dev /installer/dev
chroot /installer apt-get update -qq
chroot /installer apt-get -y -qq full-upgrade
chroot /installer apt-get install -y --no-install-recommends live-boot live-boot-initramfs-tools
chroot /installer apt-get clean
umount /installer/dev 2>/dev/null || true

View File

@ -157,6 +157,9 @@ EOF
echo "📥 Updating package lists..."
chroot /mnt/archipelago apt-get update
echo "🔒 Applying Debian security updates..."
chroot /mnt/archipelago apt-get -y full-upgrade
echo "📦 Installing kernel and bootloader..."
chroot /mnt/archipelago apt-get install -y linux-image-amd64 grub-efi-amd64 grub-efi-amd64-signed shim-signed

View File

@ -0,0 +1,43 @@
#!/usr/bin/env bash
# Build the Archipelago Debian installer ISO.
#
# The historical ISO builder remains archived because OTA tarballs are the
# normal release path. This wrapper keeps the documented ISO command working
# by running a temporary active-layout copy of that builder with fixed paths.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ARCHIVED_BUILDER="$SCRIPT_DIR/_archived/build-auto-installer-iso.sh"
TMP_DIR="$(mktemp -d -t archipelago-iso-builder.XXXXXX)"
cleanup() {
rm -rf "$TMP_DIR"
}
trap cleanup EXIT
if [ ! -f "$ARCHIVED_BUILDER" ]; then
echo "Archived ISO builder not found: $ARCHIVED_BUILDER" >&2
exit 1
fi
TMP_BUILDER="$TMP_DIR/build-auto-installer-iso.sh"
cp "$ARCHIVED_BUILDER" "$TMP_BUILDER"
# The archived builder lived one directory deeper at image-recipe/_archived/.
# Rewrite only path expressions that were relative to that old location.
perl -0pi -e 's#SCRIPT_DIR="\$\(cd "\$\(dirname "\$0"\)" && pwd\)"#SCRIPT_DIR="__ARCHIPELAGO_IMAGE_RECIPE_DIR__"#g' "$TMP_BUILDER"
perl -0pi -e 's#\$SCRIPT_DIR/\.\./\.\./scripts#\$SCRIPT_DIR/../scripts#g' "$TMP_BUILDER"
perl -0pi -e 's#\$SCRIPT_DIR/\.\./configs#\$SCRIPT_DIR/configs#g' "$TMP_BUILDER"
perl -0pi -e 's#\$SCRIPT_DIR/\.\./docker#\$SCRIPT_DIR/../docker#g' "$TMP_BUILDER"
perl -0pi -e 's#\$SCRIPT_DIR/\.\./neode-ui#\$SCRIPT_DIR/../neode-ui#g' "$TMP_BUILDER"
perl -0pi -e 's#\$SCRIPT_DIR/\.\./web#\$SCRIPT_DIR/../web#g' "$TMP_BUILDER"
perl -0pi -e 's#\$SCRIPT_DIR/\.\./demo#\$SCRIPT_DIR/../demo#g' "$TMP_BUILDER"
perl -0pi -e 's#\$SCRIPT_DIR/\.\./apps#\$SCRIPT_DIR/../apps#g' "$TMP_BUILDER"
perl -0pi -e 's#\$SCRIPT_DIR/\.\./core#\$SCRIPT_DIR/../core#g' "$TMP_BUILDER"
perl -0pi -e 's#"\$\(dirname "\$0"\)/\.\./\.\./scripts#"$(dirname "$0")/../scripts#g' "$TMP_BUILDER"
perl -0pi -e "s#__ARCHIPELAGO_IMAGE_RECIPE_DIR__#${SCRIPT_DIR}#g" "$TMP_BUILDER"
chmod +x "$TMP_BUILDER"
exec bash "$TMP_BUILDER" "$@"

View File

@ -544,7 +544,7 @@ server {
add_header Referrer-Policy strict-origin-when-cross-origin always;
}
location /app/lnd/ {
proxy_pass http://127.0.0.1:8081/;
proxy_pass http://127.0.0.1:18083/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;

View File

@ -102,7 +102,7 @@ location /app/endurain/ {
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
}
location /app/lnd/ {
proxy_pass http://127.0.0.1:8081/;
proxy_pass http://127.0.0.1:18083/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;

View File

@ -20,13 +20,18 @@ if [ -z "$1" ]; then
fi
USB_DISK="$1"
ISO_FILE="$SCRIPT_DIR/results/archipelago-debian-13-x86_64.iso"
ISO_FILE="${ARCHIPELAGO_ISO:-}"
if [ -z "$ISO_FILE" ]; then
ISO_FILE="$SCRIPT_DIR/results/archipelago-installer-x86_64.iso"
[ -f "$ISO_FILE" ] || ISO_FILE="$SCRIPT_DIR/results/archipelago-installer-unbundled-x86_64.iso"
fi
WORK_DIR="$SCRIPT_DIR/build/usb-extract"
if [ ! -f "$ISO_FILE" ]; then
echo "❌ ISO not found: $ISO_FILE"
echo ""
echo "Build the ISO first with: ./build-debian-iso.sh"
echo "Or set ARCHIPELAGO_ISO=/path/to/archipelago-installer-x86_64.iso"
exit 1
fi

View File

@ -17,12 +17,17 @@ fi
USB_DISK="$1"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ISO_FILE="$SCRIPT_DIR/results/archipelago-debian-13-x86_64.iso"
ISO_FILE="${ARCHIPELAGO_ISO:-}"
if [ -z "$ISO_FILE" ]; then
ISO_FILE="$SCRIPT_DIR/results/archipelago-installer-x86_64.iso"
[ -f "$ISO_FILE" ] || ISO_FILE="$SCRIPT_DIR/results/archipelago-installer-unbundled-x86_64.iso"
fi
if [ ! -f "$ISO_FILE" ]; then
echo "❌ ISO not found: $ISO_FILE"
echo ""
echo "Build the ISO first with: ./build-debian-iso.sh"
echo "Or set ARCHIPELAGO_ISO=/path/to/archipelago-installer-x86_64.iso"
exit 1
fi

1
neode-ui/.gitignore vendored
View File

@ -17,6 +17,7 @@ dist-ssr
!.vscode/extensions.json
.idea
.DS_Store
._*
*.suo
*.ntvs*
*.njsproj

View File

@ -0,0 +1,66 @@
import { expect, test, type Page } from '@playwright/test'
const PASSWORD = process.env.ARCHY_PASSWORD ?? 'password123'
const APP_ID = process.env.ARCHY_APP_ID ?? 'lnd'
const APP_TITLE = process.env.ARCHY_APP_TITLE ?? APP_ID
const APP_CARD_TITLE = process.env.ARCHY_APP_CARD_TITLE ?? APP_TITLE
const EXPECTED_URL = process.env.ARCHY_EXPECTED_LAUNCH_URL
const EXPECTED_URL_PATTERN = process.env.ARCHY_EXPECTED_LAUNCH_URL_PATTERN
const EXPECTED_BODY_PATTERN = process.env.ARCHY_EXPECTED_BODY_PATTERN ?? 'Connect Your Wallet|lndconnect|REST|gRPC'
const EXPECTED_MODE = process.env.ARCHY_EXPECTED_LAUNCH_MODE ?? 'popup'
async function login(page: Page) {
await page.goto('/login', { waitUntil: 'domcontentloaded' })
await page.evaluate(() => {
localStorage.setItem('neode_intro_seen', '1')
localStorage.setItem('neode_onboarding_complete', '1')
})
await page.goto('/login', { waitUntil: 'networkidle' })
const passwordInput = page.locator('input[type="password"]').first()
await passwordInput.waitFor({ timeout: 15_000 })
await passwordInput.fill(PASSWORD)
await page.locator('button:has-text("Login"), button:has-text("Unlock"), button:has-text("Continue"), button[type="submit"]').first().click()
await page.waitForURL('**/dashboard**', { timeout: 20_000 })
}
test('installed app launch opens reachable app URL', async ({ page, context, baseURL }) => {
test.skip(!EXPECTED_URL, 'Set ARCHY_EXPECTED_LAUNCH_URL for launch qualification')
await login(page)
await page.goto('/dashboard/apps', { waitUntil: 'domcontentloaded' })
const appCard = page.locator('[data-controller-container]', {
has: page.getByRole('heading', { name: APP_CARD_TITLE, exact: true }),
}).first()
await appCard.waitFor({ timeout: 30_000 })
const launchButton = appCard.locator('[data-controller-launch-btn], button:has-text("Launch")').first()
await launchButton.waitFor({ timeout: 20_000 })
if (EXPECTED_MODE === 'panel') {
await launchButton.click()
const expected = new URL(EXPECTED_URL!, baseURL)
const frameSelector = `iframe[src^="${expected.toString().replace(/\/$/, '')}"]`
await expect(page.locator(frameSelector).first()).toBeVisible({ timeout: 20_000 })
const frame = page.frameLocator(frameSelector).first()
await expect(frame.locator('body')).toContainText(new RegExp(EXPECTED_BODY_PATTERN, 'i'), { timeout: 30_000 })
return
}
const popupPromise = context.waitForEvent('page', { timeout: 15_000 })
await launchButton.click()
const popup = await popupPromise
await popup.waitForLoadState('domcontentloaded', { timeout: 20_000 })
assertLaunchUrl(popup.url(), baseURL)
await expect(popup.locator('body')).toContainText(new RegExp(EXPECTED_BODY_PATTERN, 'i'), { timeout: 20_000 })
})
function assertLaunchUrl(actual: string, baseURL: string | undefined) {
if (EXPECTED_URL_PATTERN) {
expect(actual).toMatch(new RegExp(EXPECTED_URL_PATTERN))
} else {
const expected = new URL(EXPECTED_URL!, baseURL)
expect(actual).toBe(expected.toString())
}
}

View File

@ -1,12 +1,12 @@
{
"name": "neode-ui",
"version": "1.7.51-alpha",
"version": "1.7.52-alpha",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "neode-ui",
"version": "1.7.51-alpha",
"version": "1.7.52-alpha",
"dependencies": {
"@types/dompurify": "^3.0.5",
"@vue-leaflet/vue-leaflet": "^0.10.1",
@ -2966,9 +2966,9 @@
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/codegen": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz",
"integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==",
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz",
"integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==",
"dev": true,
"license": "BSD-3-Clause"
},
@ -2998,9 +2998,9 @@
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/inquire": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz",
"integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.1.tgz",
"integrity": "sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==",
"dev": true,
"license": "BSD-3-Clause"
},
@ -3019,9 +3019,9 @@
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/utf8": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz",
"integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz",
"integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==",
"dev": true,
"license": "BSD-3-Clause"
},
@ -3045,10 +3045,37 @@
"dev": true,
"license": "MIT"
},
"node_modules/@rollup/plugin-babel": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-6.1.0.tgz",
"integrity": "sha512-dFZNuFD2YRcoomP4oYf+DvQNSUA9ih+A3vUqopQx5EdtPGo3WBnQcI/S8pwpz91UsGfL0HsMSOlaMld8HrbubA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-module-imports": "^7.18.6",
"@rollup/pluginutils": "^5.0.1"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0",
"@types/babel__core": "^7.1.9",
"rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
},
"peerDependenciesMeta": {
"@types/babel__core": {
"optional": true
},
"rollup": {
"optional": true
}
}
},
"node_modules/@rollup/plugin-node-resolve": {
"version": "15.3.1",
"resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.1.tgz",
"integrity": "sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA==",
"version": "16.0.3",
"resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz",
"integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -3070,19 +3097,41 @@
}
}
},
"node_modules/@rollup/plugin-terser": {
"version": "0.4.4",
"resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-0.4.4.tgz",
"integrity": "sha512-XHeJC5Bgvs8LfukDwWZp7yeqin6ns8RTl2B9avbejt6tZqsqvVoWI7ZTQrcNsfKEDWBTnTxM8nMDkO2IFFbd0A==",
"node_modules/@rollup/plugin-replace": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-6.0.3.tgz",
"integrity": "sha512-J4RZarRvQAm5IF0/LwUUg+obsm+xZhYnbMXmXROyoSE1ATJe3oXSb9L5MMppdxP2ylNSjv6zFBwKYjcKMucVfA==",
"dev": true,
"license": "MIT",
"dependencies": {
"serialize-javascript": "^6.0.1",
"@rollup/pluginutils": "^5.0.1",
"magic-string": "^0.30.3"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
},
"peerDependenciesMeta": {
"rollup": {
"optional": true
}
}
},
"node_modules/@rollup/plugin-terser": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-1.0.0.tgz",
"integrity": "sha512-FnCxhTBx6bMOYQrar6C8h3scPt8/JwIzw3+AJ2K++6guogH5fYaIFia+zZuhqv0eo1RN7W1Pz630SyvLbDjhtQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"serialize-javascript": "^7.0.3",
"smob": "^1.0.0",
"terser": "^5.17.4"
},
"engines": {
"node": ">=14.0.0"
"node": ">=20.0.0"
},
"peerDependencies": {
"rollup": "^2.0.0||^3.0.0||^4.0.0"
@ -3124,9 +3173,9 @@
"license": "MIT"
},
"node_modules/@rollup/pluginutils/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
@ -3486,27 +3535,20 @@
"win32"
]
},
"node_modules/@surma/rollup-plugin-off-main-thread": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz",
"integrity": "sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==",
"node_modules/@trickfilm400/rollup-plugin-off-main-thread": {
"version": "3.0.0-pre1",
"resolved": "https://registry.npmjs.org/@trickfilm400/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-3.0.0-pre1.tgz",
"integrity": "sha512-/67zpWDBLV+oYAEL682s1ktXL0HgqX76f6gaVGkGnVZlBbm1zd0v4Bz8MFF2GGhoX9rvfq3KSQHubFHwa6w6/Q==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"ejs": "^3.1.6",
"json5": "^2.2.0",
"magic-string": "^0.25.0",
"string.prototype.matchall": "^4.0.6"
}
"ejs": "^3.1.10",
"json5": "^2.2.3",
"magic-string": "^0.30.21",
"string.prototype.matchall": "^4.0.12"
},
"node_modules/@surma/rollup-plugin-off-main-thread/node_modules/magic-string": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz",
"integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"sourcemap-codec": "^1.4.8"
"engines": {
"node": ">=12"
}
},
"node_modules/@types/chai": {
@ -4238,9 +4280,9 @@
}
},
"node_modules/@vue/language-core/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
@ -4802,9 +4844,9 @@
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -4921,15 +4963,15 @@
}
},
"node_modules/call-bind": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
"integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==",
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
"integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.0",
"es-define-property": "^1.0.0",
"get-intrinsic": "^1.2.4",
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"get-intrinsic": "^1.3.0",
"set-function-length": "^1.2.2"
},
"engines": {
@ -6117,9 +6159,9 @@
}
},
"node_modules/defu": {
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz",
"integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==",
"version": "6.1.7",
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz",
"integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
"dev": true,
"license": "MIT"
},
@ -6194,9 +6236,9 @@
"license": "MIT"
},
"node_modules/docker-modem": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-5.0.6.tgz",
"integrity": "sha512-ens7BiayssQz/uAxGzH8zGXCtiV24rRWXdjNha5V4zSOcxmAZsfGVm/PPFbwQdqEkDnhG+SyR9E3zSHUbOKXBQ==",
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-5.0.7.tgz",
"integrity": "sha512-XJgGhoR/CLpqshm4d3L7rzH6t8NgDFUIIpztYlLHIApeJjMZKYJMz2zxPsYxnejq5h3ELYSw/RBsi3t5h7gNTA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@ -6210,16 +6252,16 @@
}
},
"node_modules/dockerode": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/dockerode/-/dockerode-4.0.9.tgz",
"integrity": "sha512-iND4mcOWhPaCNh54WmK/KoSb35AFqPAUWFMffTQcp52uQt36b5uNwEJTSXntJZBbeGad72Crbi/hvDIv6us/6Q==",
"version": "4.0.12",
"resolved": "https://registry.npmjs.org/dockerode/-/dockerode-4.0.12.tgz",
"integrity": "sha512-/bCZd6KlGcjZO8Buqmi/vXuqEGVEZ0PNjx/biBNqJD3MhK9DmdiAuKxqfNhflgDESDIiBz3qF+0e55+CpnrUcw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@balena/dockerignore": "^1.0.2",
"@grpc/grpc-js": "^1.11.1",
"@grpc/proto-loader": "^0.7.13",
"docker-modem": "^5.0.6",
"docker-modem": "^5.0.7",
"protobufjs": "^7.3.2",
"tar-fs": "^2.1.4",
"uuid": "^10.0.0"
@ -6229,9 +6271,9 @@
}
},
"node_modules/dompurify": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
"integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==",
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.2.tgz",
"integrity": "sha512-lHeS9SA/IKeIFFyYciHBr2n0v1VMPlSj843HdLOwjb2OxNwdq9Xykxqhk+FE42MzAdHvInbAolSE4mhahPpjXA==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
@ -6349,9 +6391,9 @@
}
},
"node_modules/es-abstract": {
"version": "1.24.1",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz",
"integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==",
"version": "1.24.2",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz",
"integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -6570,6 +6612,19 @@
"node": ">=0.10.0"
}
},
"node_modules/eta": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/eta/-/eta-4.6.0.tgz",
"integrity": "sha512-lW6is4T1NFOYnmqGZIfvixqj7A7sSvScF+DN8EK6K58xI5MZ5UvYe0GjopxOXQtZvUn4eDdVuZ8XSoYWTMEKwA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/bgub/eta?sponsor=1"
}
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
@ -8214,13 +8269,6 @@
"node": ">=8"
}
},
"node_modules/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"dev": true,
"license": "MIT"
},
"node_modules/lodash.camelcase": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
@ -8466,9 +8514,9 @@
}
},
"node_modules/nan": {
"version": "2.25.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.25.0.tgz",
"integrity": "sha512-0M90Ag7Xn5KMLLZ7zliPWP3rT90P6PN+IzVFS0VqmnPktBk3700xUVv8Ikm9EUaUE5SDWdp/BIxdENzVznpm1g==",
"version": "2.26.2",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.26.2.tgz",
"integrity": "sha512-0tTvBTYkt3tdGw22nrAy50x7gpbGCCFH3AFcyS5WiUu7Eu4vWlri1woE6qHBSfy11vksDqkiwjOnlR7WV8G1Hw==",
"dev": true,
"license": "MIT",
"optional": true
@ -8763,9 +8811,9 @@
}
},
"node_modules/path-to-regexp": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"dev": true,
"license": "MIT"
},
@ -8799,9 +8847,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true,
"license": "MIT",
"engines": {
@ -8904,9 +8952,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.8",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
"version": "8.5.14",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
"funding": [
{
"type": "opencollective",
@ -9086,23 +9134,23 @@
"license": "ISC"
},
"node_modules/protobufjs": {
"version": "7.5.4",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz",
"integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==",
"version": "7.5.6",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.6.tgz",
"integrity": "sha512-M71sTMB146U3u0di3yup8iM+zv8yPRNQVr1KK4tyBitl3qFvEGucq/rGDRShD2rsJhtN02RJaJ7j5X5hmy8SJg==",
"dev": true,
"hasInstallScript": true,
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.2",
"@protobufjs/base64": "^1.1.2",
"@protobufjs/codegen": "^2.0.4",
"@protobufjs/codegen": "^2.0.5",
"@protobufjs/eventemitter": "^1.1.0",
"@protobufjs/fetch": "^1.1.0",
"@protobufjs/float": "^1.0.2",
"@protobufjs/inquire": "^1.1.0",
"@protobufjs/inquire": "^1.1.1",
"@protobufjs/path": "^1.1.2",
"@protobufjs/pool": "^1.1.0",
"@protobufjs/utf8": "^1.1.0",
"@protobufjs/utf8": "^1.1.1",
"@types/node": ">=13.7.0",
"long": "^5.0.0"
},
@ -9323,16 +9371,6 @@
],
"license": "MIT"
},
"node_modules/randombytes": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
"integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"safe-buffer": "^5.1.0"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@ -9661,15 +9699,15 @@
}
},
"node_modules/safe-array-concat": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz",
"integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==",
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz",
"integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind": "^1.0.8",
"call-bound": "^1.0.2",
"get-intrinsic": "^1.2.6",
"call-bind": "^1.0.9",
"call-bound": "^1.0.4",
"get-intrinsic": "^1.3.0",
"has-symbols": "^1.1.0",
"isarray": "^2.0.5"
},
@ -9811,13 +9849,13 @@
"license": "MIT"
},
"node_modules/serialize-javascript": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz",
"integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==",
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.5.tgz",
"integrity": "sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"randombytes": "^2.1.0"
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/serve-static": {
@ -10175,14 +10213,6 @@
"webidl-conversions": "^4.0.2"
}
},
"node_modules/sourcemap-codec": {
"version": "1.4.8",
"resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz",
"integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==",
"deprecated": "Please use @jridgewell/sourcemap-codec instead",
"dev": true,
"license": "MIT"
},
"node_modules/speakingurl": {
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz",
@ -10731,9 +10761,9 @@
}
},
"node_modules/test-exclude/node_modules/brace-expansion": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -10832,9 +10862,9 @@
}
},
"node_modules/tinyglobby/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
@ -11328,9 +11358,9 @@
}
},
"node_modules/vite": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"version": "7.3.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -11490,9 +11520,9 @@
}
},
"node_modules/vite/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
@ -11576,9 +11606,9 @@
}
},
"node_modules/vitest/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
@ -11891,30 +11921,30 @@
}
},
"node_modules/workbox-background-sync": {
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-7.4.0.tgz",
"integrity": "sha512-8CB9OxKAgKZKyNMwfGZ1XESx89GryWTfI+V5yEj8sHjFH8MFelUwYXEyldEK6M6oKMmn807GoJFUEA1sC4XS9w==",
"version": "7.4.1",
"resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-7.4.1.tgz",
"integrity": "sha512-HhT7KE8tOWDm02wRNshXUnUPofMlhenF2DBdUnDPOubhizzPeItkYTmAB6td1Z2cjYPa98vzEiPLEuzn5hN66g==",
"dev": true,
"license": "MIT",
"dependencies": {
"idb": "^7.0.1",
"workbox-core": "7.4.0"
"workbox-core": "7.4.1"
}
},
"node_modules/workbox-broadcast-update": {
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/workbox-broadcast-update/-/workbox-broadcast-update-7.4.0.tgz",
"integrity": "sha512-+eZQwoktlvo62cI0b+QBr40v5XjighxPq3Fzo9AWMiAosmpG5gxRHgTbGGhaJv/q/MFVxwFNGh/UwHZ/8K88lA==",
"version": "7.4.1",
"resolved": "https://registry.npmjs.org/workbox-broadcast-update/-/workbox-broadcast-update-7.4.1.tgz",
"integrity": "sha512-uAlgslKLvbQY+suirIdnBCSYrcgBhjp81Nj4l1lj/Jmj0MJO2CJERnCJjT0GFVwmReV0N+zs78K6gqd5gr9/+A==",
"dev": true,
"license": "MIT",
"dependencies": {
"workbox-core": "7.4.0"
"workbox-core": "7.4.1"
}
},
"node_modules/workbox-build": {
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/workbox-build/-/workbox-build-7.4.0.tgz",
"integrity": "sha512-Ntk1pWb0caOFIvwz/hfgrov/OJ45wPEhI5PbTywQcYjyZiVhT3UrwwUPl6TRYbTm4moaFYithYnl1lvZ8UjxcA==",
"version": "7.4.1",
"resolved": "https://registry.npmjs.org/workbox-build/-/workbox-build-7.4.1.tgz",
"integrity": "sha512-SDhxIvEAde9Gy/5w4Yo1Jh/M49Z0qE3q0oteyE8zGq0DScxFqVBcCtIXFuLtmtxRQZCMbf0prco4VyEu3KBQuw==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -11922,39 +11952,39 @@
"@babel/core": "^7.24.4",
"@babel/preset-env": "^7.11.0",
"@babel/runtime": "^7.11.2",
"@rollup/plugin-babel": "^5.2.0",
"@rollup/plugin-node-resolve": "^15.2.3",
"@rollup/plugin-replace": "^2.4.1",
"@rollup/plugin-terser": "^0.4.3",
"@surma/rollup-plugin-off-main-thread": "^2.2.3",
"@rollup/plugin-babel": "^6.1.0",
"@rollup/plugin-node-resolve": "^16.0.3",
"@rollup/plugin-replace": "^6.0.3",
"@rollup/plugin-terser": "^1.0.0",
"@trickfilm400/rollup-plugin-off-main-thread": "^3.0.0-pre1",
"ajv": "^8.6.0",
"common-tags": "^1.8.0",
"eta": "^4.5.1",
"fast-json-stable-stringify": "^2.1.0",
"fs-extra": "^9.0.1",
"glob": "^11.0.1",
"lodash": "^4.17.20",
"pretty-bytes": "^5.3.0",
"rollup": "^2.79.2",
"rollup": "^4.53.3",
"source-map": "^0.8.0-beta.0",
"stringify-object": "^3.3.0",
"strip-comments": "^2.0.1",
"tempy": "^0.6.0",
"upath": "^1.2.0",
"workbox-background-sync": "7.4.0",
"workbox-broadcast-update": "7.4.0",
"workbox-cacheable-response": "7.4.0",
"workbox-core": "7.4.0",
"workbox-expiration": "7.4.0",
"workbox-google-analytics": "7.4.0",
"workbox-navigation-preload": "7.4.0",
"workbox-precaching": "7.4.0",
"workbox-range-requests": "7.4.0",
"workbox-recipes": "7.4.0",
"workbox-routing": "7.4.0",
"workbox-strategies": "7.4.0",
"workbox-streams": "7.4.0",
"workbox-sw": "7.4.0",
"workbox-window": "7.4.0"
"workbox-background-sync": "7.4.1",
"workbox-broadcast-update": "7.4.1",
"workbox-cacheable-response": "7.4.1",
"workbox-core": "7.4.1",
"workbox-expiration": "7.4.1",
"workbox-google-analytics": "7.4.1",
"workbox-navigation-preload": "7.4.1",
"workbox-precaching": "7.4.1",
"workbox-range-requests": "7.4.1",
"workbox-recipes": "7.4.1",
"workbox-routing": "7.4.1",
"workbox-strategies": "7.4.1",
"workbox-streams": "7.4.1",
"workbox-sw": "7.4.1",
"workbox-window": "7.4.1"
},
"engines": {
"node": ">=20.0.0"
@ -11970,69 +12000,6 @@
"node": ">=18"
}
},
"node_modules/workbox-build/node_modules/@rollup/plugin-babel": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz",
"integrity": "sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-module-imports": "^7.10.4",
"@rollup/pluginutils": "^3.1.0"
},
"engines": {
"node": ">= 10.0.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0",
"@types/babel__core": "^7.1.9",
"rollup": "^1.20.0||^2.0.0"
},
"peerDependenciesMeta": {
"@types/babel__core": {
"optional": true
}
}
},
"node_modules/workbox-build/node_modules/@rollup/plugin-replace": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-2.4.2.tgz",
"integrity": "sha512-IGcu+cydlUMZ5En85jxHH4qj2hta/11BHq95iHEyb2sbgiN0eCdzvUcHw5gt9pBL5lTi4JDYJ1acCoMGpTvEZg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@rollup/pluginutils": "^3.1.0",
"magic-string": "^0.25.7"
},
"peerDependencies": {
"rollup": "^1.20.0 || ^2.0.0"
}
},
"node_modules/workbox-build/node_modules/@rollup/pluginutils": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz",
"integrity": "sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "0.0.39",
"estree-walker": "^1.0.1",
"picomatch": "^2.2.2"
},
"engines": {
"node": ">= 8.0.0"
},
"peerDependencies": {
"rollup": "^1.20.0||^2.0.0"
}
},
"node_modules/workbox-build/node_modules/@types/estree": {
"version": "0.0.39",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz",
"integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==",
"dev": true,
"license": "MIT"
},
"node_modules/workbox-build/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
@ -12044,9 +12011,9 @@
}
},
"node_modules/workbox-build/node_modules/brace-expansion": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -12056,13 +12023,6 @@
"node": "18 || 20 || >=22"
}
},
"node_modules/workbox-build/node_modules/estree-walker": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-1.0.1.tgz",
"integrity": "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==",
"dev": true,
"license": "MIT"
},
"node_modules/workbox-build/node_modules/glob": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz",
@ -12114,16 +12074,6 @@
"node": "20 || >=22"
}
},
"node_modules/workbox-build/node_modules/magic-string": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz",
"integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"sourcemap-codec": "^1.4.8"
}
},
"node_modules/workbox-build/node_modules/minimatch": {
"version": "10.2.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
@ -12170,157 +12120,141 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/workbox-build/node_modules/rollup": {
"version": "2.80.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz",
"integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==",
"dev": true,
"license": "MIT",
"bin": {
"rollup": "dist/bin/rollup"
},
"engines": {
"node": ">=10.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
}
},
"node_modules/workbox-cacheable-response": {
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/workbox-cacheable-response/-/workbox-cacheable-response-7.4.0.tgz",
"integrity": "sha512-0Fb8795zg/x23ISFkAc7lbWes6vbw34DGFIMw31cwuHPgDEC/5EYm6m/ZkylLX0EnEbbOyOCLjKgFS/Z5g0HeQ==",
"version": "7.4.1",
"resolved": "https://registry.npmjs.org/workbox-cacheable-response/-/workbox-cacheable-response-7.4.1.tgz",
"integrity": "sha512-8xaFoJdDc2OjrlbbL3gEeBO1WKcMwRqwLRupgqahYXu75yXajPLuwrbXMrIGZuWYXrQwk0xDjOxZ/ujCy/oJYw==",
"dev": true,
"license": "MIT",
"dependencies": {
"workbox-core": "7.4.0"
"workbox-core": "7.4.1"
}
},
"node_modules/workbox-core": {
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.4.0.tgz",
"integrity": "sha512-6BMfd8tYEnN4baG4emG9U0hdXM4gGuDU3ectXuVHnj71vwxTFI7WOpQJC4siTOlVtGqCUtj0ZQNsrvi6kZZTAQ==",
"version": "7.4.1",
"resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.4.1.tgz",
"integrity": "sha512-DT+vu46eh/2vRsSHTY4Xmc32Z1rr9PRlQUXr1Dx30ZuXRWwOsvZgGgcwxcasubQLQmbTNYZjv44LkBAQ4tT5tQ==",
"dev": true,
"license": "MIT"
},
"node_modules/workbox-expiration": {
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/workbox-expiration/-/workbox-expiration-7.4.0.tgz",
"integrity": "sha512-V50p4BxYhtA80eOvulu8xVfPBgZbkxJ1Jr8UUn0rvqjGhLDqKNtfrDfjJKnLz2U8fO2xGQJTx/SKXNTzHOjnHw==",
"version": "7.4.1",
"resolved": "https://registry.npmjs.org/workbox-expiration/-/workbox-expiration-7.4.1.tgz",
"integrity": "sha512-lRKUF7b+OGbeXkQk1s6MHXOa3d7Xxf7Of31W6c6hCfipfIyrtdWZ89stq21AHZMaoG7VNFoHply4Ox+rU31TWg==",
"dev": true,
"license": "MIT",
"dependencies": {
"idb": "^7.0.1",
"workbox-core": "7.4.0"
"workbox-core": "7.4.1"
}
},
"node_modules/workbox-google-analytics": {
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/workbox-google-analytics/-/workbox-google-analytics-7.4.0.tgz",
"integrity": "sha512-MVPXQslRF6YHkzGoFw1A4GIB8GrKym/A5+jYDUSL+AeJw4ytQGrozYdiZqUW1TPQHW8isBCBtyFJergUXyNoWQ==",
"version": "7.4.1",
"resolved": "https://registry.npmjs.org/workbox-google-analytics/-/workbox-google-analytics-7.4.1.tgz",
"integrity": "sha512-Mks1JwLEt++ZAkF6sS1OpSh9RtAMIsiDgRpK+codiHGIPXeaUOgi4cPc3GFadUl8V5QPeypEk8Oxgl3HlwVzHw==",
"dev": true,
"license": "MIT",
"dependencies": {
"workbox-background-sync": "7.4.0",
"workbox-core": "7.4.0",
"workbox-routing": "7.4.0",
"workbox-strategies": "7.4.0"
"workbox-background-sync": "7.4.1",
"workbox-core": "7.4.1",
"workbox-routing": "7.4.1",
"workbox-strategies": "7.4.1"
}
},
"node_modules/workbox-navigation-preload": {
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/workbox-navigation-preload/-/workbox-navigation-preload-7.4.0.tgz",
"integrity": "sha512-etzftSgdQfjMcfPgbfaZCfM2QuR1P+4o8uCA2s4rf3chtKTq/Om7g/qvEOcZkG6v7JZOSOxVYQiOu6PbAZgU6w==",
"version": "7.4.1",
"resolved": "https://registry.npmjs.org/workbox-navigation-preload/-/workbox-navigation-preload-7.4.1.tgz",
"integrity": "sha512-C4KVsjPcYKJOhr631AxR9XoG2rLF3QiTk5aMv36MXOjtWvm8axwNFAtKUPGsWUwLXXAMgYM1En7fsvndaXeXRQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"workbox-core": "7.4.0"
"workbox-core": "7.4.1"
}
},
"node_modules/workbox-precaching": {
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-7.4.0.tgz",
"integrity": "sha512-VQs37T6jDqf1rTxUJZXRl3yjZMf5JX/vDPhmx2CPgDDKXATzEoqyRqhYnRoxl6Kr0rqaQlp32i9rtG5zTzIlNg==",
"version": "7.4.1",
"resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-7.4.1.tgz",
"integrity": "sha512-cdr/9qByww7yzEp7zg/qI4ukUrrNjQLgN+ONQRpjy/VqGQXwkgHwr00KksGJK8v0VifwDXBb8a4cWNZH71jn3Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"workbox-core": "7.4.0",
"workbox-routing": "7.4.0",
"workbox-strategies": "7.4.0"
"workbox-core": "7.4.1",
"workbox-routing": "7.4.1",
"workbox-strategies": "7.4.1"
}
},
"node_modules/workbox-range-requests": {
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/workbox-range-requests/-/workbox-range-requests-7.4.0.tgz",
"integrity": "sha512-3Vq854ZNuP6Y0KZOQWLaLC9FfM7ZaE+iuQl4VhADXybwzr4z/sMmnLgTeUZLq5PaDlcJBxYXQ3U91V7dwAIfvw==",
"version": "7.4.1",
"resolved": "https://registry.npmjs.org/workbox-range-requests/-/workbox-range-requests-7.4.1.tgz",
"integrity": "sha512-7i2oxAUE82gHdAJBCAQ04JzNOdRPqzuOzGfoUyJpFSmeqBNYGPrAH8GPoPjUQTfp+NycwrD2H68VtuF8qxv0vQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"workbox-core": "7.4.0"
"workbox-core": "7.4.1"
}
},
"node_modules/workbox-recipes": {
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/workbox-recipes/-/workbox-recipes-7.4.0.tgz",
"integrity": "sha512-kOkWvsAn4H8GvAkwfJTbwINdv4voFoiE9hbezgB1sb/0NLyTG4rE7l6LvS8lLk5QIRIto+DjXLuAuG3Vmt3cxQ==",
"version": "7.4.1",
"resolved": "https://registry.npmjs.org/workbox-recipes/-/workbox-recipes-7.4.1.tgz",
"integrity": "sha512-gnbVfmV4/TtmQaM4x9AtuXhcdstJsep3XMVeztOrQVPT+R6+6DeBjGTCQ7fFCXm+4GEHUA5VEBTyi5+4gWGeog==",
"dev": true,
"license": "MIT",
"dependencies": {
"workbox-cacheable-response": "7.4.0",
"workbox-core": "7.4.0",
"workbox-expiration": "7.4.0",
"workbox-precaching": "7.4.0",
"workbox-routing": "7.4.0",
"workbox-strategies": "7.4.0"
"workbox-cacheable-response": "7.4.1",
"workbox-core": "7.4.1",
"workbox-expiration": "7.4.1",
"workbox-precaching": "7.4.1",
"workbox-routing": "7.4.1",
"workbox-strategies": "7.4.1"
}
},
"node_modules/workbox-routing": {
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/workbox-routing/-/workbox-routing-7.4.0.tgz",
"integrity": "sha512-C/ooj5uBWYAhAqwmU8HYQJdOjjDKBp9MzTQ+otpMmd+q0eF59K+NuXUek34wbL0RFrIXe/KKT+tUWcZcBqxbHQ==",
"version": "7.4.1",
"resolved": "https://registry.npmjs.org/workbox-routing/-/workbox-routing-7.4.1.tgz",
"integrity": "sha512-yubJGErZOusuidAenaL5ypfhQOa7urxP/f8E0ws7FPb4039RiWXUWBAyUkmUoOL/BcQGen3h0J8872d51IYxtA==",
"dev": true,
"license": "MIT",
"dependencies": {
"workbox-core": "7.4.0"
"workbox-core": "7.4.1"
}
},
"node_modules/workbox-strategies": {
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/workbox-strategies/-/workbox-strategies-7.4.0.tgz",
"integrity": "sha512-T4hVqIi5A4mHi92+5EppMX3cLaVywDp8nsyUgJhOZxcfSV/eQofcOA6/EMo5rnTNmNTpw0rUgjAI6LaVullPpg==",
"version": "7.4.1",
"resolved": "https://registry.npmjs.org/workbox-strategies/-/workbox-strategies-7.4.1.tgz",
"integrity": "sha512-GZxpaw9NbmOelj7667uZ2kpk5BFpOGbO4X0qjwh5ls8XQ8C+Lha5LQchTiUzsTFSS+NlUpftYAyOVXvQUrcqOQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"workbox-core": "7.4.0"
"workbox-core": "7.4.1"
}
},
"node_modules/workbox-streams": {
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/workbox-streams/-/workbox-streams-7.4.0.tgz",
"integrity": "sha512-QHPBQrey7hQbnTs5GrEVoWz7RhHJXnPT+12qqWM378orDMo5VMJLCkCM1cnCk+8Eq92lccx/VgRZ7WAzZWbSLg==",
"version": "7.4.1",
"resolved": "https://registry.npmjs.org/workbox-streams/-/workbox-streams-7.4.1.tgz",
"integrity": "sha512-HWWtraKUbJknd9kgqGcpQ3G114HOPYvqs8HaJMDs2ebLNAimDkVDaWfAXE6Ybl+m8U6KsCE6pWyLYuigWmnAXw==",
"dev": true,
"license": "MIT",
"dependencies": {
"workbox-core": "7.4.0",
"workbox-routing": "7.4.0"
"workbox-core": "7.4.1",
"workbox-routing": "7.4.1"
}
},
"node_modules/workbox-sw": {
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/workbox-sw/-/workbox-sw-7.4.0.tgz",
"integrity": "sha512-ltU+Kr3qWR6BtbdlMnCjobZKzeV1hN+S6UvDywBrwM19TTyqA03X66dzw1tEIdJvQ4lYKkBFox6IAEhoSEZ8Xw==",
"version": "7.4.1",
"resolved": "https://registry.npmjs.org/workbox-sw/-/workbox-sw-7.4.1.tgz",
"integrity": "sha512-fez5f2DUlDJWTFYkCWQpY10N8gtztd849NswCbVFk0QlcSM4HT5A8x4g4ii650yem4I8tHY0R7JZahwp3ltIPw==",
"dev": true,
"license": "MIT"
},
"node_modules/workbox-window": {
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/workbox-window/-/workbox-window-7.4.0.tgz",
"integrity": "sha512-/bIYdBLAVsNR3v7gYGaV4pQW3M3kEPx5E8vDxGvxo6khTrGtSSCS7QiFKv9ogzBgZiy0OXLP9zO28U/1nF1mfw==",
"version": "7.4.1",
"resolved": "https://registry.npmjs.org/workbox-window/-/workbox-window-7.4.1.tgz",
"integrity": "sha512-notZDH2u8VXaqyuD7xaqIfEFi6SRM4SUSd7ewe9PDsVqADuepxX2ZMY3uvuZGxzY5ZOsGC/vD3A/3smFtJt4/A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/trusted-types": "^2.0.2",
"workbox-core": "7.4.0"
"workbox-core": "7.4.1"
}
},
"node_modules/wrap-ansi": {

View File

@ -1,7 +1,7 @@
{
"name": "neode-ui",
"private": true,
"version": "1.7.51-alpha",
"version": "1.7.52-alpha",
"type": "module",
"scripts": {
"start": "./start-dev.sh",

View File

@ -8,7 +8,7 @@ export default defineConfig({
timeout: 10_000,
},
use: {
baseURL: 'http://192.168.1.228',
baseURL: process.env.ARCHY_BASE_URL ?? 'http://192.168.1.228',
viewport: { width: 1440, height: 900 },
screenshot: 'only-on-failure',
trace: 'off',

Binary file not shown.

After

Width:  |  Height:  |  Size: 408 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 408 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 241 KiB

View File

@ -85,7 +85,7 @@
"title": "ElectrumX",
"version": "1.18.0",
"description": "Electrum protocol server. Index the blockchain for fast wallet lookups.",
"icon": "/assets/img/app-icons/electrumx.webp",
"icon": "/assets/img/app-icons/electrumx.png",
"author": "Luke Childs",
"category": "money",
"tier": "core",

View File

@ -29,6 +29,11 @@ describe('useAppLauncherStore', () => {
writable: true,
configurable: true,
})
Object.defineProperty(window, 'innerWidth', {
value: 1024,
writable: true,
configurable: true,
})
})
it('starts closed with empty state', () => {
@ -50,6 +55,40 @@ describe('useAppLauncherStore', () => {
expect(mockWindowOpen).not.toHaveBeenCalled()
})
it('uses route-based app sessions on mobile instead of panel mode', () => {
Object.defineProperty(window, 'innerWidth', {
value: 390,
writable: true,
configurable: true,
})
const store = useAppLauncherStore()
store.openSession('indeedhub')
expect(store.panelAppId).toBe(null)
expect(mockPush).toHaveBeenCalledWith({ name: 'app-session', params: { appId: 'indeedhub' } })
})
it('normalizes localhost launch URLs to current host before resolving', () => {
const store = useAppLauncherStore()
store.open({ url: 'http://localhost:4080', title: 'Mempool' })
expect(store.isOpen).toBe(false)
expect(store.panelAppId).toBe('mempool')
expect(mockWindowOpen).not.toHaveBeenCalled()
})
it('normalizes localhost IndeeHub URLs to current host before resolving', () => {
const store = useAppLauncherStore()
store.open({ url: 'http://localhost:7778', title: 'IndeeHub' })
expect(store.isOpen).toBe(false)
expect(store.panelAppId).toBe('indeedhub')
expect(mockWindowOpen).not.toHaveBeenCalled()
})
it('routes BTCPay (port 23000) to full-page session', () => {
const store = useAppLauncherStore()

View File

@ -44,6 +44,11 @@ function inferAppIdFromTitle(title?: string): string | null {
function normalizeLaunchUrl(urlStr: string, appIdHint?: string | null): string {
try {
const u = new URL(urlStr)
let rewrittenLocalhost = false
if (u.hostname === 'localhost' || u.hostname === '127.0.0.1') {
u.hostname = window.location.hostname
rewrittenLocalhost = true
}
const sameHost = u.hostname === window.location.hostname
const normalizedPath = u.pathname === '/' ? '' : u.pathname
const rebuilt = (port: string) => `${u.protocol}//${u.hostname}:${port}${normalizedPath}${u.search}${u.hash}`
@ -60,7 +65,7 @@ function normalizeLaunchUrl(urlStr: string, appIdHint?: string | null): string {
return rebuilt('81')
}
return urlStr
return rewrittenLocalhost ? u.toString() : urlStr
} catch {
return urlStr
}
@ -73,7 +78,7 @@ const PORT_TO_APP_ID: Record<string, string> = {
'3000': 'grafana',
'3002': 'uptime-kuma',
'8080': 'endurain',
'8081': 'lnd',
'18083': 'lnd',
'8082': 'vaultwarden',
'8083': 'filebrowser',
'8085': 'nextcloud',
@ -143,7 +148,8 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
/** Open app in session view — panel mode uses store, overlay/fullscreen uses route */
function openSession(appId: string) {
const mode = localStorage.getItem(DISPLAY_MODE_KEY) || 'panel'
if (mode === 'panel') {
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
if (mode === 'panel' && !isMobile) {
panelAppId.value = appId
} else {
panelAppId.value = null

View File

@ -23,7 +23,7 @@ const CONTAINER_NAME_MAP: Record<string, string[]> = {
'bitcoin-knots': ['bitcoin-knots', 'bitcoin-ui'],
'lnd': ['lnd', 'archy-lnd-ui'],
'btcpay-server': ['btcpay-server'],
'mempool': ['archy-mempool-web'],
'mempool': ['mempool', 'archy-mempool-web'],
'electrumx': ['archy-electrs-ui', 'electrumx', 'mempool-electrs'],
}
@ -44,7 +44,7 @@ export const BUNDLED_APPS: BundledApp[] = [
image: 'docker.io/lightninglabs/lnd:v0.18.4-beta',
description: 'Lightning Network Daemon for fast Bitcoin payments',
icon: '⚡',
ports: [{ host: 8081, container: 80 }],
ports: [{ host: 18083, container: 80 }],
volumes: [{ host: '/var/lib/archipelago/lnd', container: '/root/.lnd' }],
category: 'lightning',
},

View File

@ -2062,9 +2062,9 @@ html:has(body.video-background-active)::before {
scroll-snap-align: start;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px 12px;
padding: 8px 4px 16px;
min-height: 0;
gap: 18px 10px;
padding: 8px 2px 16px;
align-content: start;
}
.app-icon-item {
@ -2085,7 +2085,7 @@ html:has(body.video-background-active)::before {
width: 60px;
height: 60px;
border-radius: 14px;
overflow: hidden;
overflow: visible;
background: rgba(255, 255, 255, 0.08);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
@ -2094,17 +2094,19 @@ html:has(body.video-background-active)::before {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 14px;
}
/* Status dot — top-right of icon */
.app-icon-status {
position: absolute;
top: -2px;
right: -2px;
width: 12px;
height: 12px;
top: -3px;
right: -3px;
width: 13px;
height: 13px;
border-radius: 50%;
border: 2px solid #000;
border: 2px solid rgba(0, 0, 0, 0.85);
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.16), 0 2px 6px rgba(0, 0, 0, 0.45);
}
.app-icon-status-running {
background: #22c55e;
@ -2143,22 +2145,30 @@ html:has(body.video-background-active)::before {
display: flex;
justify-content: center;
gap: 6px;
padding: 4px 0 8px;
padding: 2px 0 8px;
}
.app-icon-dot {
width: 7px;
height: 7px;
display: block;
flex: 0 0 auto;
width: 8px;
height: 8px;
min-width: 8px !important;
min-height: 8px !important;
border-radius: 50%;
background: rgba(255, 255, 255, 0.25);
border: none;
border: 1px solid rgba(255, 255, 255, 0.12);
padding: 0;
margin: 0;
appearance: none;
-webkit-appearance: none;
cursor: pointer;
transition: background 0.2s, transform 0.2s;
}
.app-icon-dot-active {
background: rgba(247, 147, 26, 0.9);
transform: scale(1.3);
border-color: rgba(247, 147, 26, 0.65);
transform: none;
}
/* ===== End App Icon Grid ===== */
@ -2574,4 +2584,3 @@ select.mesh-bitcoin-input option { background: #1a1a2e; color: rgba(255,255,255,
.mesh-deadman-field { display: flex; flex-direction: column; gap: 4px; }
.mesh-deadman-info { display: flex; gap: 12px; flex-wrap: wrap; }
.mesh-deadman-info-item { font-size: 0.75rem; color: rgba(255,255,255,0.4); }

View File

@ -58,6 +58,11 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
<button class="app-session-bar-btn" aria-label="Refresh" @click="refresh">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" :class="{ 'animate-spin': isRefreshing }">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v6h6M20 20v-6h-6M5.64 15.36A8 8 0 0018.36 18M18.36 8.64A8 8 0 005.64 6" />
</svg>
</button>
<button class="app-session-bar-btn" aria-label="Open in new tab" @click="openNewTab">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
@ -139,9 +144,7 @@ const appId = computed(() => {
const appTitle = computed(() => resolveAppTitle(appId.value))
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
// On mobile (Android WebView), all apps load in the iframe X-Frame-Options
// doesn't apply since the WebView is the top-level browsing context.
const mustOpenNewTab = computed(() => isMobile ? false : NEW_TAB_APPS.has(appId.value))
const mustOpenNewTab = computed(() => NEW_TAB_APPS.has(appId.value))
const appUrl = computed(() => {
return resolveAppUrl(appId.value, route.query.path as string | undefined)
@ -501,6 +504,17 @@ onBeforeUnmount(() => {
/* Mobile: full-bleed app sessions — no border, no radius, no shadow */
@media (max-width: 767px) {
.app-session-root {
height: 100%;
}
.app-session-inline {
height: 100%;
}
.app-session-overlay,
.app-session-fullscreen {
height: 100vh;
height: 100dvh;
}
.app-session-panel.glass-card {
border: none !important;
border-radius: 0 !important;
@ -511,14 +525,11 @@ onBeforeUnmount(() => {
backdrop-filter: none;
background: black;
}
/* Iframe frame: push content below status bar on mobile */
.app-session-frame-safe {
padding-top: var(--safe-area-top, env(safe-area-inset-top, 0px));
}
/* Iframe within padded container: fill remaining space */
.app-session-frame-safe iframe {
top: var(--safe-area-top, env(safe-area-inset-top, 0px));
height: calc(100% - var(--safe-area-top, env(safe-area-inset-top, 0px)));
flex: none !important;
height: calc(100vh - var(--app-session-mobile-bar-height, 84px));
height: calc(100dvh - var(--app-session-mobile-bar-height, 84px));
padding-bottom: 0;
}
}
@ -529,24 +540,38 @@ onBeforeUnmount(() => {
}
.app-session-mobile-bar {
display: flex;
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 2600;
justify-content: space-around;
align-items: center;
flex-shrink: 0;
padding: 12px 16px;
padding-bottom: calc(12px + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)));
min-height: var(--app-session-mobile-bar-height, 84px);
padding: 10px 16px;
padding-bottom: calc(10px + max(var(--safe-area-bottom, 0px), env(safe-area-inset-bottom, 0px), 10px));
background: rgba(0, 0, 0, 0.25);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
border-top: 1px solid rgba(255, 255, 255, 0.06);
transform: translateZ(0);
}
.app-session-inline .app-session-mobile-bar {
position: absolute;
z-index: 20;
}
.app-session-bar-btn {
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: 14px;
width: 52px;
height: 52px;
min-height: 52px;
border-radius: 13px;
color: rgba(255, 255, 255, 0.65);
transition: color 0.15s ease, background 0.15s ease;
}

View File

@ -201,6 +201,8 @@ const appLauncher = useAppLauncherStore()
const selectedCategory = ref('all')
const searchQuery = ref('')
const bitcoinPruned = ref(false)
const electrumxArchiveWarning = 'You need a full archival bitcoin node before downloading ElectrumX'
const categories = computed(() => [
{ id: 'all', name: 'All' },
@ -392,6 +394,11 @@ function launchInstalledApp(app: MarketplaceApp) {
}
function handleInstall(app: MarketplaceApp) {
const blocked = installBlockedReason(app.id)
if (blocked) {
toast.error(blocked)
return
}
if (app.source === 'local') {
installApp(app)
} else {
@ -432,6 +439,23 @@ onBeforeUnmount(() => {
const toast = useToast()
async function loadBitcoinPruneStatus() {
try {
const res = await fetch('/bitcoin-status', { credentials: 'include', signal: AbortSignal.timeout(8000) })
if (!res.ok) return
const status = await res.json()
bitcoinPruned.value = status?.blockchain_info?.pruned === true
} catch (e) {
if (import.meta.env.DEV) console.warn('[Discover] Bitcoin prune status unavailable:', e)
}
}
function installBlockedReason(appId: string): string | undefined {
if (!bitcoinPruned.value) return undefined
if (appId !== 'electrumx' && appId !== 'electrs' && appId !== 'mempool-electrs') return undefined
return electrumxArchiveWarning
}
function queueInstall(app: MarketplaceApp) {
serverStore.setInstallProgress(app.id, {
id: app.id,
@ -492,6 +516,7 @@ onMounted(() => {
if (communityApps.value.length === 0 && !loadingCommunity.value) {
loadCommunityMarketplace()
}
loadBitcoinPruneStatus()
})
const catalogFeatured = ref<CatalogFeatured | null>(null)
@ -512,4 +537,3 @@ async function loadCommunityMarketplace() {
loadingCommunity.value = false
}
</script>

View File

@ -70,6 +70,7 @@
:starting-up="isStartingUp(app.id)"
:containers-scanned="containersScanned"
:tier-label="getAppTier(app.id)"
:install-blocked-reason="installBlockedReason(app.id)"
@view="viewAppDetails"
@install="app.source === 'local' ? installApp(app) : installCommunityApp(app)"
@launch="launchInstalledApp"
@ -157,6 +158,7 @@ const categories = computed(() => [
// Installation state uses global store so it persists across navigation
const installingApps = server.installingApps
const electrumxArchiveWarning = 'You need a full archival bitcoin node before downloading ElectrumX'
// Install progress tracking is now in serverStore (global watcher on WebSocket data)
// so it works regardless of which page is active
@ -174,6 +176,7 @@ const loadingCommunity = ref(false)
const communityError = ref('')
const communityApps = ref<MarketplaceApp[]>([])
const searchQuery = ref('')
const bitcoinPruned = ref(false)
// Nostr community marketplace state
const nostrApps = ref<MarketplaceApp[]>([])
@ -309,8 +312,26 @@ onMounted(() => {
if (communityApps.value.length === 0 && !loadingCommunity.value) {
loadCommunityMarketplace()
}
loadBitcoinPruneStatus()
})
async function loadBitcoinPruneStatus() {
try {
const res = await fetch('/bitcoin-status', { credentials: 'include', signal: AbortSignal.timeout(8000) })
if (!res.ok) return
const status = await res.json()
bitcoinPruned.value = status?.blockchain_info?.pruned === true
} catch (e) {
if (import.meta.env.DEV) console.warn('[Marketplace] Bitcoin prune status unavailable:', e)
}
}
function installBlockedReason(appId: string): string | undefined {
if (!bitcoinPruned.value) return undefined
if (appId !== 'electrumx' && appId !== 'electrs' && appId !== 'mempool-electrs') return undefined
return electrumxArchiveWarning
}
async function loadCommunityMarketplace() {
loadingCommunity.value = true
communityError.value = ''
@ -379,6 +400,11 @@ function failInstall(app: MarketplaceApp, err: unknown) {
async function installApp(app: MarketplaceApp) {
if (installingApps.has(app.id) || isInstalled(app.id)) return
const blocked = installBlockedReason(app.id)
if (blocked) {
toast.error(blocked)
return
}
queueInstall(app)
toast.info("Installing " + (app.title ?? app.id) + " - check My Apps")
@ -399,6 +425,11 @@ async function installApp(app: MarketplaceApp) {
async function installCommunityApp(app: MarketplaceApp) {
if (installingApps.has(app.id) || isInstalled(app.id) || !app.dockerImage) return
const blocked = installBlockedReason(app.id)
if (blocked) {
toast.error(blocked)
return
}
queueInstall(app)
toast.info("Installing " + (app.title ?? app.id) + " - check My Apps")

View File

@ -84,7 +84,8 @@
<button
v-else
@click="installApp"
:disabled="installing || (!app.manifestUrl && !app.dockerImage)"
:disabled="installing || (!installBlockedReason && !app.manifestUrl && !app.dockerImage)"
:title="installBlockedReason || undefined"
class="glass-button glass-button-sm px-6 py-2.5 rounded-lg text-sm font-semibold flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg v-if="installing" class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
@ -94,7 +95,7 @@
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
{{ installing ? t('common.installing') : t('common.install') }}
{{ installBlockedReason ? 'Bitcoin Pruned' : installing ? t('common.installing') : t('common.install') }}
</button>
</div>
</div>
@ -149,7 +150,8 @@
<button
v-else
@click="installApp"
:disabled="installing || (!app.manifestUrl && !app.dockerImage)"
:disabled="installing || (!installBlockedReason && !app.manifestUrl && !app.dockerImage)"
:title="installBlockedReason || undefined"
class="glass-button glass-button-sm px-4 py-2.5 rounded-lg text-sm font-semibold flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed col-span-2"
>
<svg v-if="installing" class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
@ -159,7 +161,7 @@
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
{{ installing ? t('common.installing') : t('common.install') }}
{{ installBlockedReason ? 'Bitcoin Pruned' : installing ? t('common.installing') : t('common.install') }}
</button>
</div>
@ -189,6 +191,10 @@
</div>
</div>
</div>
<div v-if="installBlockedReason" class="hidden md:block mt-4 p-4 bg-yellow-500/15 border border-yellow-500/30 rounded-lg">
<p class="text-yellow-100 font-medium">Bitcoin is in pruned mode</p>
<p class="text-yellow-200/80 text-sm mt-1">{{ installBlockedReason }}</p>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
@ -375,9 +381,11 @@ import { rpcClient } from '../api/rpc-client'
import { useMarketplaceApp, type MarketplaceAppInfo } from '../composables/useMarketplaceApp'
import { useMobileBackButton } from '../composables/useMobileBackButton'
import { useAppLauncherStore } from '../stores/appLauncher'
import { useToast } from '../composables/useToast'
const { t } = useI18n()
const { bottomPosition } = useMobileBackButton()
const toast = useToast()
const router = useRouter()
const route = useRoute()
@ -389,6 +397,8 @@ const installing = ref(false)
const installingDeps = ref(false)
const installError = ref<string | null>(null)
const loading = ref(true)
const bitcoinPruned = ref(false)
const electrumxArchiveWarning = 'You need a full archival bitcoin node before downloading ElectrumX'
const appId = computed(() => route.params.id as string)
@ -471,6 +481,13 @@ const dependencies = computed(() => {
})
})
const installBlockedReason = computed(() => {
const id = app.value?.id
if (!bitcoinPruned.value || !id) return ''
if (id !== 'electrumx' && id !== 'electrs' && id !== 'mempool-electrs') return ''
return electrumxArchiveWarning
})
let pendingRedirect: ReturnType<typeof setTimeout> | null = null
onMounted(() => {
@ -495,8 +512,20 @@ onMounted(() => {
router.push('/dashboard/marketplace').catch(() => {})
}, 500)
}
loadBitcoinPruneStatus()
})
async function loadBitcoinPruneStatus() {
try {
const res = await fetch('/bitcoin-status', { credentials: 'include', signal: AbortSignal.timeout(8000) })
if (!res.ok) return
const status = await res.json()
bitcoinPruned.value = status?.blockchain_info?.pruned === true
} catch (e) {
if (import.meta.env.DEV) console.warn('[MarketplaceAppDetails] Bitcoin prune status unavailable:', e)
}
}
onBeforeUnmount(() => {
if (pendingRedirect) { clearTimeout(pendingRedirect); pendingRedirect = null }
})
@ -533,6 +562,11 @@ async function installDependencies() {
if (installingDeps.value) return
const missingDeps = dependencies.value.filter(d => d.status === 'missing')
if (!missingDeps.length) return
if (bitcoinPruned.value && missingDeps.some(d => d.id === 'electrumx' || d.id === 'electrs' || d.id === 'mempool-electrs')) {
installError.value = electrumxArchiveWarning
toast.error(electrumxArchiveWarning)
return
}
installingDeps.value = true
installError.value = null
@ -561,6 +595,11 @@ async function installDependencies() {
async function installApp() {
if (installing.value || !app.value) return
if (installBlockedReason.value) {
installError.value = installBlockedReason.value
toast.error(installBlockedReason.value)
return
}
if (!app.value.manifestUrl && !app.value.dockerImage) {
if (import.meta.env.DEV) console.warn('[MarketplaceAppDetails] Cannot install - no manifestUrl or dockerImage:', app.value)
return

View File

@ -3,7 +3,7 @@
<!-- Desktop: Single Row Layout -->
<div class="hidden md:flex items-center gap-6">
<img
:src="pkg['static-files']?.icon || `/assets/img/app-icons/${pkg.manifest?.id || appId}.png`"
:src="icon"
:alt="pkg.manifest.title"
class="w-20 h-20 rounded-xl shadow-xl flex-shrink-0"
@error="handleImageError"
@ -117,7 +117,7 @@
<div class="md:hidden">
<div class="flex items-start gap-4 mb-4">
<img
:src="pkg['static-files']?.icon || `/assets/img/app-icons/${pkg.manifest?.id || appId}.png`"
:src="icon"
:alt="pkg.manifest.title"
class="w-20 h-20 rounded-xl shadow-xl flex-shrink-0"
@error="handleImageError"
@ -226,18 +226,23 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { computed } from 'vue'
import type { PackageDataEntry } from '@/types/api'
import { resolveAppIcon } from '@/views/apps/appsConfig'
import { getStatusClass, getStatusDotClass, getStatusLabel } from './appDetailsData'
const { t } = useI18n()
defineProps<{
pkg: Record<string, any>
const props = defineProps<{
pkg: PackageDataEntry
appId: string
packageKey: string
canLaunch: boolean
isWebOnly: boolean
}>()
const icon = computed(() => resolveAppIcon(props.pkg.manifest?.id || props.appId, props.pkg))
defineEmits<{
launch: []
start: []

View File

@ -16,7 +16,7 @@ export const WEB_ONLY_APP_URLS: Record<string, string> = {
/** Map route/marketplace app IDs to backend package keys (container names). */
export const ROUTE_TO_PACKAGE_KEY: Record<string, string> = {
mempool: 'mempool-web',
mempool: 'mempool',
'mempool-electrs': 'mempool-electrs',
electrs: 'mempool-electrs',
btcpay: 'btcpay-server',
@ -88,7 +88,7 @@ export const APP_URLS: Record<string, { dev: string; prod: string }> = {
'portainer': { dev: 'http://localhost:9000', prod: 'http://localhost:9000' },
'uptime-kuma': { dev: 'http://localhost:3002', prod: 'http://localhost:3002' },
'tailscale': { dev: 'http://localhost:8240', prod: 'http://localhost:8240' },
'lnd': { dev: 'http://localhost:8081', prod: 'http://localhost:8081' },
'lnd': { dev: 'http://localhost:18083', prod: 'http://localhost:18083' },
'bitcoin-knots': { dev: 'http://localhost:8334', prod: 'http://localhost:8334' },
'botfights': { dev: 'http://localhost:9100', prod: 'http://localhost:9100' },
'nwnn': { dev: 'https://nwnn.l484.com', prod: 'https://nwnn.l484.com' },

View File

@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import { NEW_TAB_APPS, resolveAppUrl } from '../appSessionConfig'
describe('appSessionConfig', () => {
it('keeps new-tab apps marked on every viewport', () => {
expect(NEW_TAB_APPS.has('btcpay-server')).toBe(true)
expect(NEW_TAB_APPS.has('grafana')).toBe(true)
expect(NEW_TAB_APPS.has('vaultwarden')).toBe(true)
})
it('resolves direct app ports against the current browser host', () => {
Object.defineProperty(window, 'location', {
value: { hostname: '192.168.1.228' },
writable: true,
configurable: true,
})
expect(resolveAppUrl('mempool')).toBe('http://192.168.1.228:4080')
expect(resolveAppUrl('indeedhub')).toBe('http://192.168.1.228:7778')
})
})

View File

@ -14,8 +14,8 @@ export const APP_PORTS: Record<string, number> = {
'archy-electrs-ui': 50002,
'mempool-electrs': 50002,
'btcpay-server': 23000,
'lnd': 8081,
'archy-lnd-ui': 8081,
'lnd': 18083,
'archy-lnd-ui': 18083,
'mempool': 4080,
'mempool-web': 4080,
'archy-mempool-web': 4080,
@ -34,6 +34,7 @@ export const APP_PORTS: Record<string, number> = {
'nginx-proxy-manager': 81,
'gitea': 3001,
'portainer': 9000,
'tailscale': 8240,
'uptime-kuma': 3002,
'fedimint': 8175,
'fedimintd': 8175,
@ -52,40 +53,8 @@ export const PROXY_APPS: Record<string, string> = {
'uptime-kuma': '/app/uptime-kuma/',
}
/** Nginx proxy paths -- used on HTTPS to avoid mixed content (HTTPS parent + HTTP port iframe).
* On HTTP, direct port access is used instead (faster, no proxy). */
/** App launches use direct ports. Do not route through /app/... path proxies. */
export const HTTPS_PROXY_PATHS: Record<string, string> = {
'lnd': '/app/lnd/',
'electrumx': '/app/electrumx/',
'electrs': '/app/electrumx/',
'archy-electrs-ui': '/app/electrumx/',
'mempool-electrs': '/app/electrumx/',
'mempool': '/app/mempool/',
'mempool-web': '/app/mempool/',
'archy-mempool-web': '/app/mempool/',
'fedimint': '/app/fedimint/',
'fedimintd': '/app/fedimint/',
'fedimint-gateway': '/app/fedimint-gateway/',
'jellyfin': '/app/jellyfin/',
'searxng': '/app/searxng/',
'filebrowser': '/app/filebrowser/',
'ollama': '/app/ollama/',
'onlyoffice': '/app/onlyoffice/',
'immich': '/app/immich/',
'immich_server': '/app/immich/',
'portainer': '/app/portainer/',
'nginx-proxy-manager': '/app/nginx-proxy-manager/',
'uptime-kuma': '/app/uptime-kuma/',
'homeassistant': '/app/homeassistant/',
'vaultwarden': '/app/vaultwarden/',
'photoprism': '/app/photoprism/',
'endurain': '/app/endurain/',
'dwn': '/app/dwn/',
'btcpay-server': '/app/btcpay/',
'nextcloud': '/app/nextcloud/',
'grafana': '/app/grafana/',
'botfights': '/app/botfights/',
'gitea': '/app/gitea/',
}
/** External HTTPS apps -- always loaded directly */
@ -96,7 +65,6 @@ export const EXTERNAL_URLS: Record<string, string> = {
'syntropy-institute': 'https://syntropy.institute',
't-zero': 'https://teeminuszero.net',
'nostrudel': 'https://nostrudel.ninja',
'tailscale': 'https://login.tailscale.com/admin/machines',
}
export const APP_TITLES: Record<string, string> = {
@ -141,19 +109,11 @@ export function resolveAppUrl(id: string, routeQueryPath?: string): string {
return 'http://' + window.location.hostname + ':8334'
}
// HTTPS pages cannot embed plain HTTP port origins (mixed-content).
if (window.location.protocol === 'https:') {
const proxyPath = HTTPS_PROXY_PATHS[id]
if (proxyPath) {
return window.location.protocol + '//' + window.location.hostname + proxyPath
}
}
// Local apps on HTTP pages launch by host port.
// Local apps launch by host port.
const port = APP_PORTS[id]
if (!port) return ''
let base = window.location.protocol + '//' + window.location.hostname + ':' + String(port)
let base = 'http://' + window.location.hostname + ':' + String(port)
if (routeQueryPath) base += routeQueryPath
return base
}

View File

@ -78,7 +78,8 @@ import { computed, ref } from 'vue'
import { useServerStore } from '@/stores/server'
import { useAppLauncherStore } from '@/stores/appLauncher'
import type { PackageDataEntry } from '@/types/api'
import { canLaunch, handleImageError, resolveAppIcon } from './appsConfig'
import { resolveAppUrl } from '@/views/appSession/appSessionConfig'
import { canLaunch, handleImageError, opensInTab, resolveAppIcon } from './appsConfig'
import { getCuratedAppList } from '../discover/curatedApps'
const ITEMS_PER_PAGE = 16 // 4 columns x 4 rows
@ -119,6 +120,13 @@ function getIcon(id: string, pkg: PackageDataEntry): string {
function handleTap(id: string, pkg: PackageDataEntry) {
if (canLaunch(pkg)) {
if (opensInTab(id)) {
const appUrl = resolveAppUrl(id)
if (appUrl) {
window.open(appUrl, '_blank', 'noopener,noreferrer')
return
}
}
appLauncher.openSession(id)
} else {
emit('goToApp', id)

View File

@ -0,0 +1,58 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { PackageState, type PackageDataEntry } from '@/types/api'
import { useAppLauncherStore } from '@/stores/appLauncher'
import AppIconGrid from '../AppIconGrid.vue'
const mockWindowOpen = vi.fn()
vi.stubGlobal('open', mockWindowOpen)
function makePkg(id: string): PackageDataEntry {
return {
state: PackageState.Running,
manifest: {
id,
title: id,
version: '1.0.0',
description: { short: '', long: '' },
'release-notes': '',
license: '',
'wrapper-repo': '',
'upstream-repo': '',
'support-site': '',
'marketing-site': '',
'donation-url': null,
interfaces: { main: { ui: true } },
} as unknown as PackageDataEntry['manifest'],
'static-files': { license: '', instructions: '', icon: '' },
}
}
describe('AppIconGrid', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
localStorage.clear()
Object.defineProperty(window, 'location', {
value: { hostname: '192.168.1.198' },
writable: true,
configurable: true,
})
})
it('opens LND companion UI in the app panel', async () => {
const wrapper = mount(AppIconGrid, {
props: { apps: [['lnd', makePkg('lnd')]] },
global: {
plugins: [createPinia()],
},
})
await wrapper.get('.app-icon-item').trigger('click')
expect(mockWindowOpen).not.toHaveBeenCalled()
expect(useAppLauncherStore().panelAppId).toBe('lnd')
})
})

View File

@ -79,6 +79,6 @@ describe('appsConfig service filtering', () => {
it('falls back to packaged app icon when static icon token is not a path', () => {
const pkg = makePkg('gitea', 'Gitea', 'dev')
pkg['static-files']!.icon = 'git-branch'
expect(resolveAppIcon('gitea', pkg)).toBe('/assets/img/app-icons/gitea.png')
expect(resolveAppIcon('gitea', pkg)).toBe('/assets/img/app-icons/gitea.svg')
})
})

View File

@ -125,7 +125,9 @@ export function opensInTab(id: string): boolean {
return TAB_LAUNCH_APPS.has(id)
}
const APP_ICON_FALLBACKS: Record<string, string> = {
gitea: '/assets/img/app-icons/gitea.svg',
}
export function resolveAppIcon(id: string, pkg: PackageDataEntry, curatedIcon?: string): string {
const icon = (pkg["static-files"]?.icon || "").trim()
@ -137,7 +139,7 @@ export function resolveAppIcon(id: string, pkg: PackageDataEntry, curatedIcon?:
) {
return icon
}
return curatedIcon || `/assets/img/app-icons/${id}.png`
return curatedIcon || APP_ICON_FALLBACKS[id] || `/assets/img/app-icons/${id}.png`
}
export function canLaunch(pkg: PackageDataEntry): boolean {

View File

@ -140,10 +140,9 @@ const uiMode = useUIModeStore()
const mobileTabBar = ref<HTMLElement | null>(null)
// Hide tab bar when an app session is open (fullscreen on mobile)
const isAppSessionActive = computed(() => {
return route.name === 'app-session' || !!appLauncher.panelAppId
})
// App sessions own their mobile controls. Normal mobile launches use the route
// session; keeping this guard also protects any desktop-panel state on resize.
const isAppSessionActive = computed(() => route.name === 'app-session' || !!appLauncher.panelAppId)
// Show persistent tabs for Apps/Marketplace on mobile
const showAppsTabs = computed(() => {

View File

@ -96,7 +96,7 @@ export function getCuratedAppList(): MarketplaceApp[] {
{ id: 'portainer', title: 'Portainer', version: '2.19.4', description: 'Container management UI. Manage your containerized services through the web.', icon: '/assets/img/app-icons/portainer.webp', author: 'Portainer', dockerImage: `${R}/portainer:latest`, repoUrl: 'https://github.com/portainer/portainer' },
{ id: 'uptime-kuma', title: 'Uptime Kuma', version: '1.23.0', description: 'Self-hosted uptime monitoring. Track HTTP, TCP, DNS, and more.', icon: '/assets/img/app-icons/uptime-kuma.webp', author: 'Uptime Kuma', dockerImage: `${R}/uptime-kuma:1`, repoUrl: 'https://github.com/louislam/uptime-kuma' },
{ id: 'tailscale', title: 'Tailscale', version: '1.78.0', description: 'Zero-config VPN. Secure remote access with WireGuard mesh networking.', icon: '/assets/img/app-icons/tailscale.webp', author: 'Tailscale', dockerImage: `${R}/tailscale:stable`, repoUrl: 'https://github.com/tailscale/tailscale' },
{ id: 'electrumx', title: 'ElectrumX', version: '1.18.0', description: 'Electrum protocol server. Index the blockchain for fast wallet lookups, privately.', icon: '/assets/img/app-icons/electrumx.webp', author: 'Luke Childs', dockerImage: `${R}/electrumx:v1.18.0`, repoUrl: 'https://github.com/spesmilo/electrumx' },
{ id: 'electrumx', title: 'ElectrumX', version: '1.18.0', description: 'Electrum protocol server. Index the blockchain for fast wallet lookups, privately.', icon: '/assets/img/app-icons/electrumx.png', author: 'Luke Childs', dockerImage: `${R}/electrumx:v1.18.0`, repoUrl: 'https://github.com/spesmilo/electrumx' },
{ id: 'fedimint', title: 'Fedimint', version: '0.10.0', description: 'Federated Bitcoin mint. Private, scalable Bitcoin through federated guardians.', icon: '/assets/img/app-icons/fedimint.png', author: 'Fedimint', dockerImage: `${R}/fedimintd:v0.10.0`, repoUrl: 'https://github.com/fedimint/fedimint' },
{ id: 'indeedhub', title: 'Indeehub', version: '1.0.0', description: 'Bitcoin documentary streaming with Nostr identity. Stream sovereignty content.', icon: '/assets/img/app-icons/indeedhub.png', author: 'Indeehub Team', dockerImage: `${R}/indeedhub:1.0.0`, repoUrl: 'https://github.com/indeedhub/indeedhub' },
{ id: 'dwn', title: 'Decentralized Web Node', version: '0.4.0', description: 'Own your data with DID-based access control. Sync across devices, sovereign.', icon: '/assets/img/app-icons/dwn.svg', author: 'TBD', dockerImage: `${R}/dwn-server:main`, repoUrl: 'https://github.com/TBD54566975/dwn-server' },

View File

@ -1,7 +1,7 @@
<template>
<div
data-controller-container
:data-controller-install="!(installed || installing) && (app.source === 'local' || !!app.dockerImage) ? '1' : undefined"
:data-controller-install="!(installed || installing || installBlockedReason) && (app.source === 'local' || !!app.dockerImage) ? '1' : undefined"
tabindex="0"
role="link"
class="glass-card p-6 hover:bg-orange-500/5 hover:border-orange-500/15 transition-all cursor-pointer flex flex-col"
@ -122,6 +122,14 @@
></div>
</div>
</div>
<button
v-else-if="!installed && installBlockedReason"
class="flex-1 px-4 py-2 bg-yellow-500/15 border border-yellow-500/30 rounded-lg text-yellow-100 text-sm font-medium"
:title="installBlockedReason"
@click.stop="$emit('install', app)"
>
Bitcoin Pruned
</button>
<button
v-else-if="!installed && (app.source === 'local' || app.dockerImage)"
data-controller-install-btn
@ -159,6 +167,7 @@ const props = defineProps<{
startingUp: boolean
containersScanned: boolean
tierLabel: string
installBlockedReason?: string
}>()
defineEmits<{

View File

@ -297,7 +297,7 @@ export function getCuratedAppList(): MarketplaceApp[] {
title: 'ElectrumX',
version: '1.18.0',
description: 'Electrum protocol server. Index the blockchain for fast wallet lookups, privately.',
icon: '/assets/img/app-icons/electrumx.webp',
icon: '/assets/img/app-icons/electrumx.png',
author: 'Luke Childs',
dockerImage: `${REGISTRY}/electrumx:v1.18.0`,
manifestUrl: undefined,

View File

@ -14,7 +14,7 @@ export default defineConfig({
globals: true,
root: '.',
passWithNoTests: true,
exclude: ['e2e/**', 'node_modules/**'],
exclude: ['e2e/**', 'node_modules/**', '**/._*'],
coverage: {
provider: 'v8',
reporter: ['text', 'text-summary'],

View File

@ -1,29 +1,28 @@
{
"version": "1.7.51-alpha",
"release_date": "2026-05-01",
"version": "1.7.52-alpha",
"release_date": "2026-05-05",
"changelog": [
"Install success now requires adopted containers and IndeedHub stack containers to stay running; failed starts surface logs instead of disappearing from My Apps.",
"Bitcoin uninstall removes the shared Bitcoin data/UI directories when data is not preserved, preventing stale partial installs from being adopted as success.",
"Bitcoin RPC bind settings are repaired on startup and before adopting existing Bitcoin containers, fixing older nodes where bitcoin-ui showed endless getblockchaininfo/502.",
"Bitcoin Core/Knots launch the Bitcoin UI on direct port 8334 instead of the /app/bitcoin-ui path proxy.",
"Nodes force OVH as the primary update mirror and app registry on next startup, with tx1138 retained as fallback."
"Tailscale now launches the local installed web UI on port `8240` and starts `tailscaled` before `tailscale web`, fixing unreachable installs after container creation.",
"Grafana install/start/restart now repairs missing rootless host listeners on port `3000`, matching the existing SearXNG, Uptime Kuma, and Gitea recovery path.",
"Debian 13/Trixie ISO and disk-install paths now force security updates from `trixie-security` during image/install creation so rebuilt release media includes patched base packages.",
"Broad `.198` lifecycle audit passes with the current qualified app set; known absent blockers remain `electrumx`, `photoprism`, `dwn`, and `ollama`."
],
"components": [
{
"name": "archipelago",
"current_version": "1.7.50-alpha",
"new_version": "1.7.51-alpha",
"download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.51-alpha/archipelago",
"sha256": "f761e659d661f0a83cd3a67a086bb2279398bc05e50ee3c52e769e52d11e476c",
"size_bytes": 41637536
"current_version": "1.7.52-alpha",
"new_version": "1.7.52-alpha",
"download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.52-alpha/archipelago",
"sha256": "fc47c3bc42f67472252cb854bb03e200a92929ab38aeac519422704486af18d4",
"size_bytes": 42342368
},
{
"name": "archipelago-frontend-1.7.51-alpha.tar.gz",
"current_version": "1.7.50-alpha",
"new_version": "1.7.51-alpha",
"download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.51-alpha/archipelago-frontend-1.7.51-alpha.tar.gz",
"sha256": "3403f4e38202bf56c53407dd62e66899693ee73252bf203475715532ac6ae326",
"size_bytes": 165155462
"name": "archipelago-frontend-1.7.52-alpha.tar.gz",
"current_version": "1.7.52-alpha",
"new_version": "1.7.52-alpha",
"download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.52-alpha/archipelago-frontend-1.7.52-alpha.tar.gz",
"sha256": "329e57a0491e91966afcd5a82f5c00920657695b01ecc6c9e99c6814b44abf29",
"size_bytes": 166462645
}
]
}

View File

@ -1191,7 +1191,7 @@ if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q tailscale; then
-v /var/lib/archipelago/tailscale:/var/lib/tailscale \
-e TS_STATE_DIR=/var/lib/tailscale \
"$TAILSCALE_IMAGE" \
sh -c 'tailscale web --listen 0.0.0.0:8240 & exec tailscaled' 2>>"$LOG" || true
sh -c 'tailscaled --tun=userspace-networking & sleep 2; tailscale web --listen 0.0.0.0:8240 & wait' 2>>"$LOG" || true
fi
track_container "tailscale"

View File

@ -78,7 +78,7 @@ location /app/endurain/ {
proxy_hide_header Content-Security-Policy;
}
location /app/lnd/ {
proxy_pass http://127.0.0.1:8081/;
proxy_pass http://127.0.0.1:18083/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;

View File

@ -236,7 +236,7 @@ server {
proxy_send_timeout 300s;
}
location /app/lnd/ {
proxy_pass http://127.0.0.1:8081/;
proxy_pass http://127.0.0.1:18083/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;

View File

@ -0,0 +1,479 @@
#!/usr/bin/env bash
# Remote app lifecycle runner for Archipelago nodes.
#
# Exercises the same public surface the UI uses:
# - JSON-RPC package.install/start/stop/restart/uninstall
# - HTTPS/direct-port launch probes from appSessionConfig.ts
#
# Default mode is audit-only. Use ARCHY_FULL_LIFECYCLE=1 for destructive
# preserve-data cycles: install -> launch -> stop -> start -> restart ->
# uninstall(preserve_data=true) -> reinstall -> launch.
set -euo pipefail
ARCHY_HOST="${ARCHY_HOST:-}"
ARCHY_SCHEME="${ARCHY_SCHEME:-https}"
ARCHY_PASSWORD="${ARCHY_PASSWORD:-}"
ARCHY_ITERATIONS="${ARCHY_ITERATIONS:-1}"
ARCHY_FULL_LIFECYCLE="${ARCHY_FULL_LIFECYCLE:-0}"
ARCHY_APPS="${ARCHY_APPS:-}"
ARCHY_TIMEOUT="${ARCHY_TIMEOUT:-900}"
ARCHY_STABILITY_SECONDS="${ARCHY_STABILITY_SECONDS:-5}"
ARCHY_ALLOW_BITCOIN_SWAP="${ARCHY_ALLOW_BITCOIN_SWAP:-0}"
if [[ -z "$ARCHY_HOST" || -z "$ARCHY_PASSWORD" ]]; then
echo "ARCHY_HOST and ARCHY_PASSWORD are required" >&2
exit 2
fi
if ! [[ "$ARCHY_ITERATIONS" =~ ^[1-9][0-9]*$ ]]; then
echo "ARCHY_ITERATIONS must be a positive integer" >&2
exit 2
fi
if ! [[ "$ARCHY_STABILITY_SECONDS" =~ ^[0-9]+$ ]]; then
echo "ARCHY_STABILITY_SECONDS must be a non-negative integer" >&2
exit 2
fi
BASE_URL="${ARCHY_SCHEME}://${ARCHY_HOST}"
SESSION=""
CSRF=""
ALL_APPS=(
bitcoin-knots
btcpay-server
lnd
mempool
homeassistant
grafana
searxng
ollama
nextcloud
vaultwarden
jellyfin
photoprism
immich
filebrowser
nginx-proxy-manager
portainer
tailscale
uptime-kuma
electrumx
fedimint
indeedhub
dwn
botfights
gitea
)
image_for() {
case "$1" in
bitcoin-knots) echo "146.59.87.168:3000/lfg2025/bitcoin-knots:latest" ;;
bitcoin-core) echo "docker.io/bitcoin/bitcoin:28.4" ;;
btcpay-server) echo "146.59.87.168:3000/lfg2025/btcpayserver:1.13.7" ;;
lnd) echo "146.59.87.168:3000/lfg2025/lnd:v0.18.4-beta" ;;
mempool) echo "146.59.87.168:3000/lfg2025/mempool-frontend:v3.0.0" ;;
homeassistant) echo "146.59.87.168:3000/lfg2025/home-assistant:2024.1" ;;
grafana) echo "146.59.87.168:3000/lfg2025/grafana:10.2.0" ;;
searxng) echo "146.59.87.168:3000/lfg2025/searxng:latest" ;;
ollama) echo "146.59.87.168:3000/lfg2025/ollama:latest" ;;
nextcloud) echo "146.59.87.168:3000/lfg2025/nextcloud:28" ;;
vaultwarden) echo "146.59.87.168:3000/lfg2025/vaultwarden:1.30.0-alpine" ;;
jellyfin) echo "146.59.87.168:3000/lfg2025/jellyfin:10.8.13" ;;
photoprism) echo "146.59.87.168:3000/lfg2025/photoprism:240915" ;;
immich) echo "146.59.87.168:3000/lfg2025/immich-server:release" ;;
filebrowser) echo "146.59.87.168:3000/lfg2025/filebrowser:v2.27.0" ;;
nginx-proxy-manager) echo "146.59.87.168:3000/lfg2025/nginx-proxy-manager:latest" ;;
portainer) echo "146.59.87.168:3000/lfg2025/portainer:latest" ;;
uptime-kuma) echo "146.59.87.168:3000/lfg2025/uptime-kuma:1" ;;
tailscale) echo "146.59.87.168:3000/lfg2025/tailscale:stable" ;;
electrumx) echo "146.59.87.168:3000/lfg2025/electrumx:v1.18.0" ;;
fedimint) echo "146.59.87.168:3000/lfg2025/fedimintd:v0.10.0" ;;
indeedhub) echo "146.59.87.168:3000/lfg2025/indeedhub:1.0.0" ;;
dwn) echo "146.59.87.168:3000/lfg2025/dwn-server:main" ;;
botfights) echo "146.59.87.168:3000/lfg2025/botfights:1.1.0" ;;
gitea) echo "docker.io/gitea/gitea:1.23" ;;
*) return 1 ;;
esac
}
launch_url_for() {
case "$1" in
bitcoin-knots|bitcoin-core|bitcoin-ui) echo "http://${ARCHY_HOST}:8334/" ;;
lnd|archy-lnd-ui) echo "http://${ARCHY_HOST}:18083/" ;;
electrumx|electrs|mempool-electrs|archy-electrs-ui) echo "http://${ARCHY_HOST}:50002/" ;;
mempool|mempool-web|archy-mempool-web) echo "http://${ARCHY_HOST}:4080/" ;;
fedimint|fedimintd) echo "http://${ARCHY_HOST}:8175/" ;;
fedimint-gateway) echo "http://${ARCHY_HOST}:8176/" ;;
filebrowser) echo "http://${ARCHY_HOST}:8083/" ;;
grafana) echo "http://${ARCHY_HOST}:3000/" ;;
btcpay-server) echo "http://${ARCHY_HOST}:23000/" ;;
jellyfin) echo "http://${ARCHY_HOST}:8096/" ;;
searxng) echo "http://${ARCHY_HOST}:8888/" ;;
ollama) echo "http://${ARCHY_HOST}:11434/" ;;
immich|immich_server) echo "http://${ARCHY_HOST}:2283/" ;;
portainer) echo "http://${ARCHY_HOST}:9000/" ;;
nginx-proxy-manager) echo "http://${ARCHY_HOST}:81/" ;;
tailscale) echo "http://${ARCHY_HOST}:8240/" ;;
uptime-kuma) echo "http://${ARCHY_HOST}:3002/" ;;
homeassistant) echo "http://${ARCHY_HOST}:8123/" ;;
vaultwarden) echo "http://${ARCHY_HOST}:8082/" ;;
photoprism) echo "http://${ARCHY_HOST}:2342/" ;;
dwn) echo "http://${ARCHY_HOST}:3100/" ;;
botfights) echo "http://${ARCHY_HOST}:9100/" ;;
gitea) echo "http://${ARCHY_HOST}:3001/" ;;
indeedhub) echo "http://${ARCHY_HOST}:7778/" ;;
*) return 1 ;;
esac
}
rpc_login() {
local headers body err
headers=$(mktemp)
body=$(curl -sk -D "$headers" -X POST "${BASE_URL}/rpc/v1" \
-H 'Content-Type: application/json' \
--data-raw "$(jq -nc --arg p "$ARCHY_PASSWORD" '{jsonrpc:"2.0",method:"auth.login",params:{password:$p},id:1}')")
err=$(printf '%s' "$body" | jq -r '.error.message // empty')
if [[ -n "$err" ]]; then
rm -f "$headers"
echo "login failed on $ARCHY_HOST: $err" >&2
return 1
fi
SESSION=$(grep -i '^set-cookie: session=' "$headers" | head -1 | sed -E 's/.*session=([^;]+).*/\1/' | tr -d '\r')
CSRF=$(grep -i '^set-cookie: csrf_token=' "$headers" | head -1 | sed -E 's/.*csrf_token=([^;]+).*/\1/' | tr -d '\r')
rm -f "$headers"
[[ -n "$SESSION" && -n "$CSRF" ]]
}
rpc_call() {
local method="$1" params="${2:-null}" id="${3:-2}"
local payload
if [[ "$params" == "null" ]]; then
payload=$(jq -nc --arg m "$method" --argjson id "$id" '{jsonrpc:"2.0",method:$m,id:$id}')
else
payload=$(jq -nc --arg m "$method" --argjson p "$params" --argjson id "$id" '{jsonrpc:"2.0",method:$m,params:$p,id:$id}')
fi
curl -sk -X POST "${BASE_URL}/rpc/v1" \
-H 'Content-Type: application/json' \
-H "Cookie: session=${SESSION}; csrf_token=${CSRF}" \
-H "X-CSRF-Token: ${CSRF}" \
--data-raw "$payload"
}
rpc_result() {
local resp err
resp=$(rpc_call "$@")
err=$(printf '%s' "$resp" | jq -r '.error.message // empty')
if [[ -n "$err" ]]; then
echo "$err" >&2
return 1
fi
printf '%s' "$resp" | jq '.result'
}
container_state() {
local app="$1"
rpc_result container-list | jq -r --arg app "$app" '
(map(select(.name == $app or .id == $app)) | first | .state // "absent") | ascii_downcase
'
}
container_health() {
local app="$1"
rpc_result container-health "$(jq -nc --arg app "$app" '{app_id:$app}')" \
| jq -r --arg app "$app" '.[$app] // "unknown" | ascii_downcase'
}
assert_container_healthy() {
local app="$1" health
health=$(container_health "$app" 2>/dev/null || echo unknown)
case "$health" in
healthy) return 0 ;;
*) echo "bad health: $app is $health" >&2; return 1 ;;
esac
}
wait_container_healthy() {
local app="$1" timeout="${2:-$ARCHY_TIMEOUT}" deadline health
deadline=$(( $(date +%s) + timeout ))
while (( $(date +%s) < deadline )); do
health=$(container_health "$app" 2>/dev/null || echo unknown)
if [[ "$health" == "healthy" ]]; then return 0; fi
sleep 5
done
echo "bad health: $app is ${health:-unknown}" >&2
return 1
}
observe_stable() {
local app="$1" seconds="${2:-$ARCHY_STABILITY_SECONDS}" deadline state
(( seconds == 0 )) && return 0
deadline=$(( $(date +%s) + seconds ))
while (( $(date +%s) < deadline )); do
state=$(container_state "$app" 2>/dev/null || echo unknown)
if [[ "$state" != "running" ]]; then
echo "stability failed: $app left running state (last=$state)" >&2
return 1
fi
assert_container_healthy "$app" || return 1
sleep 5
done
}
wait_state() {
local app="$1" target="$2" timeout="${3:-$ARCHY_TIMEOUT}"
local deadline state
deadline=$(( $(date +%s) + timeout ))
while (( $(date +%s) < deadline )); do
state=$(container_state "$app" 2>/dev/null || echo unknown)
if [[ "$target" == "absent" && "$state" == "absent" ]]; then return 0; fi
if [[ "$target" != "absent" && "$state" == "$target" ]]; then return 0; fi
sleep 5
done
echo "$app did not reach $target within ${timeout}s (last=$state)" >&2
return 1
}
wait_absent_settled() {
local app="$1" timeout="${2:-$ARCHY_TIMEOUT}"
local deadline state seen_absent=0
deadline=$(( $(date +%s) + timeout ))
while (( $(date +%s) < deadline )); do
state=$(container_state "$app" 2>/dev/null || echo unknown)
if [[ "$state" == "absent" ]]; then
if (( seen_absent == 1 )); then return 0; fi
seen_absent=1
else
seen_absent=0
fi
sleep 5
done
echo "$app did not settle absent within ${timeout}s (last=$state)" >&2
return 1
}
wait_not_installing() {
local app="$1" timeout="${2:-$ARCHY_TIMEOUT}"
local deadline state
deadline=$(( $(date +%s) + timeout ))
while (( $(date +%s) < deadline )); do
state=$(container_state "$app" 2>/dev/null || echo unknown)
case "$state" in
installing|starting|restarting|updating) sleep 5 ;;
*) return 0 ;;
esac
done
echo "$app did not settle from install transition within ${timeout}s (last=$state)" >&2
return 1
}
probe_launch() {
local app="$1" url code bytes body
url=$(launch_url_for "$app") || return 0
body=$(mktemp)
code=$(curl -skL --connect-timeout 8 -m 20 -o "$body" -w '%{http_code}' "$url" || true)
bytes=$(wc -c < "$body" 2>/dev/null || printf 0)
if [[ "$code" != "200" || "$bytes" -eq 0 ]]; then
echo "launch failed: $app $url status=$code bytes=$bytes" >&2
rm -f "$body"
return 1
fi
case "$app" in
lnd) probe_lnd_wallet_connect "$body" || { rm -f "$body"; return 1; } ;;
electrumx|electrs|mempool-electrs) probe_electrum_wallet_connect "$body" || { rm -f "$body"; return 1; } ;;
esac
rm -f "$body"
}
wait_launch() {
local app="$1" timeout="${2:-$ARCHY_TIMEOUT}" deadline
deadline=$(( $(date +%s) + timeout ))
while (( $(date +%s) < deadline )); do
if probe_launch "$app" >/dev/null 2>&1; then return 0; fi
sleep 5
done
probe_launch "$app"
}
assert_launch_metadata() {
local app="$1" timeout="${2:-$ARCHY_TIMEOUT}" deadline lan
deadline=$(( $(date +%s) + timeout ))
while (( $(date +%s) < deadline )); do
lan=$(rpc_result container-list | jq -r --arg app "$app" '
(map(select(.name == $app or .id == $app)) | first | .lan_address // "")
')
if [[ -n "$lan" && "$lan" != "null" ]]; then return 0; fi
sleep 5
done
if [[ -z "${lan:-}" || "$lan" == "null" ]]; then
echo "launch metadata missing: $app has no lan_address" >&2
return 1
fi
}
require_body() {
local body="$1" needle="$2" label="$3"
if ! grep -Fq "$needle" "$body"; then
echo "launch missing $label: $needle" >&2
return 1
fi
}
probe_lnd_wallet_connect() {
local body="$1" info err
require_body "$body" 'Connect Your Wallet' 'LND wallet heading' || return 1
require_body "$body" 'id="lndQrBox"' 'LND QR container' || return 1
require_body "$body" 'id="connHost"' 'LND host field' || return 1
require_body "$body" 'value="rest-tor"' 'LND REST Tor mode' || return 1
require_body "$body" 'value="grpc-tor"' 'LND gRPC Tor mode' || return 1
require_body "$body" 'value="rest-local"' 'LND REST local mode' || return 1
require_body "$body" 'value="grpc-local"' 'LND gRPC local mode' || return 1
require_body "$body" 'Copy lndconnect URI' 'LND connect URI button' || return 1
info=$(curl -skL --connect-timeout 8 -m 20 \
-H "Cookie: session=${SESSION}; csrf_token=${CSRF}" \
-H "X-CSRF-Token: ${CSRF}" \
"${BASE_URL}/lnd-connect-info" || true)
err=$(printf '%s' "$info" | jq -r '.error // empty' 2>/dev/null || true)
if [[ -n "$err" ]]; then
echo "lnd connect info error: $err" >&2
return 1
fi
printf '%s' "$info" | jq -e '
(.cert_base64url | type == "string" and length > 100) and
(.macaroon_base64url | type == "string" and length > 50) and
(.tor_onion | type == "string" and test("^[a-z2-7]+\\.onion$")) and
(.rest_port == 8080) and
(.grpc_port == 10009)
' >/dev/null || {
echo "lnd connect info incomplete: $info" >&2
return 1
}
}
probe_electrum_wallet_connect() {
local body="$1"
require_body "$body" 'Connect Your Wallet' 'Electrum wallet heading' || return 1
require_body "$body" 'id="qrLocalBox"' 'Electrum local QR container' || return 1
require_body "$body" 'id="qrTorBox"' 'Electrum Tor QR container' || return 1
require_body "$body" 'id="localAddress"' 'Electrum local address field' || return 1
require_body "$body" 'id="torAddress"' 'Electrum Tor address field' || return 1
require_body "$body" '50001' 'Electrum wallet port' || return 1
require_body "$body" 'renderQR' 'Electrum QR renderer' || return 1
curl -skL --connect-timeout 8 -m 20 -f "http://${ARCHY_HOST}:50002/qrcode.js" >/dev/null || {
echo "electrum qrcode.js unavailable" >&2
return 1
}
local status
status=$(curl -skL --connect-timeout 8 -m 20 "${BASE_URL}/electrs-status" || true)
printf '%s' "$status" | jq -e '(.tor_onion | type == "string" and test("^[a-z2-7]+\\.onion$"))' >/dev/null || {
echo "electrum tor connection info incomplete: $status" >&2
return 1
}
}
install_app() {
local app="$1" image params
image=$(image_for "$app")
params=$(jq -nc --arg id "$app" --arg img "$image" '{id:$id,dockerImage:$img,version:"latest"}')
rpc_result package.install "$params" >/dev/null
}
start_app() { rpc_result package.start "$(jq -nc --arg id "$1" '{id:$id}')" >/dev/null; }
stop_app() { rpc_result package.stop "$(jq -nc --arg id "$1" '{id:$id}')" >/dev/null; }
restart_app() { rpc_result package.restart "$(jq -nc --arg id "$1" '{id:$id}')" >/dev/null; }
uninstall_app() { rpc_result package.uninstall "$(jq -nc --arg id "$1" '{id:$id,preserve_data:true}')" >/dev/null; }
audit_app() {
local app="$1" state rc=0
state=$(container_state "$app" || echo unknown)
printf '%-22s state=%s\n' "$app" "$state"
case "$state" in
absent) ;;
running)
wait_container_healthy "$app" || rc=1
wait_launch "$app" || rc=1
assert_launch_metadata "$app" || rc=1
observe_stable "$app" || rc=1
;;
*) echo "bad state: $app is $state" >&2; rc=1 ;;
esac
return "$rc"
}
full_lifecycle_app() {
local app="$1"
if [[ "$app" == "bitcoin-core" && "$ARCHY_ALLOW_BITCOIN_SWAP" != "1" ]]; then
echo "skip bitcoin-core: set ARCHY_ALLOW_BITCOIN_SWAP=1 to test mutually-exclusive Bitcoin implementation"
return 0
fi
echo "== $app: install =="
install_app "$app" || return 1
wait_not_installing "$app" || return 1
wait_state "$app" running || return 1
wait_container_healthy "$app" || return 1
wait_launch "$app" || return 1
assert_launch_metadata "$app" || return 1
observe_stable "$app" || return 1
echo "== $app: stop =="
stop_app "$app" || return 1
wait_state "$app" stopped 300 || return 1
echo "== $app: start =="
start_app "$app" || return 1
wait_state "$app" running || return 1
wait_container_healthy "$app" || return 1
wait_launch "$app" || return 1
assert_launch_metadata "$app" || return 1
observe_stable "$app" || return 1
echo "== $app: restart =="
restart_app "$app" || return 1
wait_state "$app" running || return 1
wait_container_healthy "$app" || return 1
wait_launch "$app" || return 1
assert_launch_metadata "$app" || return 1
observe_stable "$app" || return 1
echo "== $app: uninstall preserve_data =="
uninstall_app "$app" || return 1
wait_absent_settled "$app" 600 || return 1
echo "== $app: reinstall =="
install_app "$app" || return 1
wait_not_installing "$app" || return 1
wait_state "$app" running || return 1
wait_container_healthy "$app" || return 1
wait_launch "$app" || return 1
assert_launch_metadata "$app" || return 1
observe_stable "$app" || return 1
}
apps=()
if [[ -n "$ARCHY_APPS" ]]; then
IFS=',' read -r -a apps <<< "$ARCHY_APPS"
elif [[ "$ARCHY_FULL_LIFECYCLE" == "1" ]]; then
echo "ARCHY_FULL_LIFECYCLE=1 requires ARCHY_APPS to avoid installing unqualified catalog apps" >&2
exit 2
else
apps=("${ALL_APPS[@]}")
fi
rpc_login
failed=0
for i in $(seq 1 "$ARCHY_ITERATIONS"); do
echo "### $ARCHY_HOST iteration $i / $ARCHY_ITERATIONS ###"
for app in "${apps[@]}"; do
if [[ "$ARCHY_FULL_LIFECYCLE" == "1" ]]; then
full_lifecycle_app "$app" || failed=$((failed + 1))
else
audit_app "$app" || failed=$((failed + 1))
fi
done
done
if (( failed > 0 )); then
echo "FAILED checks: $failed" >&2
exit 1
fi
echo "all checks passed"