diff --git a/CHANGELOG.md b/CHANGELOG.md index 7da99031..0e3c8697 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/app-catalog/catalog.json b/app-catalog/catalog.json index 3db1472e..dd547565 100644 --- a/app-catalog/catalog.json +++ b/app-catalog/catalog.json @@ -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", diff --git a/apps/archy-mempool-web/manifest.yml b/apps/archy-mempool-web/manifest.yml index b3500977..bc1cb9f1 100644 --- a/apps/archy-mempool-web/manifest.yml +++ b/apps/archy-mempool-web/manifest.yml @@ -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 diff --git a/apps/archy-nbxplorer/manifest.yml b/apps/archy-nbxplorer/manifest.yml index 0705cae5..1d48d74c 100644 --- a/apps/archy-nbxplorer/manifest.yml +++ b/apps/archy-nbxplorer/manifest.yml @@ -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: diff --git a/apps/bitcoin-core/manifest.yml b/apps/bitcoin-core/manifest.yml index 427e7831..7ca77373 100644 --- a/apps/bitcoin-core/manifest.yml +++ b/apps/bitcoin-core/manifest.yml @@ -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 diff --git a/apps/bitcoin-knots/manifest.yml b/apps/bitcoin-knots/manifest.yml index 5d0a600d..2a302ee4 100644 --- a/apps/bitcoin-knots/manifest.yml +++ b/apps/bitcoin-knots/manifest.yml @@ -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 diff --git a/apps/btcpay-server/manifest.yml b/apps/btcpay-server/manifest.yml index 93449482..2cea54ee 100644 --- a/apps/btcpay-server/manifest.yml +++ b/apps/btcpay-server/manifest.yml @@ -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: / diff --git a/apps/electrumx/manifest.yml b/apps/electrumx/manifest.yml index 620565ee..2b8fc039 100644 --- a/apps/electrumx/manifest.yml +++ b/apps/electrumx/manifest.yml @@ -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 diff --git a/apps/fedimint-gateway/manifest.yml b/apps/fedimint-gateway/manifest.yml index b7657d71..52dfecc9 100644 --- a/apps/fedimint-gateway/manifest.yml +++ b/apps/fedimint-gateway/manifest.yml @@ -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 diff --git a/apps/fedimint/manifest.yml b/apps/fedimint/manifest.yml index 2a57194b..ca6f4154 100644 --- a/apps/fedimint/manifest.yml +++ b/apps/fedimint/manifest.yml @@ -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 diff --git a/apps/gitea/manifest.yml b/apps/gitea/manifest.yml index 8f116c81..9f0053cf 100644 --- a/apps/gitea/manifest.yml +++ b/apps/gitea/manifest.yml @@ -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" diff --git a/apps/grafana/manifest.yml b/apps/grafana/manifest.yml index 2876231c..1900c1f0 100644 --- a/apps/grafana/manifest.yml +++ b/apps/grafana/manifest.yml @@ -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 diff --git a/apps/indeedhub/manifest.yml b/apps/indeedhub/manifest.yml index 4ccbfb88..0e443741 100644 --- a/apps/indeedhub/manifest.yml +++ b/apps/indeedhub/manifest.yml @@ -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 diff --git a/apps/lnd-ui/manifest.yml b/apps/lnd-ui/manifest.yml index e15bb0ad..a33c1c80 100644 --- a/apps/lnd-ui/manifest.yml +++ b/apps/lnd-ui/manifest.yml @@ -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 diff --git a/apps/mempool-api/manifest.yml b/apps/mempool-api/manifest.yml index 9657a316..97baa892 100644 --- a/apps/mempool-api/manifest.yml +++ b/apps/mempool-api/manifest.yml @@ -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 diff --git a/apps/nostr-rs-relay/manifest.yml b/apps/nostr-rs-relay/manifest.yml index 06658440..fafe7f43 100644 --- a/apps/nostr-rs-relay/manifest.yml +++ b/apps/nostr-rs-relay/manifest.yml @@ -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: diff --git a/apps/searxng/manifest.yml b/apps/searxng/manifest.yml index 6a0825cc..092d170f 100644 --- a/apps/searxng/manifest.yml +++ b/apps/searxng/manifest.yml @@ -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: diff --git a/core/Cargo.lock b/core/Cargo.lock index 4e959b45..6900f6e3 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" [[package]] name = "archipelago" -version = "1.7.51-alpha" +version = "1.7.52-alpha" dependencies = [ "anyhow", "archipelago-container", diff --git a/core/archipelago/Cargo.toml b/core/archipelago/Cargo.toml index f5d5fb39..4315f3f9 100644 --- a/core/archipelago/Cargo.toml +++ b/core/archipelago/Cargo.toml @@ -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"] diff --git a/core/archipelago/src/api/rpc/container.rs b/core/archipelago/src/api/rpc/container.rs index 27f5bbba..efed480b 100644 --- a/core/archipelago/src/api/rpc/container.rs +++ b/core/archipelago/src/api/rpc/container.rs @@ -379,11 +379,22 @@ 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")?; - return Ok(serde_json::json!({ app_id: health })); + let mut last_err: Option = 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")); } } @@ -449,6 +460,14 @@ fn status_app_id_candidates(app_id: &str) -> Vec { 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 { "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 "running": running, })) } + +async fn inspect_container_health_value(name: &str) -> Option { + 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}")), + } +} diff --git a/core/archipelago/src/api/rpc/dispatcher.rs b/core/archipelago/src/api/rpc/dispatcher.rs index d35b7cad..fc69ab8e 100644 --- a/core/archipelago/src/api/rpc/dispatcher.rs +++ b/core/archipelago/src/api/rpc/dispatcher.rs @@ -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 { + 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 { let recovery_complete = crate::crash_recovery::is_recovery_complete(); let uptime = crate::crash_recovery::uptime_seconds(); diff --git a/core/archipelago/src/api/rpc/package/config.rs b/core/archipelago/src/api/rpc/package/config.rs index 940dcbc0..b1b80218 100644 --- a/core/archipelago/src/api/rpc/package/config.rs +++ b/core/archipelago/src/api/rpc/package/config.rs @@ -309,16 +309,23 @@ pub(super) fn all_container_names(package_id: &str) -> Vec { 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 { "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 { @@ -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![ diff --git a/core/archipelago/src/api/rpc/package/dependencies.rs b/core/archipelago/src/api/rpc/package/dependencies.rs index 7a7741fd..bb647854 100644 --- a/core/archipelago/src/api/rpc/package/dependencies.rs +++ b/core/archipelago/src/api/rpc/package/dependencies.rs @@ -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,13 +24,43 @@ pub(super) struct RunningDeps { pub has_lnd: bool, } +pub(super) fn detect_running_deps_from_package_data( + packages: &HashMap, +) -> 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 { - let dep_check = tokio::process::Command::new("podman") - .args(["ps", "--format", "{{.Names}}"]) - .output() - .await - .map_err(|e| anyhow::anyhow!("Failed to check running containers: {}", e))?; + let dep_check = tokio::time::timeout( + std::time::Duration::from_secs(30), + tokio::process::Command::new("podman") + .args(["ps", "--format", "{{.Names}}"]) + .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]| { @@ -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}"); + } + } +} diff --git a/core/archipelago/src/api/rpc/package/install.rs b/core/archipelago/src/api/rpc/package/install.rs index 50cc7726..379bdc75 100644 --- a/core/archipelago/src/api/rpc/package/install.rs +++ b/core/archipelago/src/api/rpc/package/install.rs @@ -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 '' '';", + 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,30 +559,24 @@ impl RpcHandler { run_args.push(&host_gateway_flag); // Security hardening (skip for privileged containers) - let security_caps: Vec = if !is_tailscale { - get_app_capabilities(package_id) - } else { - vec![] - }; - let readonly_compatible = !is_tailscale && is_readonly_compatible(package_id); + let security_caps: Vec = 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"); - for cap in &security_caps { - run_args.push(cap); - } - if readonly_compatible { - run_args.push("--read-only"); - run_args.push("--tmpfs=/tmp:rw,noexec,nosuid,size=256m"); - run_args.push("--tmpfs=/run:rw,noexec,nosuid,size=64m"); - } + run_args.push("--cap-drop=ALL"); + run_args.push("--security-opt=no-new-privileges:true"); + run_args.push("--pids-limit=4096"); + for cap in &security_caps { + run_args.push(cap); + } + if readonly_compatible { + run_args.push("--read-only"); + run_args.push("--tmpfs=/tmp:rw,noexec,nosuid,size=256m"); + run_args.push("--tmpfs=/run:rw,noexec,nosuid,size=64m"); + } - // Jellyfin: .NET CoreCLR needs exec-enabled /tmp for JIT compilation - if package_id == "jellyfin" { - run_args.push("--tmpfs=/tmp:rw,exec,size=256m"); - } + // Jellyfin: .NET CoreCLR needs exec-enabled /tmp for JIT compilation + 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) @@ -490,12 +623,9 @@ 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); - } + for port in &ports { + run_args.push("-p"); + run_args.push(port); } // Volume mounts @@ -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) => { - self.clear_install_progress(package_id).await; - return Err(e.context(format!( - "Failed to pull {} after {} attempts", - docker_image, MAX_ATTEMPTS - ))); - } - } + 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 {}", docker_image))); } - unreachable!() + Ok(()) } /// Pull one image URL with live progress streamed through @@ -799,24 +922,29 @@ 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 { - if let Some(stderr) = child.stderr.take() { - let reader = BufReader::new(stderr); - let mut lines = reader.lines(); - let pkg_id = package_id.to_string(); - let state_mgr = self.state_manager.clone(); + // 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(); + let pkg_id = package_id.to_string(); + let state_mgr = self.state_manager.clone(); - 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; + 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; + } } } - } - child.wait().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 { + 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" + )); + } } diff --git a/core/archipelago/src/api/rpc/package/runtime.rs b/core/archipelago/src/api/rpc/package/runtime.rs index 4e61a821..647fa1e3 100644 --- a/core/archipelago/src/api/rpc/package/runtime.rs +++ b/core/archipelago/src/api/rpc/package/runtime.rs @@ -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 { + 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 { + 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()], + } +} diff --git a/core/archipelago/src/api/rpc/package/stacks.rs b/core/archipelago/src/api/rpc/package/stacks.rs index d18d949d..2b5a6748 100644 --- a/core/archipelago/src/api/rpc/package/stacks.rs +++ b/core/archipelago/src/api/rpc/package/stacks.rs @@ -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 { + 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!({ diff --git a/core/archipelago/src/bootstrap.rs b/core/archipelago/src/bootstrap.rs index 685b62a6..01668fcf 100644 --- a/core/archipelago/src/bootstrap.rs +++ b/core/archipelago/src/bootstrap.rs @@ -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 { 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 { 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 { 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 { 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 { 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); } } diff --git a/core/archipelago/src/container/boot_reconciler.rs b/core/archipelago/src/container/boot_reconciler.rs index 3678edbf..264a86d0 100644 --- a/core/archipelago/src/container/boot_reconciler.rs +++ b/core/archipelago/src/container/boot_reconciler.rs @@ -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, ) -> Arc { - 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"), diff --git a/core/archipelago/src/container/companion.rs b/core/archipelago/src/container/companion.rs index dd2101f4..a0bb0ccd 100644 --- a/core/archipelago/src/container/companion.rs +++ b/core/archipelago/src/container/companion.rs @@ -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 { 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 { 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())]); + } } diff --git a/core/archipelago/src/container/docker_packages.rs b/core/archipelago/src/container/docker_packages.rs index 7415049d..56b3fece 100644 --- a/core/archipelago/src/container/docker_packages.rs +++ b/core/archipelago/src/container/docker_packages.rs @@ -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 = 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,10 +80,10 @@ 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) { - ui_containers.insert(canonical_id, ui_address); - } + 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); } } } @@ -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 { None } +fn companion_lan_address(app_id: &str) -> Option { + 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), diff --git a/core/archipelago/src/container/lnd.rs b/core/archipelago/src/container/lnd.rs new file mode 100644 index 00000000..4955e830 --- /dev/null +++ b/core/archipelago/src/container/lnd.rs @@ -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 { + 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> { + 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, +} + +#[derive(Debug)] +enum UnlockerResponse { + Value(T), + WalletAlreadyExists, +} + +#[derive(Debug, Serialize)] +struct InitWalletRequest { + wallet_password: String, + cipher_seed_mnemonic: Vec, +} + +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::( + &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 Deserialize<'de>>( + client: &reqwest::Client, + path: &str, +) -> Result> { + 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 Deserialize<'de>>( + client: &reqwest::Client, + path: &str, + body: serde_json::Value, +) -> Result> { + 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 Deserialize<'de>>( + resp: reqwest::Response, + path: &str, +) -> Result> { + 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); + } +} diff --git a/core/archipelago/src/container/mod.rs b/core/archipelago/src/container/mod.rs index cd7771f3..c72ce626 100644 --- a/core/archipelago/src/container/mod.rs +++ b/core/archipelago/src/container/mod.rs @@ -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; diff --git a/core/archipelago/src/container/prod_orchestrator.rs b/core/archipelago/src/container/prod_orchestrator.rs index 4a33c757..a766a0f0 100644 --- a/core/archipelago/src/container/prod_orchestrator.rs +++ b/core/archipelago/src/container/prod_orchestrator.rs @@ -29,7 +29,7 @@ use archipelago_container::{ HostFacts, ManifestError, ResolvedSource, SecretsProvider, }; use async_trait::async_trait; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::Arc; @@ -37,9 +37,9 @@ use tokio::sync::{Mutex, RwLock}; use crate::config::{Config, ContainerRuntime as ConfigContainerRuntime}; use crate::container::bitcoin_ui; -use crate::container::filebrowser; use crate::container::quadlet; use crate::container::traits::ContainerOrchestrator; +use crate::container::{filebrowser, lnd}; use crate::update::host_sudo; /// App IDs whose containers are named `archy-` rather than bare ``. @@ -70,6 +70,49 @@ pub fn compute_container_name(manifest: &AppManifest) -> String { } } +async fn chown_for_rootless_container(uid_gid: &str, path: &str) -> Result<()> { + let uid = uid_gid + .split_once(':') + .and_then(|(uid, _)| uid.parse::().ok()) + .unwrap_or(0); + + if uid > 0 && uid < 100_000 { + let output = tokio::process::Command::new("podman") + .args(["unshare", "chown", "-R", uid_gid, path]) + .output() + .await + .with_context(|| format!("podman unshare chown -R {uid_gid} {path}"))?; + if output.status.success() { + return Ok(()); + } + } + + let status = host_sudo(&["chown", "-R", uid_gid, path]) + .await + .with_context(|| format!("sudo chown -R {uid_gid} {path}"))?; + if status.success() { + return Ok(()); + } + + if uid == 0 || uid >= 100_000 { + return Err(anyhow::anyhow!( + "chown -R {uid_gid} {path} failed with status {:?}", + status.code() + )); + } + + let output = tokio::process::Command::new("podman") + .args(["unshare", "chown", "-R", uid_gid, path]) + .output() + .await + .with_context(|| format!("podman unshare chown -R {uid_gid} {path}"))?; + let stderr = String::from_utf8_lossy(&output.stderr); + Err(anyhow::anyhow!( + "chown -R {uid_gid} {path} failed; podman unshare also failed: {}", + stderr.trim() + )) +} + /// Outcome of `reconcile_all` for a single app. #[derive(Debug, Clone, PartialEq, Eq)] pub enum ReconcileAction { @@ -83,6 +126,12 @@ pub enum ReconcileAction { Left(String), } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReconcileMode { + ExistingOnly, + InstallMissing, +} + #[derive(Debug, Default)] pub struct ReconcileReport { /// (app_id, action) for each manifest we touched. @@ -115,6 +164,10 @@ struct LoadedManifest { struct OrchestratorState { /// app_id → loaded manifest manifests: HashMap, + /// App IDs intentionally uninstalled by the user. Manifests stay loaded so + /// explicit reinstalls still use the orchestrator path, but background + /// reconcile and companions must not resurrect them. + disabled: HashSet, /// app_id → per-app mutex, created lazily the first time we touch an app locks: HashMap>>, } @@ -123,6 +176,7 @@ impl OrchestratorState { fn new() -> Self { Self { manifests: HashMap::new(), + disabled: HashSet::new(), locks: HashMap::new(), } } @@ -131,6 +185,7 @@ impl OrchestratorState { pub struct ProdContainerOrchestrator { runtime: Arc, manifests_dir: PathBuf, + data_dir: PathBuf, state: Arc>, /// Where the bitcoin-ui pre-start hook reads its secret from and /// writes the rendered nginx.conf to. Configurable so tests can @@ -138,6 +193,8 @@ pub struct ProdContainerOrchestrator { bitcoin_ui_paths: bitcoin_ui::RenderPaths, /// Filebrowser on-disk bootstrap paths. filebrowser_paths: filebrowser::EnsurePaths, + /// LND on-disk bootstrap paths. + lnd_paths: lnd::EnsurePaths, /// Root directory for secret files referenced by /// `container.secret_env[*].secret_file`. secrets_dir: PathBuf, @@ -187,9 +244,11 @@ impl ProdContainerOrchestrator { Ok(Self { runtime, manifests_dir, + data_dir: config.data_dir.clone(), state: Arc::new(RwLock::new(OrchestratorState::new())), bitcoin_ui_paths: bitcoin_ui::RenderPaths::default(), filebrowser_paths: filebrowser::EnsurePaths::default(), + lnd_paths: lnd::EnsurePaths::default(), secrets_dir: PathBuf::from("/var/lib/archipelago/secrets"), use_quadlet_backends: config.use_quadlet_backends, }) @@ -202,9 +261,11 @@ impl ProdContainerOrchestrator { Self { runtime, manifests_dir, + data_dir: PathBuf::from("/var/lib/archipelago"), state: Arc::new(RwLock::new(OrchestratorState::new())), bitcoin_ui_paths: bitcoin_ui::RenderPaths::default(), filebrowser_paths: filebrowser::EnsurePaths::default(), + lnd_paths: lnd::EnsurePaths::default(), secrets_dir: PathBuf::from("/var/lib/archipelago/secrets"), use_quadlet_backends: false, } @@ -228,6 +289,16 @@ impl ProdContainerOrchestrator { self.filebrowser_paths = paths; } + #[cfg(test)] + pub fn set_data_dir(&mut self, data_dir: PathBuf) { + self.data_dir = data_dir; + } + + #[cfg(test)] + pub fn set_lnd_paths(&mut self, paths: lnd::EnsurePaths) { + self.lnd_paths = paths; + } + /// Walk `manifests_dir` looking for `*/manifest.yml` files. Parses each, /// validates it, and stores it in the in-memory state. /// @@ -276,7 +347,22 @@ impl ProdContainerOrchestrator { let count = loaded.len(); let mut state = self.state.write().await; state.manifests.clear(); + state.disabled.clear(); + let has_split_mempool_stack = loaded + .iter() + .any(|lm| lm.manifest.app.id == "archy-mempool-db") + && loaded.iter().any(|lm| lm.manifest.app.id == "mempool-api") + && loaded + .iter() + .any(|lm| lm.manifest.app.id == "archy-mempool-web"); + for lm in loaded { + if has_split_mempool_stack && lm.manifest.app.id == "mempool" { + tracing::info!( + "skipping legacy mempool manifest because split mempool stack manifests are present" + ); + continue; + } state.manifests.insert(lm.manifest.app.id.clone(), lm); } Ok(count) @@ -286,6 +372,7 @@ impl ProdContainerOrchestrator { #[cfg(test)] pub async fn insert_manifest_for_test(&self, manifest: AppManifest, manifest_dir: PathBuf) { let mut state = self.state.write().await; + state.disabled.remove(&manifest.app.id); state.manifests.insert( manifest.app.id.clone(), LoadedManifest { @@ -323,8 +410,15 @@ impl ProdContainerOrchestrator { /// Snapshot of the app IDs currently in the in-memory manifest map. /// Used by the boot reconciler to drive companion-unit reconciliation. pub async fn manifest_ids(&self) -> Vec { + let user_stopped = crate::crash_recovery::load_user_stopped(&self.data_dir).await; let state = self.state.read().await; - state.manifests.keys().cloned().collect() + state + .manifests + .keys() + .filter(|app_id| !state.disabled.contains(*app_id)) + .filter(|app_id| !user_stopped.contains(*app_id)) + .cloned() + .collect() } /// Scan the runtime for containers whose names match one of our manifests. @@ -351,15 +445,39 @@ impl ProdContainerOrchestrator { /// Walk every loaded manifest and ensure it has a running container. /// Never panics; per-app failures are collected into the report. + #[cfg(test)] pub async fn reconcile_all(&self) -> ReconcileReport { + self.reconcile_all_with_mode(ReconcileMode::InstallMissing) + .await + } + + /// Reconcile loaded manifests. Boot uses `ExistingOnly` so merely listing a + /// catalog manifest never installs an unqualified app; explicit install/start + /// paths still call `ensure_running` and can create missing containers. + pub async fn reconcile_existing(&self) -> ReconcileReport { + self.reconcile_all_with_mode(ReconcileMode::ExistingOnly) + .await + } + + async fn reconcile_all_with_mode(&self, mode: ReconcileMode) -> ReconcileReport { + let user_stopped = crate::crash_recovery::load_user_stopped(&self.data_dir).await; let manifests: Vec = { let state = self.state.read().await; - state.manifests.values().cloned().collect() + state + .manifests + .iter() + .filter(|(app_id, _)| !state.disabled.contains(*app_id)) + .filter(|(app_id, lm)| { + !user_stopped.contains(*app_id) + && !user_stopped.contains(&compute_container_name(&lm.manifest)) + }) + .map(|(_, lm)| lm.clone()) + .collect() }; let mut report = ReconcileReport::default(); for lm in manifests { let app_id = lm.manifest.app.id.clone(); - match self.ensure_running(&lm).await { + match self.ensure_running_with_mode(&lm, mode).await { Ok(action) => report.record(&app_id, action), Err(e) => { tracing::error!(app_id = %app_id, error = %e, "reconcile failed"); @@ -371,64 +489,129 @@ impl ProdContainerOrchestrator { } async fn ensure_running(&self, lm: &LoadedManifest) -> Result { + self.ensure_running_with_mode(lm, ReconcileMode::InstallMissing) + .await + } + + async fn ensure_running_with_mode( + &self, + lm: &LoadedManifest, + mode: ReconcileMode, + ) -> Result { let app_id = lm.manifest.app.id.clone(); + if app_id == "indeedhub" { + // IndeedHub is a multi-container stack installed by the package + // stack path. Reconciling its single manifest races stack installs + // and can recreate a broken frontend container with the same name. + return Ok(ReconcileAction::Left("stack-managed".to_string())); + } let lock = self.app_lock(&app_id).await; let _guard = lock.lock().await; + let mut resolved_manifest = lm.manifest.clone(); + self.resolve_dynamic_env(&mut resolved_manifest)?; let name = compute_container_name(&lm.manifest); - // Phase 3.3: migrate pre-Phase-3 containers in place. - if self.use_quadlet_backends { - if let Some(action) = self.migrate_to_quadlet_if_needed(lm, &name).await? { - return Ok(action); - } - // Sync drift: keep an existing Quadlet unit's bytes in step - // with what the renderer produces today, even when nothing - // else triggers an install. Without this, every renderer change - // (new directive, fixed bug) requires a fresh package.install - // RPC per app to take effect — observed live on .228 2026-05-02 - // where the TimeoutStartSec=600 fix shipped in code but no - // existing units picked it up. - self.sync_quadlet_unit(lm, &name).await?; - } - match self.runtime.get_container_status(&name).await { - Ok(status) => match status.state { - ContainerState::Running => { - // App-specific hooks get a chance to refresh bind-mounted - // config. bitcoin-ui: re-render nginx.conf if the RPC - // password rotated (or template changed via OTA). If - // anything was rewritten, restart the container so nginx - // picks up the new config. - if let Some(HookOutcome::Rewritten) = self.run_pre_start_hooks(&app_id).await? { - tracing::info!(app_id = %app_id, "config rewritten while running — restarting"); - let _ = self.runtime.stop_container(&name).await; - self.runtime - .start_container(&name) - .await - .with_context(|| format!("reconcile restart {name}"))?; - return Ok(ReconcileAction::Started); + Ok(status) => { + // Phase 3.3: migrate pre-Phase-3 containers in place, but only + // after proving the container exists. Boot reconciliation must + // not create every catalog app just because a Quadlet unit is + // absent. + if self.use_quadlet_backends { + if let Some(action) = self.migrate_to_quadlet_if_needed(lm, &name).await? { + return Ok(action); } - Ok(ReconcileAction::NoOp) + self.sync_quadlet_unit(lm, &name).await?; } - ContainerState::Stopped | ContainerState::Exited => { - // Even for a plain start, re-run the pre-start hooks so - // the bind-mounted file is fresh before nginx starts - // reading it. A Rewritten outcome is fine here — we're - // about to start from a stopped state anyway. - self.run_pre_start_hooks(&app_id).await?; - self.runtime - .start_container(&name) - .await - .with_context(|| format!("reconcile start {name}"))?; - Ok(ReconcileAction::Started) + match status.state { + ContainerState::Running => { + // App-specific hooks get a chance to refresh bind-mounted + // config. bitcoin-ui: re-render nginx.conf if the RPC + // password rotated (or template changed via OTA). If + // anything was rewritten, restart the container so nginx + // picks up the new config. + if let Some(HookOutcome::Rewritten) = + self.run_pre_start_hooks(&app_id).await? + { + tracing::info!(app_id = %app_id, "config rewritten while running — restarting"); + let _ = self.runtime.stop_container(&name).await; + self.runtime + .start_container(&name) + .await + .with_context(|| format!("reconcile restart {name}"))?; + self.run_post_start_hooks(&app_id).await?; + return Ok(ReconcileAction::Started); + } + if self.container_env_drifted(&name, &resolved_manifest).await { + tracing::info!(app_id = %app_id, container = %name, "container env drift detected — recreating"); + let _ = self.runtime.stop_container(&name).await; + let _ = self.runtime.remove_container(&name).await; + self.install_fresh(lm).await?; + return Ok(ReconcileAction::Installed); + } + self.run_post_start_hooks(&app_id).await?; + Ok(ReconcileAction::NoOp) + } + ContainerState::Stopped | ContainerState::Exited => { + // Even for a plain start, re-run the pre-start hooks so + // the bind-mounted file is fresh before nginx starts + // reading it. A Rewritten outcome is fine here — we're + // about to start from a stopped state anyway. + self.prepare_for_start(&resolved_manifest).await?; + if self.container_env_drifted(&name, &resolved_manifest).await { + tracing::info!(app_id = %app_id, container = %name, "stopped container env drift detected — recreating"); + let _ = self.runtime.remove_container(&name).await; + self.install_fresh(lm).await?; + return Ok(ReconcileAction::Installed); + } + if let Err(e) = self.runtime.start_container(&name).await { + tracing::warn!( + app_id = %app_id, + container = %name, + error = %e, + "start failed for stopped/exited container; recreating container record" + ); + let _ = self.runtime.stop_container(&name).await; + let _ = self.runtime.remove_container(&name).await; + self.install_fresh(lm).await?; + return Ok(ReconcileAction::Installed); + } + self.run_post_start_hooks(&app_id).await?; + Ok(ReconcileAction::Started) + } + ContainerState::Created => { + self.prepare_for_start(&resolved_manifest).await?; + if self.container_env_drifted(&name, &resolved_manifest).await { + tracing::info!(app_id = %app_id, container = %name, "created container env drift detected — recreating"); + let _ = self.runtime.remove_container(&name).await; + self.install_fresh(lm).await?; + return Ok(ReconcileAction::Installed); + } + if let Err(e) = self.runtime.start_container(&name).await { + tracing::warn!( + app_id = %app_id, + container = %name, + error = %e, + "start failed for created container; recreating container record" + ); + let _ = self.runtime.stop_container(&name).await; + let _ = self.runtime.remove_container(&name).await; + self.install_fresh(lm).await?; + return Ok(ReconcileAction::Installed); + } + self.run_post_start_hooks(&app_id).await?; + Ok(ReconcileAction::Started) + } + ContainerState::Paused => Ok(ReconcileAction::Left("paused".to_string())), + ContainerState::Unknown(s) => Ok(ReconcileAction::Left(s)), } - ContainerState::Created => Ok(ReconcileAction::Left("created".to_string())), - ContainerState::Paused => Ok(ReconcileAction::Left("paused".to_string())), - ContainerState::Unknown(s) => Ok(ReconcileAction::Left(s)), - }, + } Err(_) => { // Container missing entirely → install fresh. + if mode == ReconcileMode::ExistingOnly { + return Ok(ReconcileAction::Left("absent".to_string())); + } self.install_fresh(lm).await?; Ok(ReconcileAction::Installed) } @@ -488,8 +671,7 @@ impl ProdContainerOrchestrator { // with the plaintext RPC auth substituted in. Failure is fatal: if we // can't render the config the bind-mount would resolve to either a // stale file or a missing path, and nginx would 502 every request. - self.run_pre_start_hooks(&lm.manifest.app.id).await?; - self.apply_data_uid(&resolved_manifest).await?; + self.prepare_for_start(&resolved_manifest).await?; self.ensure_container_network(&resolved_manifest).await?; if self.use_quadlet_backends { @@ -510,9 +692,38 @@ impl ProdContainerOrchestrator { .await .with_context(|| format!("start_container {name}"))?; } + self.run_post_start_hooks(&lm.manifest.app.id).await?; Ok(()) } + async fn run_post_start_hooks(&self, app_id: &str) -> Result<()> { + if cfg!(test) { + return Ok(()); + } + + match app_id { + "lnd" => lnd::ensure_wallet_initialized() + .await + .context("lnd post-start: ensure wallet initialized"), + _ => Ok(()), + } + } + + async fn prepare_for_start(&self, manifest: &AppManifest) -> Result<()> { + self.run_pre_start_hooks(&manifest.app.id).await?; + self.ensure_bind_mount_dirs(manifest).await?; + self.apply_data_uid(manifest).await?; + self.run_post_data_uid_hooks(&manifest.app.id).await?; + Ok(()) + } + + async fn run_post_data_uid_hooks(&self, app_id: &str) -> Result<()> { + match app_id { + "fedimint" | "fedimint-gateway" => self.ensure_fedimint_dirs().await, + _ => Ok(()), + } + } + /// Phase 3.3 in-place migration. When `use_quadlet_backends` flips /// from off → on, existing nodes have backend containers parented /// under archipelago.service's cgroup (the bad shape). They need to @@ -656,11 +867,7 @@ impl ProdContainerOrchestrator { /// writes it atomically into ~/.config/containers/systemd/, asks /// systemd to reload, and starts the generated service. Errors at /// any step propagate as install_fresh failures — no half-state. - async fn install_via_quadlet( - &self, - resolved_manifest: &AppManifest, - name: &str, - ) -> Result<()> { + async fn install_via_quadlet(&self, resolved_manifest: &AppManifest, name: &str) -> Result<()> { let unit = quadlet::QuadletUnit::from_manifest(resolved_manifest, name); let dir = quadlet::unit_dir() .await @@ -755,10 +962,227 @@ impl ProdContainerOrchestrator { filebrowser::EnsureOutcome::Unchanged => HookOutcome::Unchanged, })) } + "lnd" => { + let rpc_pass = FileSecretsProvider { + root: self.secrets_dir.clone(), + } + .read("bitcoin-rpc-password") + .context("lnd pre-start: read bitcoin RPC password")?; + let outcome = lnd::ensure_config(&self.lnd_paths, &rpc_pass) + .await + .context("lnd pre-start: ensure lnd.conf")?; + Ok(Some(match outcome { + lnd::EnsureOutcome::Written => HookOutcome::Rewritten, + lnd::EnsureOutcome::Unchanged => HookOutcome::Unchanged, + })) + } + "archy-nbxplorer" | "btcpay-server" => { + self.ensure_btcpay_stack_dirs().await?; + Ok(Some(HookOutcome::Unchanged)) + } + "indeedhub" => { + self.start_indeedhub_backends().await?; + Ok(Some(HookOutcome::Unchanged)) + } + "grafana" => { + self.cleanup_stale_grafana_port().await; + Ok(Some(HookOutcome::Unchanged)) + } _ => Ok(None), } } + async fn ensure_btcpay_stack_dirs(&self) -> Result<()> { + for dir in [ + "/var/lib/archipelago/btcpay/Main", + "/var/lib/archipelago/nbxplorer/Main", + ] { + let status = host_sudo(&["mkdir", "-p", dir]) + .await + .with_context(|| format!("mkdir {dir}"))?; + if !status.success() { + return Err(anyhow::anyhow!( + "mkdir -p {dir} failed with status {status}" + )); + } + } + + for dir in [ + "/var/lib/archipelago/btcpay", + "/var/lib/archipelago/nbxplorer", + ] { + let status = host_sudo(&["chown", "-R", "1000:1000", dir]) + .await + .with_context(|| format!("chown {dir}"))?; + if !status.success() { + return Err(anyhow::anyhow!("chown {dir} failed with status {status}")); + } + } + + let db_dir = "/var/lib/archipelago/postgres-btcpay"; + let status = host_sudo(&["chown", "-R", "100998:100998", db_dir]) + .await + .with_context(|| format!("chown {db_dir}"))?; + if !status.success() { + return Err(anyhow::anyhow!( + "chown {db_dir} failed with status {status}" + )); + } + self.repair_btcpay_database_password().await?; + Ok(()) + } + + async fn ensure_fedimint_dirs(&self) -> Result<()> { + for dir in [ + "/var/lib/archipelago/fedimint", + "/var/lib/archipelago/fedimint-gateway", + ] { + let mkdir = host_sudo(&["mkdir", "-p", dir]) + .await + .with_context(|| format!("mkdir {dir}"))?; + if !mkdir.success() { + return Err(anyhow::anyhow!("mkdir -p {dir} failed with status {mkdir}")); + } + + // Fedimint images currently run as root inside the rootless + // container. Container uid 0 maps to the Archipelago host user + // (1000), not to subuid 100000. Repair old installs that were + // chowned into the subuid range and crash on database.db.lock. + let chown = host_sudo(&["chown", "-R", "1000:1000", dir]) + .await + .with_context(|| format!("chown {dir}"))?; + if !chown.success() { + return Err(anyhow::anyhow!("chown {dir} failed with status {chown}")); + } + } + Ok(()) + } + + async fn repair_btcpay_database_password(&self) -> Result<()> { + let secret_path = self.secrets_dir.join("btcpay-db-password"); + let Ok(db_pass) = tokio::fs::read_to_string(&secret_path).await else { + return Ok(()); + }; + let db_pass = db_pass.trim(); + if db_pass.is_empty() { + return Ok(()); + } + + if self + .runtime + .get_container_status("archy-btcpay-db") + .await + .is_err() + { + return Ok(()); + } + let _ = self.runtime.start_container("archy-btcpay-db").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 output = tokio::process::Command::new("podman") + .args([ + "exec", + "archy-btcpay-db", + "psql", + "-U", + "btcpay", + "-d", + "btcpay", + "-c", + &sql, + ]) + .output() + .await + .context("btcpay db password repair: exec psql")?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + tracing::warn!(error = %stderr.trim(), "btcpay db password repair failed"); + } + let _ = tokio::process::Command::new("podman") + .args([ + "exec", + "archy-btcpay-db", + "createdb", + "-U", + "btcpay", + "nbxplorer", + ]) + .output() + .await; + Ok(()) + } + + async fn start_indeedhub_backends(&self) -> Result<()> { + let _ = tokio::process::Command::new("podman") + .args(["network", "create", "indeedhub-net"]) + .output() + .await; + + for name in [ + "indeedhub-postgres", + "indeedhub-redis", + "indeedhub-minio", + "indeedhub-relay", + "indeedhub-api", + "indeedhub-ffmpeg", + ] { + let exists = self.runtime.get_container_status(name).await.is_ok(); + if exists { + let _ = self.runtime.start_container(name).await; + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + } + } + self.repair_indeedhub_network_aliases().await; + Ok(()) + } + + async fn repair_indeedhub_network_aliases(&self) { + 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 cleanup_stale_grafana_port(&self) { + let _ = tokio::process::Command::new("pkill") + .args(["-f", "pasta.*3001"]) + .output() + .await; + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + } + fn detect_host_facts(&self) -> HostFacts { let host_ip = Self::detect_host_ip().unwrap_or_else(|| "127.0.0.1".to_string()); let host_mdns = Self::detect_host_mdns(); @@ -846,10 +1270,70 @@ impl ProdContainerOrchestrator { ) })?; env.extend(secrets); + Self::expand_env_placeholders(&mut env); manifest.app.environment = env; Ok(()) } + fn expand_env_placeholders(env: &mut Vec) { + let values: HashMap = env + .iter() + .filter_map(|entry| { + let (key, value) = entry.split_once('=')?; + Some((key.to_string(), value.to_string())) + }) + .collect(); + + for entry in env.iter_mut() { + let Some((key, value)) = entry.split_once('=') else { + continue; + }; + let mut expanded = value.to_string(); + for (placeholder_key, placeholder_value) in &values { + expanded = + expanded.replace(&format!("${{{}}}", placeholder_key), placeholder_value); + } + *entry = format!("{}={}", key, expanded); + } + } + + async fn container_env_drifted(&self, name: &str, manifest: &AppManifest) -> bool { + if cfg!(test) { + return false; + } + + let inspect = tokio::process::Command::new("podman") + .args([ + "inspect", + name, + "--format", + "{{range .Config.Env}}{{println .}}{{end}}", + ]) + .output() + .await; + let Ok(output) = inspect else { + return false; + }; + if !output.status.success() { + return false; + } + + let current: HashMap = String::from_utf8_lossy(&output.stdout) + .lines() + .filter_map(|line| { + let (key, value) = line.split_once('=')?; + Some((key.to_string(), value.to_string())) + }) + .collect(); + + manifest.app.environment.iter().any(|entry| { + let Some((key, expected)) = entry.split_once('=') else { + return false; + }; + current.get(key).map_or(true, |actual| actual != expected) + }) + } + async fn apply_data_uid(&self, manifest: &AppManifest) -> Result<()> { let Some(uid_gid) = manifest.app.container.data_uid.as_ref() else { return Ok(()); @@ -871,16 +1355,36 @@ impl ProdContainerOrchestrator { )); } - let status = host_sudo(&["chown", "-R", uid_gid, &volume.source]) - .await - .with_context(|| format!("running chown on {}", volume.source))?; + if let Err(err) = chown_for_rootless_container(uid_gid, &volume.source).await { + return Err(err.context(format!("running chown on {}", volume.source))); + } + } + Ok(()) + } - if !status.success() { + async fn ensure_bind_mount_dirs(&self, manifest: &AppManifest) -> Result<()> { + for volume in &manifest.app.volumes { + if volume.volume_type == "tmpfs" || volume.source.is_empty() { + continue; + } + if !Path::new(&volume.source).is_absolute() { + continue; + } + + // File mounts are rendered by app-specific hooks; everything else + // must exist before the Podman API accepts the bind mount. + if volume.target.contains('.') || volume.source.contains('.') { + continue; + } + + let mkdir_status = host_sudo(&["mkdir", "-p", &volume.source]) + .await + .with_context(|| format!("mkdir {}", volume.source))?; + if !mkdir_status.success() { return Err(anyhow::anyhow!( - "chown -R {} {} failed with status {:?}", - uid_gid, + "mkdir -p {} failed with status {:?}", volume.source, - status.code() + mkdir_status.code() )); } } @@ -906,6 +1410,10 @@ enum HookOutcome { #[async_trait] impl ContainerOrchestrator for ProdContainerOrchestrator { async fn install(&self, app_id: &str) -> Result { + { + let mut state = self.state.write().await; + state.disabled.remove(app_id); + } // Idempotent: if the container is already up and healthy, just // refresh hooks and return. If it's stopped, start it. If it's // missing or in a wedged state, install fresh. @@ -920,11 +1428,12 @@ impl ContainerOrchestrator for ProdContainerOrchestrator { // ensure_running takes the per-app lock itself; release the install // path lock first if we hold one (we don't — install is the entry // point). Just delegate. + self.prepare_for_start(&lm.manifest).await?; let action = self.ensure_running(&lm).await?; match action { - ReconcileAction::NoOp - | ReconcileAction::Started - | ReconcileAction::Installed => Ok(name), + ReconcileAction::NoOp | ReconcileAction::Started | ReconcileAction::Installed => { + Ok(name) + } ReconcileAction::Left(state) => { // Container is in a wedged state (Created / Paused / Unknown). // Force-recreate so the install RPC has a clean outcome. @@ -944,17 +1453,26 @@ impl ContainerOrchestrator for ProdContainerOrchestrator { } async fn start(&self, app_id: &str) -> Result<()> { + { + let mut state = self.state.write().await; + state.disabled.remove(app_id); + } let lm = self.loaded(app_id).await?; - let lock = self.app_lock(app_id).await; - let _guard = lock.lock().await; - let name = compute_container_name(&lm.manifest); - self.runtime - .start_container(&name) - .await - .with_context(|| format!("start_container {name}")) + let action = self.ensure_running(&lm).await?; + match action { + ReconcileAction::NoOp | ReconcileAction::Started | ReconcileAction::Installed => Ok(()), + ReconcileAction::Left(state) => Err(anyhow::anyhow!( + "container {} left in {state}", + compute_container_name(&lm.manifest) + )), + } } async fn stop(&self, app_id: &str) -> Result<()> { + { + let mut state = self.state.write().await; + state.disabled.insert(app_id.to_string()); + } let lm = self.loaded(app_id).await?; let lock = self.app_lock(app_id).await; let _guard = lock.lock().await; @@ -972,6 +1490,7 @@ impl ContainerOrchestrator for ProdContainerOrchestrator { let name = compute_container_name(&lm.manifest); // Best-effort stop (ignored if already stopped), then start. let _ = self.runtime.stop_container(&name).await; + self.prepare_for_start(&lm.manifest).await?; self.runtime .start_container(&name) .await @@ -986,11 +1505,41 @@ impl ContainerOrchestrator for ProdContainerOrchestrator { let lock = self.app_lock(app_id).await; let _guard = lock.lock().await; let name = compute_container_name(&lm.manifest); + + let unit_removed = match quadlet::unit_dir().await { + Ok(dir) => { + let unit_path = dir.join(format!("{name}.container")); + if tokio::fs::try_exists(&unit_path).await.unwrap_or(false) { + quadlet::disable_remove(&name, &dir) + .await + .with_context(|| format!("remove quadlet unit for {name}"))?; + true + } else { + false + } + } + Err(e) => { + tracing::debug!(container = %name, error = %e, "remove: cannot locate quadlet dir"); + false + } + }; + let _ = self.runtime.stop_container(&name).await; - self.runtime - .remove_container(&name) - .await - .with_context(|| format!("remove_container {name}")) + let mut remove_err = None; + if unit_removed { + let _ = self.runtime.remove_container(&name).await; + } else { + if let Err(e) = self.runtime.remove_container(&name).await { + remove_err = Some(e.context(format!("remove_container {name}"))); + } + } + + let mut state = self.state.write().await; + state.disabled.insert(app_id.to_string()); + if let Some(e) = remove_err { + return Err(e); + } + Ok(()) } /// Upgrade: stop-remove-reinstall (re-pulls or rebuilds as required). @@ -1073,6 +1622,8 @@ mod tests { created_env: StdMutex>>, /// If set, the next `build_image` call fails with this message. fail_build: StdMutex>, + /// If set, `start_container` for this container fails with this message. + fail_start: StdMutex>, } impl MockRuntime { @@ -1124,6 +1675,10 @@ mod tests { } async fn start_container(&self, name: &str) -> Result<()> { self.record(format!("start_container:{name}")); + let fail = self.fail_start.lock().unwrap().remove(name); + if let Some(msg) = fail { + return Err(anyhow::anyhow!(msg)); + } self.set_state(name, ContainerState::Running); Ok(()) } @@ -1310,6 +1865,25 @@ app: AppManifest::parse(yaml).unwrap() } + fn pull_manifest_lnd() -> AppManifest { + let yaml = r#" +app: + id: lnd + name: LND + version: 1.0.0 + container: + image: git.tx1138.com/lfg2025/lnd:v0.18.4-beta + secret_env: + - key: BITCOIND_RPCPASS + secret_file: bitcoin-rpc-password + volumes: + - type: bind + source: /tmp/lnd + target: /root/.lnd +"#; + AppManifest::parse(yaml).unwrap() + } + fn test_bitcoin_ui_paths() -> bitcoin_ui::RenderPaths { use std::sync::OnceLock; static DIR: OnceLock = OnceLock::new(); @@ -1579,6 +2153,35 @@ app: assert!(!calls.iter().any(|c| c.starts_with("pull_image:"))); } + #[tokio::test] + async fn reconcile_recreates_when_exited_start_fails() { + let rt = Arc::new(MockRuntime::default()); + rt.mark_image_present("docker.io/bitcoin/knots:28"); + rt.set_state("bitcoin-knots", ContainerState::Exited); + rt.fail_start + .lock() + .unwrap() + .insert("bitcoin-knots".into(), "stale runtime state".into()); + let orch = orch_with(rt.clone()).await; + orch.insert_manifest_for_test( + pull_manifest("bitcoin-knots", "docker.io/bitcoin/knots:28"), + PathBuf::from("/tmp/bk"), + ) + .await; + + let report = orch.reconcile_all().await; + assert_eq!( + report.actions, + vec![("bitcoin-knots".to_string(), ReconcileAction::Installed)] + ); + assert!(report.failures.is_empty()); + let calls = rt.calls(); + assert!(calls.iter().any(|c| c == "remove_container:bitcoin-knots")); + assert!(calls + .iter() + .any(|c| c == "create_container:bitcoin-knots:offset=0")); + } + #[tokio::test] async fn reconcile_installs_missing_container() { let rt = Arc::new(MockRuntime::default()); @@ -1695,6 +2298,48 @@ app: ); } + #[tokio::test] + async fn remove_disables_manifest_so_reconcile_does_not_reinstall() { + let rt = Arc::new(MockRuntime::default()); + rt.set_state("bitcoin-knots", ContainerState::Running); + let orch = orch_with(rt.clone()).await; + orch.insert_manifest_for_test( + pull_manifest("bitcoin-knots", "docker.io/bitcoin/knots:28"), + PathBuf::from("/tmp/bk"), + ) + .await; + + orch.remove("bitcoin-knots", true).await.unwrap(); + + let report = orch.reconcile_all().await; + assert!(report.actions.is_empty()); + assert!(report.failures.is_empty()); + let calls = rt.calls(); + assert!(calls.iter().any(|c| c == "remove_container:bitcoin-knots")); + assert!(!calls.iter().any(|c| c.starts_with("pull_image:"))); + assert!(!calls.iter().any(|c| c.starts_with("create_container:"))); + } + + #[tokio::test] + async fn install_reenables_manifest_after_remove() { + let rt = Arc::new(MockRuntime::default()); + rt.set_state("bitcoin-knots", ContainerState::Running); + let orch = orch_with(rt.clone()).await; + orch.insert_manifest_for_test( + pull_manifest("bitcoin-knots", "docker.io/bitcoin/knots:28"), + PathBuf::from("/tmp/bk"), + ) + .await; + + orch.remove("bitcoin-knots", true).await.unwrap(); + rt.set_state("bitcoin-knots", ContainerState::Running); + orch.install("bitcoin-knots").await.unwrap(); + + let report = orch.reconcile_all().await; + assert_eq!(report.actions.len(), 1); + assert!(report.failures.is_empty()); + } + #[tokio::test] async fn list_filters_to_known_manifests_only() { let rt = Arc::new(MockRuntime::default()); @@ -1712,6 +2357,30 @@ app: assert_eq!(names, vec!["bitcoin-knots"]); } + #[tokio::test] + async fn stop_disables_manifest_until_start() { + let rt = Arc::new(MockRuntime::default()); + rt.set_state("bitcoin-knots", ContainerState::Running); + let orch = orch_with(rt.clone()).await; + orch.insert_manifest_for_test( + pull_manifest("bitcoin-knots", "docker.io/bitcoin/knots:28"), + PathBuf::from("/tmp/bk"), + ) + .await; + + orch.stop("bitcoin-knots").await.unwrap(); + let report = orch.reconcile_all().await; + assert!(report.actions.is_empty()); + assert!(report.failures.is_empty()); + let calls = rt.calls(); + assert!(calls.iter().any(|c| c == "stop_container:bitcoin-knots")); + assert!(!calls.iter().any(|c| c == "start_container:bitcoin-knots")); + + orch.start("bitcoin-knots").await.unwrap(); + let calls = rt.calls(); + assert!(calls.iter().any(|c| c == "start_container:bitcoin-knots")); + } + #[tokio::test] async fn health_maps_states_to_strings() { let rt = Arc::new(MockRuntime::default()); @@ -1768,4 +2437,36 @@ app: .iter() .any(|c| c == "create_container:filebrowser:offset=0")); } + + #[tokio::test] + async fn install_lnd_writes_config_before_create() { + let rt = Arc::new(MockRuntime::default()); + let mut orch = orch_with(rt.clone()).await; + + let tmp = tempfile::TempDir::new().unwrap(); + let secrets = tmp.path().join("secrets"); + std::fs::create_dir_all(&secrets).unwrap(); + std::fs::write(secrets.join("bitcoin-rpc-password"), "secret-pass\n").unwrap(); + orch.set_secrets_dir(secrets); + + let paths = lnd::EnsurePaths { + data_dir: tmp.path().join("lnd"), + conf_path: tmp.path().join("lnd/lnd.conf"), + }; + orch.set_lnd_paths(paths.clone()); + + let mut m = pull_manifest_lnd(); + m.app.volumes[0].source = paths.data_dir.to_string_lossy().into_owned(); + orch.insert_manifest_for_test(m, PathBuf::from("/tmp/lnd")) + .await; + + orch.install("lnd").await.unwrap(); + + let cfg = std::fs::read_to_string(paths.conf_path).unwrap(); + assert!(cfg.contains("bitcoin.active=true")); + assert!(cfg.contains("bitcoin.mainnet=true")); + assert!(cfg.contains("bitcoind.rpcpass=secret-pass")); + let calls = rt.calls(); + assert!(calls.iter().any(|c| c == "create_container:lnd:offset=0")); + } } diff --git a/core/archipelago/src/container/quadlet.rs b/core/archipelago/src/container/quadlet.rs index 1ec54d56..ce805e41 100644 --- a/core/archipelago/src/container/quadlet.rs +++ b/core/archipelago/src/container/quadlet.rs @@ -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 { +fn translate_health_check(hc: &archipelago_container::HealthCheck) -> Option { 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); } diff --git a/core/archipelago/src/crash_recovery.rs b/core/archipelago/src/crash_recovery.rs index 0ff75a3b..ad9caa09 100644 --- a/core/archipelago/src/crash_recovery.rs +++ b/core/archipelago/src/crash_recovery.rs @@ -412,6 +412,19 @@ pub async fn start_stopped_containers(data_dir: &Path) -> RecoveryReport { }; } + let names: Vec = 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 = 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")); + } } diff --git a/core/archipelago/src/health_monitor.rs b/core/archipelago/src/health_monitor.rs index 1aadbf7c..51b7ecdd 100644 --- a/core/archipelago/src/health_monitor.rs +++ b/core/archipelago/src/health_monitor.rs @@ -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, @@ -539,6 +567,16 @@ pub fn spawn_health_monitor(state: Arc, 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, 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); diff --git a/core/archipelago/src/main.rs b/core/archipelago/src/main.rs index 68d47b41..d534f936 100644 --- a/core/archipelago/src/main.rs +++ b/core/archipelago/src/main.rs @@ -137,44 +137,39 @@ 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 { - Ok(Some(containers)) => { - info!( - "🔧 Recovering {} containers from previous crash...", - containers.len() - ); - let report = crash_recovery::recover_containers(&containers).await; - info!( - "🔧 Recovery complete: {}/{} containers restarted (failed: {:?})", - report.recovered, report.total, report.failed - ); - } - Ok(None) => {} - Err(e) => { - tracing::warn!("Crash recovery check failed: {}", e); - } - } - - // 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; - 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(); - }); + // 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...", + containers.len() + ); + let report = crash_recovery::recover_containers(&containers).await; + info!( + "🔧 Recovery complete: {}/{} containers restarted (failed: {:?})", + report.recovered, report.total, report.failed + ); + } + Ok(None) => {} + Err(e) => { + tracing::warn!("Crash recovery check failed: {}", e); + } } + // 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 + ); + } + 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 // BootReconciler loop (Step 5/6 of the rust-orchestrator migration). diff --git a/core/archipelago/src/port_allocator.rs b/core/archipelago/src/port_allocator.rs index e847faab..4bc5d114 100644 --- a/core/archipelago/src/port_allocator.rs +++ b/core/archipelago/src/port_allocator.rs @@ -13,8 +13,8 @@ use std::path::Path; 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) + 9735, 10009, 8080, // LND P2P, gRPC, REST + 18083, // LND UI (archy-lnd-ui) 4080, 8999, 50001, // Mempool stack 23000, // BTCPay 8173, 8174, 8175, // Fedimint diff --git a/core/archipelago/src/server.rs b/core/archipelago/src/server.rs index bbb2756d..2f6d70d1 100644 --- a/core/archipelago/src/server.rs +++ b/core/archipelago/src/server.rs @@ -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, transitional_since: &mut HashMap, ) -> 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 [ diff --git a/core/container/src/podman_client.rs b/core/container/src/podman_client.rs index a50100f8..9bbdf34e 100644 --- a/core/container/src/podman_client.rs +++ b/core/container/src/podman_client.rs @@ -109,7 +109,7 @@ impl PodmanClient { pub fn lan_address_for(name: &str) -> Option { 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::>(), @@ -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 diff --git a/core/container/src/runtime.rs b/core/container/src/runtime.rs index b0d3b040..8b9499d5 100644 --- a/core/container/src/runtime.rs +++ b/core/container/src/runtime.rs @@ -104,7 +104,20 @@ impl ContainerRuntime for PodmanRuntime { } async fn list_containers(&self) -> Result> { - 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 { @@ -147,6 +160,83 @@ impl ContainerRuntime for PodmanRuntime { } } +fn parse_podman_ps_json(stdout: &[u8]) -> Result> { + let text = String::from_utf8_lossy(stdout); + if text.trim().is_empty() { + return Ok(Vec::new()); + } + + let containers: Vec = 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 { + 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 { + 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"]); + } } diff --git a/image-recipe/README.md b/image-recipe/README.md index 1038c072..e45d193f 100644 --- a/image-recipe/README.md +++ b/image-recipe/README.md @@ -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 diff --git a/image-recipe/_archived/build-auto-installer-iso.sh b/image-recipe/_archived/build-auto-installer-iso.sh index aef4dc00..c178df34 100755 --- a/image-recipe/_archived/build-auto-installer-iso.sh +++ b/image-recipe/_archived/build-auto-installer-iso.sh @@ -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 diff --git a/image-recipe/archipelago-scripts/install-to-disk.sh b/image-recipe/archipelago-scripts/install-to-disk.sh index 7f50dc29..d319362c 100755 --- a/image-recipe/archipelago-scripts/install-to-disk.sh +++ b/image-recipe/archipelago-scripts/install-to-disk.sh @@ -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 diff --git a/image-recipe/build-debian-iso.sh b/image-recipe/build-debian-iso.sh new file mode 100644 index 00000000..6908a223 --- /dev/null +++ b/image-recipe/build-debian-iso.sh @@ -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" "$@" diff --git a/image-recipe/configs/nginx-archipelago.conf b/image-recipe/configs/nginx-archipelago.conf index 8a3c17b6..5d436450 100644 --- a/image-recipe/configs/nginx-archipelago.conf +++ b/image-recipe/configs/nginx-archipelago.conf @@ -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; diff --git a/image-recipe/configs/snippets/archipelago-https-app-proxies.conf b/image-recipe/configs/snippets/archipelago-https-app-proxies.conf index bd947db2..08198625 100644 --- a/image-recipe/configs/snippets/archipelago-https-app-proxies.conf +++ b/image-recipe/configs/snippets/archipelago-https-app-proxies.conf @@ -102,7 +102,7 @@ location /app/endurain/ { sub_filter '' ''; } 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; diff --git a/image-recipe/create-fat32-usb.sh b/image-recipe/create-fat32-usb.sh index 8c90fb5a..d1c3ed49 100755 --- a/image-recipe/create-fat32-usb.sh +++ b/image-recipe/create-fat32-usb.sh @@ -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 diff --git a/image-recipe/write-usb-dd.sh b/image-recipe/write-usb-dd.sh index 675b704c..21f1a4ac 100755 --- a/image-recipe/write-usb-dd.sh +++ b/image-recipe/write-usb-dd.sh @@ -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 diff --git a/neode-ui/.gitignore b/neode-ui/.gitignore index 9f90fd97..42d307b4 100644 --- a/neode-ui/.gitignore +++ b/neode-ui/.gitignore @@ -17,6 +17,7 @@ dist-ssr !.vscode/extensions.json .idea .DS_Store +._* *.suo *.ntvs* *.njsproj diff --git a/neode-ui/e2e/app-launch.spec.ts b/neode-ui/e2e/app-launch.spec.ts new file mode 100644 index 00000000..c8b87e5c --- /dev/null +++ b/neode-ui/e2e/app-launch.spec.ts @@ -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()) + } +} diff --git a/neode-ui/package-lock.json b/neode-ui/package-lock.json index d83f4cc1..be273b8f 100644 --- a/neode-ui/package-lock.json +++ b/neode-ui/package-lock.json @@ -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" - } - }, - "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" + "ejs": "^3.1.10", + "json5": "^2.2.3", + "magic-string": "^0.30.21", + "string.prototype.matchall": "^4.0.12" + }, + "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": { diff --git a/neode-ui/package.json b/neode-ui/package.json index 8d8dfc47..8f4addba 100644 --- a/neode-ui/package.json +++ b/neode-ui/package.json @@ -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", diff --git a/neode-ui/playwright.config.ts b/neode-ui/playwright.config.ts index 58edc2d5..10bd34bf 100644 --- a/neode-ui/playwright.config.ts +++ b/neode-ui/playwright.config.ts @@ -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', diff --git a/neode-ui/public/assets/img/app-icons/electrumx.png b/neode-ui/public/assets/img/app-icons/electrumx.png new file mode 100644 index 00000000..e3758ab5 Binary files /dev/null and b/neode-ui/public/assets/img/app-icons/electrumx.png differ diff --git a/neode-ui/public/assets/img/app-icons/electrumx.webp b/neode-ui/public/assets/img/app-icons/electrumx.webp index 4d05b2d2..e3758ab5 100644 Binary files a/neode-ui/public/assets/img/app-icons/electrumx.webp and b/neode-ui/public/assets/img/app-icons/electrumx.webp differ diff --git a/neode-ui/public/assets/img/app-icons/grafana.png b/neode-ui/public/assets/img/app-icons/grafana.png index af2a3171..c4b88bf3 100644 Binary files a/neode-ui/public/assets/img/app-icons/grafana.png and b/neode-ui/public/assets/img/app-icons/grafana.png differ diff --git a/neode-ui/public/assets/img/app-icons/immich.png b/neode-ui/public/assets/img/app-icons/immich.png index cbdc20bd..ee7ab24b 100644 Binary files a/neode-ui/public/assets/img/app-icons/immich.png and b/neode-ui/public/assets/img/app-icons/immich.png differ diff --git a/neode-ui/public/assets/img/grafana.png b/neode-ui/public/assets/img/grafana.png index fdb3ad19..c4b88bf3 100644 Binary files a/neode-ui/public/assets/img/grafana.png and b/neode-ui/public/assets/img/grafana.png differ diff --git a/neode-ui/public/catalog.json b/neode-ui/public/catalog.json index 3db1472e..dd547565 100644 --- a/neode-ui/public/catalog.json +++ b/neode-ui/public/catalog.json @@ -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", diff --git a/neode-ui/src/stores/__tests__/appLauncher.test.ts b/neode-ui/src/stores/__tests__/appLauncher.test.ts index 92637007..9afd9cf8 100644 --- a/neode-ui/src/stores/__tests__/appLauncher.test.ts +++ b/neode-ui/src/stores/__tests__/appLauncher.test.ts @@ -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() diff --git a/neode-ui/src/stores/appLauncher.ts b/neode-ui/src/stores/appLauncher.ts index a6b01c86..2fa7dd9d 100644 --- a/neode-ui/src/stores/appLauncher.ts +++ b/neode-ui/src/stores/appLauncher.ts @@ -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 = { '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 diff --git a/neode-ui/src/stores/container.ts b/neode-ui/src/stores/container.ts index 579d18fe..5408f430 100644 --- a/neode-ui/src/stores/container.ts +++ b/neode-ui/src/stores/container.ts @@ -23,7 +23,7 @@ const CONTAINER_NAME_MAP: Record = { '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', }, diff --git a/neode-ui/src/style.css b/neode-ui/src/style.css index 2e4cd0e5..8b2fa35c 100644 --- a/neode-ui/src/style.css +++ b/neode-ui/src/style.css @@ -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); } - diff --git a/neode-ui/src/views/AppSession.vue b/neode-ui/src/views/AppSession.vue index 29f73f54..78baf890 100644 --- a/neode-ui/src/views/AppSession.vue +++ b/neode-ui/src/views/AppSession.vue @@ -58,6 +58,11 @@ +