Compare commits
@@ -77,3 +77,10 @@ web/
|
||||
|
||||
# Resilience harness reports (generated, contains session cookies)
|
||||
scripts/resilience/reports/
|
||||
|
||||
# Codex / pnpm / python caches / editor backups
|
||||
.codex
|
||||
.pnpm-store/
|
||||
**/__pycache__/
|
||||
*.bak
|
||||
.claude/scheduled_tasks.lock
|
||||
|
||||
@@ -1,5 +1,44 @@
|
||||
# Changelog
|
||||
|
||||
## v1.7.53-alpha (2026-05-05)
|
||||
|
||||
- Bitcoin Knots/Core config generation no longer duplicates RPC bind and port settings between `bitcoin.conf` and container command args, fixing `Unable to bind all endpoints for RPC server` startup failures.
|
||||
- Legacy Bitcoin container healthchecks no longer depend on `bitcoin-cli`, which is absent from current Knots images and can wedge Podman healthcheck runners.
|
||||
- Update checks now prefer manifest OTA releases over stale git remotes unless `ARCHIPELAGO_GIT_UPDATES` is explicitly enabled, so installed nodes can see published releases from the VPS mirror.
|
||||
|
||||
## 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.
|
||||
- ElectrumX UI now exposes indexed height, local Bitcoin height, known headers, status, and progress source so indexing/waiting states are readable during long initial sync.
|
||||
- Added container doctor timer and smoke/lifecycle test coverage for Bitcoin Knots/Core, ElectrumX, Mempool, BTCPay/NBXplorer, and UI surface availability.
|
||||
- Bitcoin Core and Bitcoin Knots are mutually exclusive variants, with a real Bitcoin Core manifest and corrected install conflict handling.
|
||||
- IndeeHub now launches only on direct web UI port `7778`; the broken `/app/indeedhub/` path proxy was removed, and port `7777` remains the Nostr relay.
|
||||
- BTCPay/NBXplorer Postgres environment formatting fixed so installs do not carry malformed connection strings.
|
||||
|
||||
## v1.7.48-alpha (2026-04-29)
|
||||
|
||||
- archipelago.service no longer fails to start with "Failed to set up mount namespacing: /run/containers: No such file or directory" on nodes where /run/containers wasn't pre-created. ExecStartPre now creates it. Existing nodes need a one-time `systemctl edit archipelago` to add the mkdir; ISO installs from this version forward have the fix baked in.
|
||||
|
||||
## v1.7.47-alpha (2026-04-29)
|
||||
|
||||
- Bitcoin Knots/Core sync is now significantly faster. The container now uses every available core for script verification (was capped at 2) and has 8GB of memory instead of 4GB so its 4GB UTXO cache has headroom for the mempool and peer connections. Existing nodes pick up the new limits on next install/update; freshly-installed nodes start at full speed.
|
||||
- ElectrumX initial indexing is faster too. Its container memory bumped from 1GB to 2GB and its internal cache is now 2GB (default was 1.2GB).
|
||||
|
||||
## v1.7.46-alpha (2026-04-29)
|
||||
|
||||
- Health monitor no longer pages "Auto-restart failed" for orphaned containers. After a variant switch (bitcoin-core ↔ bitcoin-knots) the previous variant's container could survive uninstall and the health monitor would try restarting it forever. Now skipped silently with a debug log.
|
||||
- Apps no longer disappear from My Apps when an install fails. The card stays visible with state=Stopped so the user can retry or uninstall, with the failure reason surfaced via the new install_progress.message field.
|
||||
- "Downloading…" progress now actually advances during multi-image stack pulls. Was sticking at 20% until all pulls finished; now interpolates 20%→70% based on which image of N has landed.
|
||||
- Pulled four docker.io images (bitcoin, gitea, nextcloud, valkey) into the lfg2025 registries on OVH and tx1138. Removes a docker.io dependency from first-boot installs.
|
||||
- Resilience harness improvements: install-fail entries no longer vanish, install/uninstall/probe cells are timing-tolerant (60s retry on ui_probe and auth_probe), dep snapshots no longer leak companion containers into the dependent app's "new containers" set.
|
||||
|
||||
## v1.7.45-alpha (2026-04-29)
|
||||
|
||||
- Bitcoin RPC auth is durable. The dashboard reliably connects across container restart, image update, and reboot. Was failing on registry-pulled images that shipped a stale baked-in password.
|
||||
|
||||
@@ -26,12 +26,12 @@
|
||||
"id": "bitcoin-core",
|
||||
"title": "Bitcoin Core",
|
||||
"version": "28.4",
|
||||
"description": "Reference implementation of the Bitcoin protocol. Run a full node validating and relaying blocks.",
|
||||
"description": "Reference Bitcoin node implementation. Alternative to Bitcoin Knots; uninstall Knots before switching.",
|
||||
"icon": "/assets/img/app-icons/bitcoin-core.svg",
|
||||
"author": "Bitcoin Core contributors",
|
||||
"category": "money",
|
||||
"tier": "optional",
|
||||
"dockerImage": "docker.io/bitcoin/bitcoin:28.4",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/bitcoin:28.4",
|
||||
"repoUrl": "https://github.com/bitcoin/bitcoin"
|
||||
},
|
||||
{
|
||||
@@ -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",
|
||||
@@ -125,7 +125,7 @@
|
||||
"icon": "/assets/img/app-icons/gitea.svg",
|
||||
"author": "Gitea",
|
||||
"category": "development",
|
||||
"dockerImage": "docker.io/gitea/gitea:1.23",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/gitea:1.23",
|
||||
"repoUrl": "https://gitea.com"
|
||||
},
|
||||
{
|
||||
@@ -263,7 +263,7 @@
|
||||
"icon": "/assets/img/app-icons/nextcloud.webp",
|
||||
"author": "Nextcloud",
|
||||
"category": "data",
|
||||
"dockerImage": "docker.io/nextcloud:28",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/nextcloud:28",
|
||||
"repoUrl": "https://github.com/nextcloud/server"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -47,7 +47,9 @@ app:
|
||||
- NBXPLORER_BIND=0.0.0.0:32838
|
||||
- NBXPLORER_BTCRPCURL=http://bitcoin-knots:8332
|
||||
- NBXPLORER_BTCRPCUSER=archipelago
|
||||
- NBXPLORER_POSTGRES=User ID=btcpay;Password=${BTCPAY_DB_PASS};Host=archy-btcpay-db;Port=5432;Database=nbxplorer;Include Error Detail=true
|
||||
- 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:
|
||||
type: http
|
||||
|
||||
@@ -1,22 +1,35 @@
|
||||
app:
|
||||
id: bitcoin-core
|
||||
name: Bitcoin Knots
|
||||
name: Bitcoin Core
|
||||
version: 28.4.0
|
||||
description: Full Bitcoin Knots node with dynamic prune/full-mode startup based on host disk.
|
||||
description: Reference Bitcoin Core node with dynamic prune/full-mode startup based on host disk.
|
||||
|
||||
container_name: bitcoin-knots
|
||||
container_name: bitcoin-core
|
||||
|
||||
container:
|
||||
image: 146.59.87.168:3000/lfg2025/bitcoin-knots:latest
|
||||
image: 146.59.87.168:3000/lfg2025/bitcoin:28.4
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
entrypoint: ["sh", "-lc"]
|
||||
custom_args:
|
||||
# Sync-speed flags: -par=0 uses every core (was capped at 2 by
|
||||
# --cpus=2, now removed for bitcoin/electrumx). -dbcache sized to
|
||||
# the IBD sweet spot - 4GB on full nodes, 1GB on pruned. Container
|
||||
# --memory=8g (config.rs::get_memory_limit) leaves headroom for
|
||||
# mempool + connections.
|
||||
- >-
|
||||
BITCOIND="$(command -v bitcoind || true)";
|
||||
if [ -z "$BITCOIND" ]; then
|
||||
BITCOIND="$(find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1)";
|
||||
fi;
|
||||
if [ -z "$BITCOIND" ]; then
|
||||
echo "bitcoind not found in image" >&2;
|
||||
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=512 -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 -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
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
app:
|
||||
id: bitcoin-knots
|
||||
name: Bitcoin Knots
|
||||
version: 28.1.0
|
||||
description: Full Bitcoin Knots node with dynamic prune/full-mode startup based on host disk.
|
||||
|
||||
container_name: bitcoin-knots
|
||||
|
||||
container:
|
||||
image: 146.59.87.168:3000/lfg2025/bitcoin-knots:latest
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
entrypoint: ["sh", "-lc"]
|
||||
custom_args:
|
||||
# Sync-speed flags: -par=0 uses every core (was capped at 2 by
|
||||
# --cpus=2, now removed for bitcoin/electrumx). -dbcache sized to
|
||||
# the IBD sweet spot - 4GB on full nodes, 1GB on pruned. Container
|
||||
# --memory=8g (config.rs::get_memory_limit) leaves headroom for
|
||||
# mempool + connections.
|
||||
- >-
|
||||
BITCOIND="$(command -v bitcoind || true)";
|
||||
if [ -z "$BITCOIND" ]; then
|
||||
BITCOIND="$(find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1)";
|
||||
fi;
|
||||
if [ -z "$BITCOIND" ]; then
|
||||
echo "bitcoind not found in image" >&2;
|
||||
exit 127;
|
||||
fi;
|
||||
if [ "${DISK_GB:-0}" -lt 1000 ]; then
|
||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 -rpcuser="${BITCOIN_RPC_USER}" -rpcpassword="${BITCOIN_RPC_PASS}";
|
||||
else
|
||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 -rpcuser="${BITCOIN_RPC_USER}" -rpcpassword="${BITCOIN_RPC_PASS}";
|
||||
fi
|
||||
derived_env:
|
||||
- key: DISK_GB
|
||||
template: "{{DISK_GB}}"
|
||||
secret_env:
|
||||
- key: BITCOIN_RPC_PASS
|
||||
secret_file: bitcoin-rpc-password
|
||||
data_uid: "100101:100101"
|
||||
|
||||
dependencies:
|
||||
- storage: 500Gi
|
||||
|
||||
resources:
|
||||
cpu_limit: 0
|
||||
memory_limit: 4Gi
|
||||
disk_limit: 500Gi
|
||||
|
||||
security:
|
||||
capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE]
|
||||
readonly_root: false
|
||||
network_policy: isolated
|
||||
|
||||
ports:
|
||||
- host: 8332
|
||||
container: 8332
|
||||
protocol: tcp
|
||||
- host: 8333
|
||||
container: 8333
|
||||
protocol: tcp
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/bitcoin
|
||||
target: /home/bitcoin/.bitcoin
|
||||
options: [rw]
|
||||
|
||||
environment:
|
||||
- BITCOIN_RPC_USER=archipelago
|
||||
|
||||
health_check:
|
||||
type: tcp
|
||||
endpoint: localhost:8332
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
bitcoin_integration:
|
||||
rpc_access: admin
|
||||
sync_required: true
|
||||
testnet_support: false
|
||||
pruning_support: true
|
||||
@@ -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,12 +49,11 @@ 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
|
||||
- BTCPAY_BTCRPCUSER=archipelago
|
||||
- BTCPAY_POSTGRES=User ID=btcpay;Password=${BTCPAY_DB_PASS};Host=archy-btcpay-db;Port=5432;Database=btcpay;Include Error Detail=true
|
||||
- BTCPAY_POSTGRES=Username=btcpay;Password=${BTCPAY_DB_PASS};Host=archy-btcpay-db;Port=5432;Database=btcpay
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
@@ -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: /
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -27,9 +28,9 @@ app:
|
||||
apparmor_profile: default
|
||||
|
||||
ports:
|
||||
- host: 7777
|
||||
container: 3000
|
||||
protocol: tcp # Web UI (Next.js)
|
||||
- host: 7778
|
||||
container: 7777
|
||||
protocol: tcp # Web UI. Port 7777 on the host is reserved for Nostr relay.
|
||||
|
||||
volumes:
|
||||
- type: tmpfs
|
||||
@@ -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
|
||||
@@ -57,7 +64,7 @@ app:
|
||||
name: Web UI
|
||||
description: Stream Bitcoin documentaries with Nostr identity
|
||||
type: ui
|
||||
port: 7777
|
||||
port: 7778
|
||||
protocol: http
|
||||
path: /
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.7.45-alpha"
|
||||
version = "1.7.53-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.7.45-alpha"
|
||||
version = "1.7.53-alpha"
|
||||
edition = "2021"
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
authors = ["Archipelago Team"]
|
||||
|
||||
@@ -429,6 +429,7 @@ impl ApiHandler {
|
||||
|
||||
// Electrs status — unauthenticated (read-only sync status)
|
||||
(Method::GET, "/electrs-status") => Self::handle_electrs_status().await,
|
||||
(Method::GET, "/bitcoin-status") => Self::handle_bitcoin_status().await,
|
||||
|
||||
// App-catalog proxy — fetches catalog.json from the configured
|
||||
// upstream URLs server-side so the browser doesn't hit CORS
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::build_response;
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use crate::bitcoin_status;
|
||||
use crate::electrs_status;
|
||||
use anyhow::Result;
|
||||
use hyper::{Response, StatusCode};
|
||||
@@ -76,11 +77,23 @@ impl ApiHandler {
|
||||
pub(super) async fn handle_electrs_status() -> Result<Response<hyper::Body>> {
|
||||
let status = electrs_status::get_electrs_sync_status().await;
|
||||
let body = serde_json::to_vec(&status).unwrap_or_default();
|
||||
Ok(build_response(
|
||||
StatusCode::OK,
|
||||
"application/json",
|
||||
hyper::Body::from(body),
|
||||
))
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Cache-Control", "no-store")
|
||||
.body(hyper::Body::from(body))
|
||||
.unwrap_or_else(|_| Response::new(hyper::Body::from("{}"))))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_bitcoin_status() -> Result<Response<hyper::Body>> {
|
||||
let status = bitcoin_status::get_bitcoin_status().await;
|
||||
let body = serde_json::to_vec(&status).unwrap_or_default();
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Cache-Control", "no-store")
|
||||
.body(hyper::Body::from(body))
|
||||
.unwrap_or_else(|_| Response::new(hyper::Body::from("{}"))))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_lnd_connect_info(
|
||||
|
||||
@@ -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<anyhow::Error> = None;
|
||||
for candidate in status_app_id_candidates(app_id) {
|
||||
match orchestrator.health(&candidate).await {
|
||||
Ok(health) => return Ok(serde_json::json!({ app_id: health })),
|
||||
Err(e) => last_err = Some(e),
|
||||
}
|
||||
}
|
||||
for name in status_container_name_candidates(app_id) {
|
||||
if let Some(health) = inspect_container_health_value(&name).await {
|
||||
return Ok(serde_json::json!({ app_id: health }));
|
||||
}
|
||||
}
|
||||
if let Some(e) = last_err {
|
||||
return Err(e.context("Failed to get container health"));
|
||||
}
|
||||
return Err(anyhow::anyhow!("Failed to get container health"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -449,6 +460,14 @@ fn status_app_id_candidates(app_id: &str) -> Vec<String> {
|
||||
push("mempool-electrs");
|
||||
push("electrumx");
|
||||
}
|
||||
"mempool" | "mempool-web" => {
|
||||
push("mempool");
|
||||
push("archy-mempool-web");
|
||||
}
|
||||
"immich" => {
|
||||
push("immich");
|
||||
push("immich_server");
|
||||
}
|
||||
_ => push(app_id),
|
||||
}
|
||||
|
||||
@@ -469,6 +488,8 @@ fn status_container_name_candidates(app_id: &str) -> Vec<String> {
|
||||
"lnd-ui" => push("archy-lnd-ui"),
|
||||
"electrs-ui" => push("archy-electrs-ui"),
|
||||
"electrs" | "mempool-electrs" => push("electrumx"),
|
||||
"mempool" | "mempool-web" | "archy-mempool-web" => push("mempool"),
|
||||
"immich" => push("immich_server"),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -511,3 +532,14 @@ async fn inspect_container_state_value(name: &str) -> Option<serde_json::Value>
|
||||
"running": running,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn inspect_container_health_value(name: &str) -> Option<String> {
|
||||
let v = inspect_container_state_value(name).await?;
|
||||
match v.get("state").and_then(|s| s.as_str()).unwrap_or("unknown") {
|
||||
"running" => Some("healthy".to_string()),
|
||||
"created" => Some("starting".to_string()),
|
||||
"paused" => Some("paused".to_string()),
|
||||
"exited" | "stopped" => Some("unhealthy".to_string()),
|
||||
other => Some(format!("unknown:{other}")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ impl RpcHandler {
|
||||
match method {
|
||||
"echo" => self.handle_echo(params).await,
|
||||
"server.echo" => self.handle_echo(params).await,
|
||||
"server.get-state" => self.handle_server_get_state().await,
|
||||
"health" => self.handle_health().await,
|
||||
"auth.login" => self.handle_auth_login(params).await,
|
||||
"auth.logout" => self.handle_auth_logout().await,
|
||||
@@ -530,6 +531,11 @@ impl RpcHandler {
|
||||
Ok(serde_json::json!({ "message": "Hello from Archipelago!" }))
|
||||
}
|
||||
|
||||
async fn handle_server_get_state(&self) -> Result<serde_json::Value> {
|
||||
let (data, rev) = self.state_manager.get_snapshot().await;
|
||||
Ok(serde_json::json!({ "data": data, "rev": rev }))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_health(&self) -> Result<serde_json::Value> {
|
||||
let recovery_complete = crate::crash_recovery::is_recovery_complete();
|
||||
let uptime = crate::crash_recovery::uptime_seconds();
|
||||
|
||||
@@ -98,21 +98,14 @@ impl RpcHandler {
|
||||
|
||||
// Anchor bootstrap window: poll the status every ~3s for up to
|
||||
// 20s. Bail as soon as the anchor is connected.
|
||||
let mut last_status: Option<fips::FipsStatus> = None;
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
|
||||
loop {
|
||||
let after = loop {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
|
||||
let s = fips::FipsStatus::query(&self.config.data_dir).await;
|
||||
if s.anchor_connected {
|
||||
last_status = Some(s);
|
||||
break;
|
||||
if s.anchor_connected || std::time::Instant::now() >= deadline {
|
||||
break s;
|
||||
}
|
||||
last_status = Some(s);
|
||||
if std::time::Instant::now() >= deadline {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let after = last_status.unwrap_or_else(|| before.clone());
|
||||
};
|
||||
|
||||
let recovered = after.anchor_connected && !before.anchor_connected;
|
||||
let likely_cause = if after.anchor_connected {
|
||||
|
||||
@@ -113,11 +113,26 @@ impl RpcHandler {
|
||||
Err(e) => {
|
||||
error!("package.install {} failed: {:#}", package_id_spawn, e);
|
||||
install_log(&format!("INSTALL FAIL: {} — {:#}", package_id_spawn, e)).await;
|
||||
// No pre-state to revert to — remove the entry entirely so
|
||||
// the UI shows the app as not installed. The next package
|
||||
// scan will re-create it only if podman actually has a
|
||||
// container for it (partial install recovery).
|
||||
remove_package_entry(&handler.state_manager, &package_id_spawn).await;
|
||||
// Don't remove the entry — that's what made the card
|
||||
// vanish from My Apps mid-install / between retry-loop
|
||||
// attempts (e.g. tailscale's entrypoint failure). Leave
|
||||
// the entry visible with state=Stopped + the install
|
||||
// error in install_progress.message so the user can see
|
||||
// what went wrong and decide whether to retry or
|
||||
// uninstall. clear_install_progress would erase the
|
||||
// message, so we set it explicitly here instead.
|
||||
let err_msg = format!("Install failed: {:#}", e);
|
||||
let (mut data, _) = handler.state_manager.get_snapshot().await;
|
||||
if let Some(entry) = data.package_data.get_mut(&package_id_spawn) {
|
||||
entry.state = PackageState::Stopped;
|
||||
entry.install_progress = Some(crate::data_model::InstallProgress {
|
||||
size: 0,
|
||||
downloaded: 0,
|
||||
phase: None,
|
||||
message: Some(err_msg),
|
||||
});
|
||||
handler.state_manager.update_data(data).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -395,16 +410,6 @@ async fn set_package_state_and_clear_uninstall_stage(
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a package entry from state. Used for install-failure cleanup
|
||||
/// (since there's no pre-state to revert to — the entry was created
|
||||
/// speculatively when we flipped to Installing).
|
||||
async fn remove_package_entry(state_manager: &StateManager, package_id: &str) {
|
||||
let (mut data, _) = state_manager.get_snapshot().await;
|
||||
if data.package_data.remove(package_id).is_some() {
|
||||
state_manager.update_data(data).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Kick the container scanner to run immediately and wait for it to finish
|
||||
/// (with a 2s timeout). Used by install/update success paths so the fresh
|
||||
/// manifest — with `interfaces.main.ui` populated from the now-running
|
||||
|
||||
@@ -70,19 +70,30 @@ pub(super) fn get_app_capabilities(app_id: &str) -> Vec<String> {
|
||||
"--cap-add=SETGID".to_string(),
|
||||
"--cap-add=NET_BIND_SERVICE".to_string(),
|
||||
],
|
||||
// Bitcoin and Lightning need file ownership ops + NET_BIND_SERVICE for port binding
|
||||
// LND additionally needs NET_RAW for TLS certificate generation (netlinkrib interface enumeration)
|
||||
"bitcoin" | "bitcoin-core" | "bitcoin-knots" | "lnd" | "fedimint" | "fedimint-gateway" => {
|
||||
vec![
|
||||
"--cap-add=CHOWN".to_string(),
|
||||
"--cap-add=FOWNER".to_string(),
|
||||
"--cap-add=SETUID".to_string(),
|
||||
"--cap-add=SETGID".to_string(),
|
||||
"--cap-add=DAC_OVERRIDE".to_string(),
|
||||
"--cap-add=NET_BIND_SERVICE".to_string(),
|
||||
"--cap-add=NET_RAW".to_string(),
|
||||
]
|
||||
}
|
||||
// Bitcoin needs only file-ownership ops + NET_BIND_SERVICE for the
|
||||
// RPC port. NO NET_RAW — bitcoind never opens raw sockets and
|
||||
// dropping it removes a class of intra-pod spoofing capability.
|
||||
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => vec![
|
||||
"--cap-add=CHOWN".to_string(),
|
||||
"--cap-add=FOWNER".to_string(),
|
||||
"--cap-add=SETUID".to_string(),
|
||||
"--cap-add=SETGID".to_string(),
|
||||
"--cap-add=DAC_OVERRIDE".to_string(),
|
||||
"--cap-add=NET_BIND_SERVICE".to_string(),
|
||||
],
|
||||
// LND additionally needs NET_RAW for TLS certificate generation
|
||||
// (netlink interface enumeration during `lnd --tlscertpath` first run).
|
||||
// Fedimint inherits the same set because the gateway also enumerates
|
||||
// network interfaces on startup.
|
||||
"lnd" | "fedimint" | "fedimint-gateway" => vec![
|
||||
"--cap-add=CHOWN".to_string(),
|
||||
"--cap-add=FOWNER".to_string(),
|
||||
"--cap-add=SETUID".to_string(),
|
||||
"--cap-add=SETGID".to_string(),
|
||||
"--cap-add=DAC_OVERRIDE".to_string(),
|
||||
"--cap-add=NET_BIND_SERVICE".to_string(),
|
||||
"--cap-add=NET_RAW".to_string(),
|
||||
],
|
||||
// Vaultwarden needs file ownership + NET_BIND_SERVICE (binds port 80 internally)
|
||||
"vaultwarden" => vec![
|
||||
"--cap-add=CHOWN".to_string(),
|
||||
@@ -153,11 +164,10 @@ pub(super) fn is_readonly_compatible(app_id: &str) -> bool {
|
||||
/// Get container health check arguments for podman run.
|
||||
/// Returns (health-cmd, interval, retries) args to append to run_args.
|
||||
pub(super) fn get_health_check_args(app_id: &str, _rpc_pass: &str) -> Vec<String> {
|
||||
// bitcoin-cli reads the .cookie file from -datadir automatically (no plaintext creds needed)
|
||||
let btc_health =
|
||||
"bitcoin-cli -datadir=/home/bitcoin/.bitcoin getblockchaininfo || exit 1".to_string();
|
||||
let (cmd, interval, retries) = match app_id {
|
||||
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => (btc_health.as_str(), "30s", "3"),
|
||||
// Bitcoin images do not consistently ship bitcoin-cli/curl/nc. Rely on
|
||||
// process state here; manifests still describe the desired TCP check.
|
||||
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => return vec![],
|
||||
"lnd" => ("lncli getinfo || exit 1", "30s", "3"),
|
||||
"btcpay-server" | "btcpayserver" => {
|
||||
("curl -sf http://localhost:49392/ || exit 1", "30s", "3")
|
||||
@@ -244,13 +254,19 @@ pub(super) fn get_health_check_args(app_id: &str, _rpc_pass: &str) -> Vec<String
|
||||
/// Get per-app memory limit.
|
||||
pub(super) fn get_memory_limit(app_id: &str) -> &'static str {
|
||||
match app_id {
|
||||
// Heavy apps
|
||||
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => "4g",
|
||||
// Heavy apps. Bitcoin: dbcache uses ~4GB; the daemon also needs
|
||||
// headroom for mempool + connection buffers + script-verifier
|
||||
// memory + I/O. 4g caused OOM-cascades during IBD. 8g is the
|
||||
// floor; ideally this would be host-RAM aware (next pass).
|
||||
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => "8g",
|
||||
// ElectrumX: bumped from 1g to 2g so its CACHE_MB has somewhere
|
||||
// to live during initial blockchain indexing. CACHE_MB=2048 in
|
||||
// env vars below requires this much.
|
||||
"electrumx" | "mempool-electrs" | "electrs" => "2g",
|
||||
"cryptpad" => "512m",
|
||||
"ollama" => "4g",
|
||||
// Medium apps
|
||||
"lnd" => "512m",
|
||||
"electrumx" | "mempool-electrs" | "electrs" => "1g",
|
||||
"nextcloud" => "1g",
|
||||
"immich_server" | "immich" => "1g",
|
||||
"btcpay-server" | "btcpayserver" => "1g",
|
||||
@@ -292,16 +308,23 @@ pub(super) fn all_container_names(package_id: &str) -> Vec<String> {
|
||||
let archy = format!("archy-{}", package_id);
|
||||
|
||||
match package_id {
|
||||
// Bitcoin: multiple historical names
|
||||
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => vec![
|
||||
// Bitcoin variants share the UI but not the backend process. Keep
|
||||
// backend names precise so stopping one implementation does not clear
|
||||
// stop markers or issue podman operations for the other.
|
||||
"bitcoin" | "bitcoin-knots" => vec![
|
||||
"bitcoin-knots".into(),
|
||||
"bitcoin".into(),
|
||||
"bitcoin-core".into(),
|
||||
"archy-bitcoin-knots".into(),
|
||||
"archy-bitcoin".into(),
|
||||
"bitcoin-ui".into(),
|
||||
"archy-bitcoin-ui".into(),
|
||||
],
|
||||
"bitcoin-core" => vec![
|
||||
"bitcoin-core".into(),
|
||||
"archy-bitcoin-core".into(),
|
||||
"bitcoin-ui".into(),
|
||||
"archy-bitcoin-ui".into(),
|
||||
],
|
||||
// LND + UI
|
||||
"lnd" => vec!["lnd".into(), "archy-lnd".into(), "archy-lnd-ui".into()],
|
||||
// Electrumx: multiple aliases
|
||||
@@ -360,6 +383,15 @@ pub(super) fn all_container_names(package_id: &str) -> Vec<String> {
|
||||
"penpot-exporter".into(),
|
||||
"penpot-frontend".into(),
|
||||
],
|
||||
"indeedhub" => vec![
|
||||
"indeedhub-postgres".into(),
|
||||
"indeedhub-redis".into(),
|
||||
"indeedhub-minio".into(),
|
||||
"indeedhub-relay".into(),
|
||||
"indeedhub-api".into(),
|
||||
"indeedhub-ffmpeg".into(),
|
||||
"indeedhub".into(),
|
||||
],
|
||||
"nostr-vpn" => vec![
|
||||
"nostr-vpn".into(),
|
||||
"archy-nostr-vpn".into(),
|
||||
@@ -394,11 +426,30 @@ pub(super) async fn get_containers_for_app(package_id: &str) -> Result<Vec<Strin
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::all_container_names;
|
||||
|
||||
#[test]
|
||||
fn bitcoin_variant_container_names_are_precise() {
|
||||
let core = all_container_names("bitcoin-core");
|
||||
assert!(core.contains(&"bitcoin-core".to_string()));
|
||||
assert!(!core.contains(&"bitcoin-knots".to_string()));
|
||||
|
||||
let knots = all_container_names("bitcoin-knots");
|
||||
assert!(knots.contains(&"bitcoin-knots".to_string()));
|
||||
assert!(!knots.contains(&"bitcoin-core".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Get data directories to clean for an app.
|
||||
/// Caller must validate package_id before calling.
|
||||
pub(super) fn get_data_dirs_for_app(package_id: &str) -> Vec<String> {
|
||||
let base = "/var/lib/archipelago";
|
||||
match package_id {
|
||||
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => {
|
||||
vec![format!("{}/bitcoin", base), format!("{}/bitcoin-ui", base)]
|
||||
}
|
||||
"mempool" | "mempool-web" => vec![
|
||||
format!("{}/mempool", base),
|
||||
format!("{}/mysql-mempool", base),
|
||||
@@ -497,6 +548,16 @@ pub(super) async fn get_app_config(
|
||||
// only what's in bitcoin.conf + argv. The shared bitcoin.conf
|
||||
// carries rpcauth; we inject the networking flags as CLI args so
|
||||
// RPC is reachable from the bitcoin-ui companion container.
|
||||
//
|
||||
// Sync-speed flags:
|
||||
// -dbcache=4096 — UTXO set cache; 4GB is the sweet spot before
|
||||
// diminishing returns. Container has --memory=8g now so
|
||||
// there's headroom for mempool + connections.
|
||||
// -par=0 — use all available cores for script
|
||||
// verification (defaults to NCPU-1 capped at 16). Was
|
||||
// effectively pinned at 2 by --cpus=2 (now removed).
|
||||
// -maxconnections=125 — default but explicit, so ops can
|
||||
// tune downward on bandwidth-constrained nodes.
|
||||
Some(vec![
|
||||
"-server=1".to_string(),
|
||||
"-rpcbind=0.0.0.0".to_string(),
|
||||
@@ -504,6 +565,9 @@ pub(super) async fn get_app_config(
|
||||
"-rpcport=8332".to_string(),
|
||||
"-printtoconsole=1".to_string(),
|
||||
"-datadir=/home/bitcoin/.bitcoin".to_string(),
|
||||
"-dbcache=4096".to_string(),
|
||||
"-par=0".to_string(),
|
||||
"-maxconnections=125".to_string(),
|
||||
]),
|
||||
),
|
||||
"bitcoin" | "bitcoin-knots" => (
|
||||
@@ -597,6 +661,13 @@ pub(super) async fn get_app_config(
|
||||
"COIN=Bitcoin".to_string(),
|
||||
"DB_DIRECTORY=/data".to_string(),
|
||||
"SERVICES=tcp://:50001,rpc://0.0.0.0:8000".to_string(),
|
||||
// Sync-speed: bigger LRU/write cache during initial
|
||||
// history index. Default is 1200MB, container now
|
||||
// gets 2g (config.rs::get_memory_limit) so 2048 fits.
|
||||
"CACHE_MB=2048".to_string(),
|
||||
// Block-fetcher concurrency — defaults are conservative
|
||||
// for shared hosts; 4 is plenty for one bitcoind backend.
|
||||
"MAX_SEND=10000000".to_string(),
|
||||
],
|
||||
None,
|
||||
None,
|
||||
@@ -762,7 +833,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()],
|
||||
@@ -777,7 +852,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" => (
|
||||
@@ -938,8 +1013,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![
|
||||
|
||||
@@ -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<String, PackageDataEntry>,
|
||||
) -> RunningDeps {
|
||||
let is_running = |names: &[&str]| {
|
||||
names.iter().any(|name| {
|
||||
packages
|
||||
.get(*name)
|
||||
.map(|pkg| pkg.state == PackageState::Running)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
};
|
||||
|
||||
RunningDeps {
|
||||
has_bitcoin: is_running(BITCOIN_NAMES),
|
||||
has_electrumx: is_running(ELECTRUM_NAMES),
|
||||
has_lnd: is_running(&["lnd"]),
|
||||
}
|
||||
}
|
||||
|
||||
/// Query podman for currently running containers and return dependency status.
|
||||
pub(super) async fn detect_running_deps() -> Result<RunningDeps> {
|
||||
let dep_check = tokio::process::Command::new("podman")
|
||||
.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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ impl RpcHandler {
|
||||
size,
|
||||
downloaded,
|
||||
phase: existing_phase,
|
||||
message: None,
|
||||
});
|
||||
self.state_manager.update_data(data).await;
|
||||
}
|
||||
@@ -55,6 +56,7 @@ impl RpcHandler {
|
||||
size,
|
||||
downloaded,
|
||||
phase: Some(phase),
|
||||
message: None,
|
||||
});
|
||||
self.state_manager.update_data(data).await;
|
||||
}
|
||||
@@ -97,6 +99,7 @@ impl RpcHandler {
|
||||
size: total,
|
||||
downloaded,
|
||||
phase: existing_phase,
|
||||
message: None,
|
||||
});
|
||||
state_manager.update_data(data).await;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -227,6 +251,20 @@ impl RpcHandler {
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
// Disable + remove Quadlet companion units BEFORE the rm loop.
|
||||
// Otherwise systemd's Restart=always will respawn each companion
|
||||
// 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);
|
||||
@@ -571,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])
|
||||
@@ -580,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));
|
||||
}
|
||||
@@ -625,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();
|
||||
@@ -644,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));
|
||||
}
|
||||
@@ -660,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()
|
||||
@@ -668,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,
|
||||
@@ -687,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("; ")));
|
||||
@@ -700,6 +832,239 @@ async fn do_package_restart(containers: &[String]) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_unknown_app_id_error(err: &anyhow::Error) -> bool {
|
||||
err.chain()
|
||||
.any(|cause| cause.to_string().contains("unknown app_id"))
|
||||
}
|
||||
|
||||
async fn repair_before_package_start(container_name: &str) {
|
||||
match container_name {
|
||||
"btcpay-server" | "archy-nbxplorer" => repair_btcpay_dirs().await,
|
||||
"indeedhub-postgres" | "indeedhub-redis" | "indeedhub-minio" | "indeedhub-relay"
|
||||
| "indeedhub-api" | "indeedhub-ffmpeg" | "indeedhub" => repair_indeedhub_network().await,
|
||||
"grafana" => cleanup_stale_pasta_port("3000").await,
|
||||
"gitea" => cleanup_gitea_stale_ports().await,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
async fn ensure_runtime_host_port_listener(container_name: &str) -> Result<()> {
|
||||
let Some(port) = runtime_required_host_port(container_name) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if wait_for_runtime_host_port(port, 10).await {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
install_log(&format!(
|
||||
"START REPAIR: {} — host port {} missing after start; restarting container",
|
||||
container_name, port
|
||||
))
|
||||
.await;
|
||||
let output = tokio::process::Command::new("podman")
|
||||
.args(["restart", container_name])
|
||||
.output()
|
||||
.await
|
||||
.context("failed to restart container after missing host port")?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow::anyhow!(
|
||||
"Container {} host port {} was not listening and restart failed: {}",
|
||||
container_name,
|
||||
port,
|
||||
stderr.trim()
|
||||
));
|
||||
}
|
||||
|
||||
if wait_for_runtime_host_port(port, 60).await {
|
||||
install_log(&format!(
|
||||
"START REPAIR OK: {} — host port {} is listening after restart",
|
||||
container_name, port
|
||||
))
|
||||
.await;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(anyhow::anyhow!(
|
||||
"Container {} is running but host port {} is not listening",
|
||||
container_name,
|
||||
port
|
||||
))
|
||||
}
|
||||
|
||||
fn runtime_required_host_port(container_name: &str) -> Option<u16> {
|
||||
match container_name {
|
||||
"grafana" => Some(3000),
|
||||
"searxng" => Some(8888),
|
||||
"uptime-kuma" => Some(3002),
|
||||
"gitea" => Some(3001),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_runtime_host_port(port: u16, timeout_secs: u64) -> bool {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
|
||||
loop {
|
||||
if tokio::net::TcpStream::connect(("127.0.0.1", port))
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if std::time::Instant::now() >= deadline {
|
||||
return false;
|
||||
}
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn repair_btcpay_dirs() {
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args([
|
||||
"mkdir",
|
||||
"-p",
|
||||
"/var/lib/archipelago/btcpay/Main",
|
||||
"/var/lib/archipelago/nbxplorer/Main",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
for dir in [
|
||||
"/var/lib/archipelago/btcpay",
|
||||
"/var/lib/archipelago/nbxplorer",
|
||||
] {
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["chown", "-R", "1000:1000", dir])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
repair_btcpay_database_password().await;
|
||||
}
|
||||
|
||||
async fn repair_btcpay_database_password() {
|
||||
let Ok(db_pass) =
|
||||
tokio::fs::read_to_string("/var/lib/archipelago/secrets/btcpay-db-password").await
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let db_pass = db_pass.trim();
|
||||
if db_pass.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["start", "archy-btcpay-db"])
|
||||
.output()
|
||||
.await;
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
|
||||
let escaped = db_pass.replace('\'', "''");
|
||||
let sql = format!("ALTER USER btcpay WITH PASSWORD '{}';", escaped);
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"exec",
|
||||
"archy-btcpay-db",
|
||||
"psql",
|
||||
"-U",
|
||||
"btcpay",
|
||||
"-d",
|
||||
"btcpay",
|
||||
"-c",
|
||||
&sql,
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"exec",
|
||||
"archy-btcpay-db",
|
||||
"createdb",
|
||||
"-U",
|
||||
"btcpay",
|
||||
"nbxplorer",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn repair_indeedhub_network() {
|
||||
super::stacks::repair_indeedhub_network_aliases().await;
|
||||
}
|
||||
|
||||
async fn cleanup_start_conflict(container_name: &str, stderr: &str) {
|
||||
if !stderr.contains("address already in use") && !stderr.contains("pasta failed") {
|
||||
return;
|
||||
}
|
||||
|
||||
if container_name == "gitea" {
|
||||
cleanup_gitea_stale_ports().await;
|
||||
return;
|
||||
}
|
||||
|
||||
if container_name != "grafana" {
|
||||
return;
|
||||
}
|
||||
|
||||
cleanup_stale_pasta_port("3000").await;
|
||||
}
|
||||
|
||||
async fn cleanup_stale_pasta_port(port: &str) {
|
||||
let kill_listener = format!(
|
||||
"ss -ltnp 'sport = :{}' 2>/dev/null | sed -n 's/.*pid=\\([0-9]*\\).*/\\1/p' | xargs -r kill 2>/dev/null || true",
|
||||
port
|
||||
);
|
||||
let _ = tokio::process::Command::new("sh")
|
||||
.args(["-c", &kill_listener])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
let pattern = format!("pasta.*{}", port);
|
||||
let _ = tokio::process::Command::new("pkill")
|
||||
.args(["-f", &pattern])
|
||||
.output()
|
||||
.await;
|
||||
let pattern = format!("rootlessport.*{}", port);
|
||||
let _ = tokio::process::Command::new("pkill")
|
||||
.args(["-f", &pattern])
|
||||
.output()
|
||||
.await;
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
}
|
||||
|
||||
async fn cleanup_gitea_stale_ports() {
|
||||
for port in ["3001", "2222", "3000"] {
|
||||
let kill_listener = format!(
|
||||
"ss -ltnp 'sport = :{}' 2>/dev/null | sed -n 's/.*pid=\\([0-9]*\\).*/\\1/p' | xargs -r kill 2>/dev/null || true",
|
||||
port
|
||||
);
|
||||
let _ = tokio::process::Command::new("sh")
|
||||
.args(["-c", &kill_listener])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
let pattern = format!("pasta.*{}", port);
|
||||
let _ = tokio::process::Command::new("pkill")
|
||||
.args(["-f", &pattern])
|
||||
.output()
|
||||
.await;
|
||||
let pattern = format!("rootlessport.*{}", port);
|
||||
let _ = tokio::process::Command::new("pkill")
|
||||
.args(["-f", &pattern])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
}
|
||||
|
||||
pub(super) fn is_missing_companion_ok(name: &str, stderr: &str) -> bool {
|
||||
matches!(
|
||||
name,
|
||||
"archy-bitcoin-ui" | "archy-lnd-ui" | "archy-electrs-ui"
|
||||
) && stderr.contains("no container with name or ID")
|
||||
}
|
||||
|
||||
/// Flip the primary package entry's state and return the pre-transition
|
||||
/// state for revert on error. Mirrors `transitional::flip_to_transitional`
|
||||
/// but lives here because the package path keys by `package_id` (which may
|
||||
@@ -733,3 +1098,41 @@ async fn set_package_state(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn reconcile_companions_for(package_id: &str) {
|
||||
let app_ids = match package_id {
|
||||
"bitcoin" | "bitcoin-core" => vec!["bitcoin-core".to_string(), "bitcoin-ui".to_string()],
|
||||
"bitcoin-knots" => vec!["bitcoin-knots".to_string(), "bitcoin-ui".to_string()],
|
||||
"lnd" => vec!["lnd".to_string(), "lnd-ui".to_string()],
|
||||
"electrumx" | "electrs" | "mempool-electrs" => {
|
||||
vec!["electrumx".to_string(), "electrs-ui".to_string()]
|
||||
}
|
||||
_ => return,
|
||||
};
|
||||
for (companion, err) in crate::container::companion::reconcile(&app_ids).await {
|
||||
tracing::warn!(companion = %companion, error = %err, "companion reconcile failed");
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn orchestrator_uninstall_app_ids(package_id: &str) -> Vec<String> {
|
||||
match package_id {
|
||||
"bitcoin" | "bitcoin-core" => vec!["bitcoin-core".into(), "bitcoin-ui".into()],
|
||||
"bitcoin-knots" => vec!["bitcoin-knots".into(), "bitcoin-ui".into()],
|
||||
"lnd" => vec!["lnd".into(), "lnd-ui".into()],
|
||||
"electrumx" | "electrs" | "mempool-electrs" => {
|
||||
vec!["electrumx".into(), "electrs-ui".into()]
|
||||
}
|
||||
"mempool" | "mempool-web" => vec![
|
||||
"mempool-api".into(),
|
||||
"archy-mempool-web".into(),
|
||||
"archy-mempool-db".into(),
|
||||
],
|
||||
"btcpay-server" | "btcpayserver" | "btcpay" => vec![
|
||||
"btcpay-server".into(),
|
||||
"archy-nbxplorer".into(),
|
||||
"archy-btcpay-db".into(),
|
||||
],
|
||||
"fedimint" => vec!["fedimint".into(), "fedimint-gateway".into()],
|
||||
_ => vec![package_id.to_string()],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
@@ -48,6 +50,16 @@ async fn adopt_stack_if_exists(
|
||||
.await;
|
||||
}
|
||||
}
|
||||
let existing: Vec<&str> = all_containers
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|container| names.iter().any(|n| n == container))
|
||||
.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",
|
||||
@@ -61,6 +73,177 @@ 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,
|
||||
cmd: &mut tokio::process::Command,
|
||||
) -> Result<()> {
|
||||
let output = cmd
|
||||
.output()
|
||||
.await
|
||||
.with_context(|| format!("{}: failed to run {}", stack_name, label))?;
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let msg = format!("{} failed: {}{}", label, stdout, stderr);
|
||||
install_log(&format!("INSTALL FAIL: {} - {}", stack_name, msg.trim())).await;
|
||||
Err(anyhow::anyhow!("{} {}", stack_name, msg.trim()))
|
||||
}
|
||||
|
||||
async fn wait_for_stack_containers(
|
||||
stack_name: &str,
|
||||
containers: &[&str],
|
||||
timeout_secs: u64,
|
||||
) -> Result<()> {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
|
||||
loop {
|
||||
let mut pending = Vec::new();
|
||||
for container in containers {
|
||||
let status = tokio::process::Command::new("podman")
|
||||
.args(["inspect", container, "--format", "{{.State.Status}}"])
|
||||
.output()
|
||||
.await;
|
||||
match status {
|
||||
Ok(output) if output.status.success() => {
|
||||
let state = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
match state.as_str() {
|
||||
"running" => {}
|
||||
"exited" | "dead" => {
|
||||
let logs = stack_container_logs(container, 40).await;
|
||||
install_log(&format!(
|
||||
"INSTALL CRASH: {} - container {} exited. Logs:\n{}",
|
||||
stack_name,
|
||||
container,
|
||||
logs.chars().take(1000).collect::<String>()
|
||||
))
|
||||
.await;
|
||||
return Err(anyhow::anyhow!(
|
||||
"{} container {} exited after install. Logs: {}",
|
||||
stack_name,
|
||||
container,
|
||||
logs.chars().take(500).collect::<String>()
|
||||
));
|
||||
}
|
||||
other => pending.push(format!("{}={}", container, other)),
|
||||
}
|
||||
}
|
||||
Ok(output) => {
|
||||
pending.push(format!(
|
||||
"{}=missing({})",
|
||||
container,
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
));
|
||||
}
|
||||
Err(e) => pending.push(format!("{}=inspect-error({})", container, e)),
|
||||
}
|
||||
}
|
||||
|
||||
if pending.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
if std::time::Instant::now() >= deadline {
|
||||
install_log(&format!(
|
||||
"INSTALL TIMEOUT: {} - containers not running: {}",
|
||||
stack_name,
|
||||
pending.join(", ")
|
||||
))
|
||||
.await;
|
||||
return Err(anyhow::anyhow!(
|
||||
"{} containers did not reach running state: {}",
|
||||
stack_name,
|
||||
pending.join(", ")
|
||||
));
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn stack_container_logs(container: &str, lines: u32) -> String {
|
||||
tokio::process::Command::new("podman")
|
||||
.args(["logs", "--tail", &lines.to_string(), container])
|
||||
.output()
|
||||
.await
|
||||
.map(|o| {
|
||||
let stdout = String::from_utf8_lossy(&o.stdout);
|
||||
let stderr = String::from_utf8_lossy(&o.stderr);
|
||||
format!("{}{}", stdout, stderr)
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
async fn install_stack_via_orchestrator(
|
||||
handler: &RpcHandler,
|
||||
stack_name: &str,
|
||||
@@ -133,8 +316,13 @@ async fn pull_image_with_retry(image: &str) -> Result<()> {
|
||||
const BACKOFF_SECS: [u64; 3] = [5, 15, 45];
|
||||
|
||||
for attempt in 1..=MAX_ATTEMPTS {
|
||||
let output = tokio::process::Command::new("podman")
|
||||
.args(["pull", image])
|
||||
let mut cmd = tokio::process::Command::new("podman");
|
||||
cmd.arg("pull");
|
||||
if archipelago_container::image_uses_insecure_registry(image) {
|
||||
cmd.arg("--tls-verify=false");
|
||||
}
|
||||
let output = cmd
|
||||
.arg(image)
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to execute podman pull")?;
|
||||
@@ -201,17 +389,19 @@ impl RpcHandler {
|
||||
|
||||
let images = [
|
||||
"146.59.87.168:3000/lfg2025/immich-postgres:14-vectorchord0.4.3-pgvectors0.2.0",
|
||||
"docker.io/valkey/valkey:7-alpine",
|
||||
"146.59.87.168:3000/lfg2025/valkey:7-alpine",
|
||||
"146.59.87.168:3000/lfg2025/immich-server:release",
|
||||
];
|
||||
self.set_install_phase("immich", InstallPhase::PullingImage)
|
||||
.await;
|
||||
let n_images = images.len() as u64;
|
||||
for (i, img) in images.iter().enumerate() {
|
||||
self.set_install_progress("immich", i as u64, n_images).await;
|
||||
self.set_install_progress("immich", i as u64, n_images)
|
||||
.await;
|
||||
pull_image_with_retry(img).await?;
|
||||
}
|
||||
self.set_install_progress("immich", n_images, n_images).await;
|
||||
self.set_install_progress("immich", n_images, n_images)
|
||||
.await;
|
||||
self.set_install_phase("immich", InstallPhase::CreatingContainer)
|
||||
.await;
|
||||
|
||||
@@ -300,7 +490,7 @@ impl RpcHandler {
|
||||
"--health-cmd=valkey-cli ping || exit 1",
|
||||
"--health-interval=30s",
|
||||
"--health-retries=3",
|
||||
"docker.io/valkey/valkey:7-alpine",
|
||||
"146.59.87.168:3000/lfg2025/valkey:7-alpine",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
@@ -366,6 +556,12 @@ impl RpcHandler {
|
||||
|
||||
/// Install BTCPay stack (postgres + nbxplorer + btcpay-server).
|
||||
pub(super) async fn install_btcpay_stack(&self) -> Result<serde_json::Value> {
|
||||
if let Some(orchestrated) =
|
||||
install_stack_via_orchestrator(self, "btcpay-server", btcpay_stack_app_ids()).await?
|
||||
{
|
||||
return Ok(orchestrated);
|
||||
}
|
||||
|
||||
if let Some(adopted) = adopt_stack_if_exists(
|
||||
"btcpay-server",
|
||||
"btcpay",
|
||||
@@ -376,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)?;
|
||||
@@ -626,10 +816,12 @@ impl RpcHandler {
|
||||
.await;
|
||||
let n_images = images.len() as u64;
|
||||
for (i, img) in images.iter().enumerate() {
|
||||
self.set_install_progress("mempool", i as u64, n_images).await;
|
||||
self.set_install_progress("mempool", i as u64, n_images)
|
||||
.await;
|
||||
pull_image_with_retry(img).await?;
|
||||
}
|
||||
self.set_install_progress("mempool", n_images, n_images).await;
|
||||
self.set_install_progress("mempool", n_images, n_images)
|
||||
.await;
|
||||
self.set_install_phase("mempool", InstallPhase::CreatingContainer)
|
||||
.await;
|
||||
|
||||
@@ -901,7 +1093,8 @@ impl RpcHandler {
|
||||
let minio_pass = super::config::read_or_generate_secret("indeedhub-minio-password").await;
|
||||
|
||||
// 1. Postgres
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
let mut postgres_cmd = tokio::process::Command::new("podman");
|
||||
postgres_cmd
|
||||
.args([
|
||||
"run",
|
||||
"-d",
|
||||
@@ -923,12 +1116,12 @@ impl RpcHandler {
|
||||
"indeedhub-postgres-data:/var/lib/postgresql/data",
|
||||
&format!("{}/postgres:16.13-alpine", registry),
|
||||
])
|
||||
.env("TMPDIR", &user_tmp)
|
||||
.status()
|
||||
.await;
|
||||
.env("TMPDIR", &user_tmp);
|
||||
run_required_stack_command("indeedhub", "create postgres", &mut postgres_cmd).await?;
|
||||
|
||||
// 2. Redis
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
let mut redis_cmd = tokio::process::Command::new("podman");
|
||||
redis_cmd
|
||||
.args([
|
||||
"run",
|
||||
"-d",
|
||||
@@ -944,12 +1137,12 @@ impl RpcHandler {
|
||||
"indeedhub-redis-data:/data",
|
||||
&format!("{}/redis:7.4.8-alpine", registry),
|
||||
])
|
||||
.env("TMPDIR", &user_tmp)
|
||||
.status()
|
||||
.await;
|
||||
.env("TMPDIR", &user_tmp);
|
||||
run_required_stack_command("indeedhub", "create redis", &mut redis_cmd).await?;
|
||||
|
||||
// 3. MinIO
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
let mut minio_cmd = tokio::process::Command::new("podman");
|
||||
minio_cmd
|
||||
.args([
|
||||
"run",
|
||||
"-d",
|
||||
@@ -971,12 +1164,12 @@ impl RpcHandler {
|
||||
"server",
|
||||
"/data",
|
||||
])
|
||||
.env("TMPDIR", &user_tmp)
|
||||
.status()
|
||||
.await;
|
||||
.env("TMPDIR", &user_tmp);
|
||||
run_required_stack_command("indeedhub", "create minio", &mut minio_cmd).await?;
|
||||
|
||||
// 4. Nostr relay
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
let mut relay_cmd = tokio::process::Command::new("podman");
|
||||
relay_cmd
|
||||
.args([
|
||||
"run",
|
||||
"-d",
|
||||
@@ -992,12 +1185,12 @@ impl RpcHandler {
|
||||
"indeedhub-relay-data:/usr/src/app/db",
|
||||
&format!("{}/nostr-rs-relay:0.9.0", registry),
|
||||
])
|
||||
.env("TMPDIR", &user_tmp)
|
||||
.status()
|
||||
.await;
|
||||
.env("TMPDIR", &user_tmp);
|
||||
run_required_stack_command("indeedhub", "create relay", &mut relay_cmd).await?;
|
||||
|
||||
// 5. API
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
let mut api_cmd = tokio::process::Command::new("podman");
|
||||
api_cmd
|
||||
.args([
|
||||
"run",
|
||||
"-d",
|
||||
@@ -1049,12 +1242,12 @@ impl RpcHandler {
|
||||
"ENVIRONMENT=production",
|
||||
&format!("{}/indeedhub-api:1.0.0", registry),
|
||||
])
|
||||
.env("TMPDIR", &user_tmp)
|
||||
.status()
|
||||
.await;
|
||||
.env("TMPDIR", &user_tmp);
|
||||
run_required_stack_command("indeedhub", "create api", &mut api_cmd).await?;
|
||||
|
||||
// 6. FFmpeg worker
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
let mut ffmpeg_cmd = tokio::process::Command::new("podman");
|
||||
ffmpeg_cmd
|
||||
.args([
|
||||
"run",
|
||||
"-d",
|
||||
@@ -1096,15 +1289,15 @@ impl RpcHandler {
|
||||
"AES_MASTER_SECRET=0123456789abcdef0123456789abcdef",
|
||||
&format!("{}/indeedhub-ffmpeg:1.0.0", registry),
|
||||
])
|
||||
.env("TMPDIR", &user_tmp)
|
||||
.status()
|
||||
.await;
|
||||
.env("TMPDIR", &user_tmp);
|
||||
run_required_stack_command("indeedhub", "create ffmpeg worker", &mut ffmpeg_cmd).await?;
|
||||
|
||||
// Wait for backend services to start
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
|
||||
// 7. Frontend (nginx)
|
||||
let run = tokio::process::Command::new("podman")
|
||||
let mut frontend_cmd = tokio::process::Command::new("podman");
|
||||
frontend_cmd
|
||||
.args([
|
||||
"run",
|
||||
"-d",
|
||||
@@ -1114,19 +1307,31 @@ 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),
|
||||
])
|
||||
.env("TMPDIR", &user_tmp)
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to create indeedhub container")?;
|
||||
.env("TMPDIR", &user_tmp);
|
||||
run_required_stack_command("indeedhub", "create frontend", &mut frontend_cmd).await?;
|
||||
|
||||
if !run.status.success() {
|
||||
let err = String::from_utf8_lossy(&run.stderr);
|
||||
return Err(anyhow::anyhow!("IndeedHub frontend failed: {}", err));
|
||||
}
|
||||
wait_for_stack_containers(
|
||||
"indeedhub",
|
||||
&[
|
||||
"indeedhub-postgres",
|
||||
"indeedhub-redis",
|
||||
"indeedhub-minio",
|
||||
"indeedhub-relay",
|
||||
"indeedhub-api",
|
||||
"indeedhub-ffmpeg",
|
||||
"indeedhub",
|
||||
],
|
||||
60,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Phase: WaitingHealthy → PostInstall → clear. The actual readiness
|
||||
// gate is the package scanner's next sweep; this just gives the UI a
|
||||
@@ -1136,9 +1341,12 @@ impl RpcHandler {
|
||||
.await;
|
||||
self.set_install_phase("indeedhub", InstallPhase::PostInstall)
|
||||
.await;
|
||||
self.set_install_phase("indeedhub", InstallPhase::Done).await;
|
||||
self.set_install_phase("indeedhub", InstallPhase::Done)
|
||||
.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!({
|
||||
|
||||
@@ -322,8 +322,13 @@ impl RpcHandler {
|
||||
async fn pull_update_image(&self, package_id: &str, image: &str) -> Result<()> {
|
||||
self.set_install_progress(package_id, 0, 0).await;
|
||||
|
||||
let mut child = tokio::process::Command::new("podman")
|
||||
.args(["pull", image])
|
||||
let mut cmd = tokio::process::Command::new("podman");
|
||||
cmd.arg("pull");
|
||||
if archipelago_container::image_uses_insecure_registry(image) {
|
||||
cmd.arg("--tls-verify=false");
|
||||
}
|
||||
let mut child = cmd
|
||||
.arg(image)
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
|
||||
@@ -355,7 +355,7 @@ pub(in crate::api::rpc) fn known_service_port(name: &str) -> u16 {
|
||||
"penpot" => 9001,
|
||||
"nginx-proxy-manager" => 81,
|
||||
"vaultwarden" => 8343,
|
||||
"indeedhub" => 7777,
|
||||
"indeedhub" => 7778,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,26 +4,9 @@ use anyhow::{Context, Result};
|
||||
|
||||
impl RpcHandler {
|
||||
/// Check for available system updates.
|
||||
/// Tries git-based check first (if repo exists), falls back to manifest-based.
|
||||
/// Prefer manifest-based OTA so installed nodes with a checked-out repo do
|
||||
/// not depend on a potentially stale git remote. Git remains a dev fallback.
|
||||
pub(super) async fn handle_update_check(&self) -> Result<serde_json::Value> {
|
||||
// Manifest override: when ARCHIPELAGO_UPDATE_URL is explicitly set,
|
||||
// the operator wants OTA via manifest — typically a dev box where
|
||||
// ~/archy/.git exists but isn't the intended update surface.
|
||||
// Without this short-circuit the dev box always advertises "Pull
|
||||
// & Rebuild" and can never exercise the manifest OTA path.
|
||||
let manifest_override = std::env::var("ARCHIPELAGO_UPDATE_URL").is_ok();
|
||||
|
||||
let repo_dir = std::path::PathBuf::from(
|
||||
std::env::var("HOME").unwrap_or_else(|_| "/home/archipelago".to_string()),
|
||||
)
|
||||
.join("archy");
|
||||
if !manifest_override && repo_dir.join(".git").exists() {
|
||||
if let Ok(git_status) = self.git_check_update(&repo_dir).await {
|
||||
return Ok(git_status);
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to manifest-based check
|
||||
let state = update::check_for_updates(&self.config.data_dir).await?;
|
||||
|
||||
let update_info = state.available_update.as_ref().map(|u| {
|
||||
@@ -35,10 +18,30 @@ impl RpcHandler {
|
||||
})
|
||||
});
|
||||
|
||||
if update_info.is_some() {
|
||||
return Ok(serde_json::json!({
|
||||
"current_version": state.current_version,
|
||||
"last_check": state.last_check,
|
||||
"update_available": true,
|
||||
"update": update_info,
|
||||
"manifest_mirror": state.manifest_mirror,
|
||||
}));
|
||||
}
|
||||
|
||||
let repo_dir = std::path::PathBuf::from(
|
||||
std::env::var("HOME").unwrap_or_else(|_| "/home/archipelago".to_string()),
|
||||
)
|
||||
.join("archy");
|
||||
if std::env::var("ARCHIPELAGO_GIT_UPDATES").is_ok() && repo_dir.join(".git").exists() {
|
||||
if let Ok(git_status) = self.git_check_update(&repo_dir).await {
|
||||
return Ok(git_status);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"current_version": state.current_version,
|
||||
"last_check": state.last_check,
|
||||
"update_available": update_info.is_some(),
|
||||
"update_available": false,
|
||||
"update": update_info,
|
||||
"manifest_mirror": state.manifest_mirror,
|
||||
}))
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
//! Cached Bitcoin node status for browser UIs.
|
||||
//!
|
||||
//! The bitcoin-ui should not poll Bitcoin RPC directly for display state.
|
||||
//! During container restarts, reindexing, and IBD, direct browser RPC polling
|
||||
//! turns short RPC gaps into visible UI failures. This module owns the RPC
|
||||
//! polling loop, caches the last successful snapshot, and serves stale-but-known
|
||||
//! state while the node is reconnecting.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Serialize;
|
||||
use std::sync::OnceLock;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
const CACHE_REFRESH_SECS: u64 = 5;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct BitcoinNodeStatus {
|
||||
pub ok: bool,
|
||||
pub stale: bool,
|
||||
pub updated_at_ms: u64,
|
||||
pub error: Option<String>,
|
||||
pub blockchain_info: Option<serde_json::Value>,
|
||||
pub network_info: Option<serde_json::Value>,
|
||||
pub index_info: Option<serde_json::Value>,
|
||||
pub zmq_notifications: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl Default for BitcoinNodeStatus {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
ok: false,
|
||||
stale: false,
|
||||
updated_at_ms: 0,
|
||||
error: Some("Connecting to Bitcoin node...".to_string()),
|
||||
blockchain_info: None,
|
||||
network_info: None,
|
||||
index_info: None,
|
||||
zmq_notifications: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static STATUS_CACHE: OnceLock<RwLock<BitcoinNodeStatus>> = OnceLock::new();
|
||||
|
||||
fn cache() -> &'static RwLock<BitcoinNodeStatus> {
|
||||
STATUS_CACHE.get_or_init(|| RwLock::new(BitcoinNodeStatus::default()))
|
||||
}
|
||||
|
||||
fn now_ms() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as u64
|
||||
}
|
||||
|
||||
fn transient_error(err_msg: &str) -> bool {
|
||||
let lower = err_msg.to_lowercase();
|
||||
lower.contains("connect")
|
||||
|| lower.contains("reset")
|
||||
|| lower.contains("refused")
|
||||
|| lower.contains("timed out")
|
||||
|| lower.contains("timeout")
|
||||
|| lower.contains("broken pipe")
|
||||
|| lower.contains("eof")
|
||||
|| lower.contains("500 internal server error")
|
||||
}
|
||||
|
||||
pub fn spawn_status_cache() {
|
||||
tokio::spawn(async {
|
||||
loop {
|
||||
let fresh = fetch_bitcoin_status().await;
|
||||
let mut cached = cache().write().await;
|
||||
match fresh {
|
||||
Ok(mut status) => {
|
||||
status.ok = true;
|
||||
status.stale = false;
|
||||
status.error = None;
|
||||
*cached = status;
|
||||
}
|
||||
Err(e) => {
|
||||
let err_msg = e.to_string();
|
||||
if transient_error(&err_msg) {
|
||||
debug!("Bitcoin status: transient RPC failure: {}", err_msg);
|
||||
} else {
|
||||
warn!("Bitcoin status: RPC failure: {}", err_msg);
|
||||
}
|
||||
|
||||
if cached.blockchain_info.is_some() {
|
||||
cached.ok = false;
|
||||
cached.stale = true;
|
||||
cached.error = Some(format!(
|
||||
"Bitcoin node is reconnecting; showing last known state: {}",
|
||||
err_msg
|
||||
));
|
||||
} else {
|
||||
*cached = BitcoinNodeStatus {
|
||||
ok: false,
|
||||
stale: false,
|
||||
updated_at_ms: now_ms(),
|
||||
error: Some(format!("Connecting to Bitcoin node: {}", err_msg)),
|
||||
..BitcoinNodeStatus::default()
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(cached);
|
||||
tokio::time::sleep(Duration::from_secs(CACHE_REFRESH_SECS)).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub async fn get_bitcoin_status() -> BitcoinNodeStatus {
|
||||
cache().read().await.clone()
|
||||
}
|
||||
|
||||
async fn fetch_bitcoin_status() -> Result<BitcoinNodeStatus> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(8))
|
||||
.build()
|
||||
.context("build Bitcoin status HTTP client")?;
|
||||
|
||||
let blockchain_info = bitcoin_rpc_call(&client, "getblockchaininfo", serde_json::json!([]))
|
||||
.await
|
||||
.context("getblockchaininfo")?;
|
||||
let network_info = bitcoin_rpc_call(&client, "getnetworkinfo", serde_json::json!([]))
|
||||
.await
|
||||
.context("getnetworkinfo")
|
||||
.ok();
|
||||
let index_info = bitcoin_rpc_call(&client, "getindexinfo", serde_json::json!([]))
|
||||
.await
|
||||
.context("getindexinfo")
|
||||
.ok();
|
||||
let zmq_notifications = bitcoin_rpc_call(&client, "getzmqnotifications", serde_json::json!([]))
|
||||
.await
|
||||
.context("getzmqnotifications")
|
||||
.ok();
|
||||
|
||||
Ok(BitcoinNodeStatus {
|
||||
ok: true,
|
||||
stale: false,
|
||||
updated_at_ms: now_ms(),
|
||||
error: None,
|
||||
blockchain_info: Some(blockchain_info),
|
||||
network_info,
|
||||
index_info,
|
||||
zmq_notifications,
|
||||
})
|
||||
}
|
||||
|
||||
async fn bitcoin_rpc_call(
|
||||
client: &reqwest::Client,
|
||||
method: &str,
|
||||
params: serde_json::Value,
|
||||
) -> Result<serde_json::Value> {
|
||||
let (rpc_user, rpc_pass) = crate::bitcoin_rpc::bitcoin_rpc_credentials().await;
|
||||
let body = serde_json::json!({
|
||||
"jsonrpc": "1.0",
|
||||
"id": "bitcoin-status",
|
||||
"method": method,
|
||||
"params": params,
|
||||
});
|
||||
|
||||
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("Bitcoin RPC request failed")?;
|
||||
|
||||
let status = resp.status();
|
||||
let json: serde_json::Value = resp.json().await.context("decode Bitcoin RPC JSON")?;
|
||||
if !status.is_success() {
|
||||
anyhow::bail!("Bitcoin RPC returned {}: {}", status, json);
|
||||
}
|
||||
if let Some(error) = json.get("error").filter(|e| !e.is_null()) {
|
||||
anyhow::bail!("Bitcoin RPC {} error: {}", method, error);
|
||||
}
|
||||
json.get("result")
|
||||
.cloned()
|
||||
.context("missing Bitcoin RPC result")
|
||||
}
|
||||
@@ -15,7 +15,7 @@
|
||||
//! best-effort — failures are logged but never abort the backend.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::fs;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
@@ -31,6 +31,7 @@ const DOCTOR_SERVICE_PATH: &str = "/etc/systemd/system/archipelago-doctor.servic
|
||||
const DOCTOR_TIMER_PATH: &str = "/etc/systemd/system/archipelago-doctor.timer";
|
||||
|
||||
const NGINX_CONF_PATH: &str = "/etc/nginx/sites-available/archipelago";
|
||||
const RUNTIME_ASSETS_DIR: &str = "/opt/archipelago/web-ui/archipelago-runtime";
|
||||
|
||||
/// Inserted into every server block of the nginx config that lacks the
|
||||
/// `/api/app-catalog` proxy. Kept in sync with the canonical block in
|
||||
@@ -40,6 +41,16 @@ const NGINX_APP_CATALOG_BLOCK: &str = "\n # App Store catalog proxy — backe
|
||||
/// Entry point called from main startup. Never returns an error to the caller —
|
||||
/// failing to bootstrap host artifacts must not prevent the backend from serving.
|
||||
pub async fn ensure_doctor_installed() {
|
||||
match run_service_override_repair().await {
|
||||
Ok(true) => info!("Removed stale Archipelago dev-mode service override"),
|
||||
Ok(false) => debug!("No stale Archipelago dev-mode service override found"),
|
||||
Err(e) => warn!("Service override repair failed (non-fatal): {:#}", e),
|
||||
}
|
||||
match run_runtime_assets().await {
|
||||
Ok(changed) if changed => info!("Runtime assets synchronized from OTA payload"),
|
||||
Ok(_) => debug!("No OTA runtime payload to synchronize"),
|
||||
Err(e) => warn!("Runtime asset bootstrap failed (non-fatal): {:#}", e),
|
||||
}
|
||||
match run().await {
|
||||
Ok(changed) if changed => info!("Doctor artifacts synchronized with binary"),
|
||||
Ok(_) => debug!("Doctor artifacts already in sync"),
|
||||
@@ -50,6 +61,290 @@ pub async fn ensure_doctor_installed() {
|
||||
Ok(false) => debug!("Nginx already has /api/app-catalog block"),
|
||||
Err(e) => warn!("Nginx bootstrap failed (non-fatal): {:#}", e),
|
||||
}
|
||||
match run_bitcoin_rpc_repair().await {
|
||||
Ok(true) => info!("Repaired Bitcoin RPC bind settings and restarted Bitcoin containers"),
|
||||
Ok(false) => debug!("Bitcoin RPC bind settings already usable"),
|
||||
Err(e) => warn!("Bitcoin RPC repair failed (non-fatal): {:#}", e),
|
||||
}
|
||||
match tighten_secrets_dir().await {
|
||||
Ok(n) if n > 0 => info!(tightened = n, "Tightened mode on secret files"),
|
||||
Ok(_) => debug!("Secrets directory already at expected mode"),
|
||||
Err(e) => warn!("Secrets dir tightening failed (non-fatal): {:#}", e),
|
||||
}
|
||||
// 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::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
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum PodmanHealOutcome {
|
||||
Healthy,
|
||||
Unhealthy,
|
||||
}
|
||||
|
||||
async fn heal_podman_state() -> Result<PodmanHealOutcome> {
|
||||
if probe_podman_ok().await {
|
||||
return Ok(PodmanHealOutcome::Healthy);
|
||||
}
|
||||
Ok(PodmanHealOutcome::Unhealthy)
|
||||
}
|
||||
|
||||
/// True iff `podman info` returns 0 within 5s. Any timeout, spawn
|
||||
/// failure, or non-zero exit reads as "wedged" and triggers cleanup.
|
||||
async fn probe_podman_ok() -> bool {
|
||||
use std::time::Duration;
|
||||
let probe = tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
tokio::process::Command::new("podman")
|
||||
.arg("info")
|
||||
.arg("--format=json")
|
||||
.output(),
|
||||
)
|
||||
.await;
|
||||
match probe {
|
||||
Ok(Ok(out)) => out.status.success(),
|
||||
Ok(Err(_)) | Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Make sure /var/lib/archipelago/secrets/ stays 0700 owned by archipelago,
|
||||
/// and every file inside is 0600. The parent dir mode is the load-bearing
|
||||
/// boundary against host-side reads from other UIDs (rootless container
|
||||
/// escapes get mapped to UID >= 100000 and can't traverse a 0700/uid=1000
|
||||
/// directory). The per-file 0600 sweep is defense-in-depth in case some
|
||||
/// installer wrote a 0644 file.
|
||||
async fn tighten_secrets_dir() -> Result<u32> {
|
||||
let dir = Path::new("/var/lib/archipelago/secrets");
|
||||
if !dir.exists() {
|
||||
return Ok(0);
|
||||
}
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))
|
||||
.await
|
||||
.with_context(|| format!("chmod 0700 {}", dir.display()))?;
|
||||
|
||||
let mut entries = fs::read_dir(dir)
|
||||
.await
|
||||
.with_context(|| format!("read_dir {}", dir.display()))?;
|
||||
let mut tightened = 0u32;
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let path = entry.path();
|
||||
let meta = match entry.metadata().await {
|
||||
Ok(m) => m,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if !meta.is_file() {
|
||||
continue;
|
||||
}
|
||||
if meta.permissions().mode() & 0o777 != 0o600 {
|
||||
fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
|
||||
.await
|
||||
.with_context(|| format!("chmod 0600 {}", path.display()))?;
|
||||
tightened += 1;
|
||||
}
|
||||
}
|
||||
Ok(tightened)
|
||||
}
|
||||
|
||||
async fn run_service_override_repair() -> Result<bool> {
|
||||
let override_path = Path::new("/etc/systemd/system/archipelago.service.d/override.conf");
|
||||
let Ok(content) = fs::read_to_string(override_path).await else {
|
||||
return Ok(false);
|
||||
};
|
||||
if !content.contains("ARCHIPELAGO_DEV_MODE=true") {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let only_dev_mode_override = content
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty() && !line.starts_with('#'))
|
||||
.all(|line| line == "[Service]" || line == "Environment=ARCHIPELAGO_DEV_MODE=true");
|
||||
if !only_dev_mode_override {
|
||||
warn!(
|
||||
path = %override_path.display(),
|
||||
"Archipelago service override contains ARCHIPELAGO_DEV_MODE=true plus other settings; leaving it untouched"
|
||||
);
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let path_s = override_path.to_string_lossy().to_string();
|
||||
let status = host_sudo(&["rm", "-f", &path_s])
|
||||
.await
|
||||
.with_context(|| format!("remove {}", override_path.display()))?;
|
||||
if !status.success() {
|
||||
anyhow::bail!("remove {} exited with {}", override_path.display(), status);
|
||||
}
|
||||
let _ = host_sudo(&["systemctl", "daemon-reload"]).await;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn run_runtime_assets() -> Result<bool> {
|
||||
// The v1.7.50 OTA bridge puts scripts/apps/docker assets inside the
|
||||
// frontend tarball because older binaries only know how to apply the
|
||||
// backend binary and frontend archive. Once the new backend starts, it
|
||||
// promotes that payload into /opt so app installs use the matching specs.
|
||||
let runtime_dir = Path::new(RUNTIME_ASSETS_DIR);
|
||||
if !runtime_dir.exists() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let mut changed = false;
|
||||
for (relative, dest) in [
|
||||
("apps", "/opt/archipelago/apps"),
|
||||
("scripts", "/opt/archipelago/scripts"),
|
||||
("docker", "/opt/archipelago/docker"),
|
||||
] {
|
||||
let src = runtime_dir.join(relative);
|
||||
if src.exists() {
|
||||
replace_dir_from_runtime(&src, dest).await?;
|
||||
if relative == "scripts" {
|
||||
let _ = host_sudo(&[
|
||||
"find", dest, "-type", "f", "-name", "*.sh", "-exec", "chmod", "755", "{}", "+",
|
||||
])
|
||||
.await;
|
||||
let image_versions = format!("{}/image-versions.sh", dest);
|
||||
if Path::new(&image_versions).exists() {
|
||||
let _ =
|
||||
host_sudo(&["cp", &image_versions, "/opt/archipelago/image-versions.sh"])
|
||||
.await;
|
||||
}
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
let configs = runtime_dir.join("image-recipe/configs");
|
||||
for unit in ["archipelago-doctor.service", "archipelago-doctor.timer"] {
|
||||
let src = configs.join(unit);
|
||||
if src.exists() {
|
||||
let src_s = src.to_string_lossy().to_string();
|
||||
let dest = format!("/etc/systemd/system/{}", unit);
|
||||
let status = host_sudo(&["install", "-m", "644", &src_s, &dest])
|
||||
.await
|
||||
.with_context(|| format!("install {}", unit))?;
|
||||
if !status.success() {
|
||||
anyhow::bail!("install {} exited with {}", unit, status);
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if changed {
|
||||
let _ = host_sudo(&["systemctl", "daemon-reload"]).await;
|
||||
}
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
async fn replace_dir_from_runtime(src: &Path, dest: &str) -> Result<()> {
|
||||
let tmp = format!("{}.new.{}", dest, chrono::Utc::now().timestamp_millis());
|
||||
let src_dot = path_dot(src);
|
||||
let mkdir = host_sudo(&["mkdir", "-p", &tmp])
|
||||
.await
|
||||
.with_context(|| format!("mkdir {}", tmp))?;
|
||||
if !mkdir.success() {
|
||||
anyhow::bail!("mkdir {} exited with {}", tmp, mkdir);
|
||||
}
|
||||
let copy = host_sudo(&["cp", "-a", &src_dot, &tmp])
|
||||
.await
|
||||
.with_context(|| format!("copy runtime {} -> {}", src.display(), tmp))?;
|
||||
if !copy.success() {
|
||||
let _ = host_sudo(&["rm", "-rf", &tmp]).await;
|
||||
anyhow::bail!("copy runtime {} exited with {}", src.display(), copy);
|
||||
}
|
||||
let _ = host_sudo(&["mkdir", "-p", dest]).await;
|
||||
let cleanup = host_sudo(&[
|
||||
"find",
|
||||
dest,
|
||||
"-mindepth",
|
||||
"1",
|
||||
"-maxdepth",
|
||||
"1",
|
||||
"-exec",
|
||||
"rm",
|
||||
"-rf",
|
||||
"{}",
|
||||
"+",
|
||||
])
|
||||
.await
|
||||
.with_context(|| format!("clean {}", dest))?;
|
||||
if !cleanup.success() {
|
||||
let _ = host_sudo(&["rm", "-rf", &tmp]).await;
|
||||
anyhow::bail!("clean {} exited with {}", dest, cleanup);
|
||||
}
|
||||
let tmp_dot = format!("{}/.", tmp);
|
||||
let promote = host_sudo(&["cp", "-a", &tmp_dot, dest])
|
||||
.await
|
||||
.with_context(|| format!("promote {} -> {}", tmp, dest))?;
|
||||
let _ = host_sudo(&["rm", "-rf", &tmp]).await;
|
||||
if !promote.success() {
|
||||
anyhow::bail!("promote {} exited with {}", dest, promote);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn path_dot(path: &Path) -> String {
|
||||
let mut p = PathBuf::from(path);
|
||||
p.push(".");
|
||||
p.to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
async fn run_bitcoin_rpc_repair() -> Result<bool> {
|
||||
// Older installs can have a container-owned bitcoin.conf with only rpcauth
|
||||
// and printtoconsole. Repair it at startup so OTA fixes existing nodes
|
||||
// without a manual uninstall/reinstall. Bind/port stay in the container
|
||||
// command line to avoid duplicate RPC endpoint definitions.
|
||||
let script = r#"
|
||||
set -eu
|
||||
conf=/var/lib/archipelago/bitcoin/bitcoin.conf
|
||||
[ -f "$conf" ] || exit 0
|
||||
changed=0
|
||||
ensure_line() {
|
||||
line="$1"
|
||||
key="${line%%=*}"
|
||||
if ! grep -q "^${key}=" "$conf"; then
|
||||
printf '%s\n' "$line" >> "$conf"
|
||||
changed=1
|
||||
fi
|
||||
}
|
||||
ensure_line server=1
|
||||
ensure_line rpcallowip=0.0.0.0/0
|
||||
ensure_line listen=1
|
||||
[ "$changed" -eq 0 ] && exit 0
|
||||
exit 2
|
||||
"#;
|
||||
let status = host_sudo(&["sh", "-lc", script])
|
||||
.await
|
||||
.context("repair bitcoin.conf RPC bind settings")?;
|
||||
match status.code() {
|
||||
Some(0) => Ok(false),
|
||||
Some(2) => {
|
||||
for name in ["bitcoin-knots", "bitcoin-core", "archy-bitcoin-ui"] {
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["restart", name])
|
||||
.status()
|
||||
.await;
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
_ => {
|
||||
warn!("Bitcoin RPC repair helper exited with {}", status);
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run() -> Result<bool> {
|
||||
@@ -101,21 +396,14 @@ async fn run() -> Result<bool> {
|
||||
let timer_changed = write_root_if_needed(DOCTOR_TIMER_PATH, DOCTOR_TIMER).await?;
|
||||
changed = changed || service_changed || timer_changed;
|
||||
|
||||
// 3. Reload + enable. Only when we actually touched units, or when the
|
||||
// timer isn't enabled yet (catches fresh upgrades of boxes that predate
|
||||
// the doctor entirely).
|
||||
let timer_enabled = is_timer_enabled().await;
|
||||
if service_changed || timer_changed || !timer_enabled {
|
||||
// 3. Reload if units changed. Do not enable/start the timer here: lifecycle
|
||||
// qualification and explicit app operations need deterministic Podman
|
||||
// ownership, and the doctor can race those flows. Operators can enable it
|
||||
// separately when they want periodic host repair.
|
||||
if service_changed || timer_changed {
|
||||
if let Err(e) = host_sudo(&["systemctl", "daemon-reload"]).await {
|
||||
warn!("daemon-reload failed: {:#}", e);
|
||||
}
|
||||
if let Err(e) =
|
||||
host_sudo(&["systemctl", "enable", "--now", "archipelago-doctor.timer"]).await
|
||||
{
|
||||
warn!("enable archipelago-doctor.timer failed: {:#}", e);
|
||||
} else if !timer_enabled {
|
||||
info!("Enabled archipelago-doctor.timer");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(changed)
|
||||
@@ -156,15 +444,6 @@ async fn write_root_if_needed(path: &str, content: &str) -> Result<bool> {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn is_timer_enabled() -> bool {
|
||||
tokio::process::Command::new("systemctl")
|
||||
.args(["is-enabled", "--quiet", "archipelago-doctor.timer"])
|
||||
.status()
|
||||
.await
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Patch the nginx site config to add a `/api/app-catalog` proxy block if
|
||||
/// it's missing. The original ISO shipped individual per-endpoint `location`
|
||||
/// blocks and no catch-all `/api/`, so `/api/app-catalog` silently fell
|
||||
@@ -258,3 +537,14 @@ async fn run_nginx() -> Result<bool> {
|
||||
let _ = host_sudo(&["rm", "-f", &backup]).await;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn podman_heal_outcome_no_longer_has_cleanup_variant() {
|
||||
let outcome = PodmanHealOutcome::Unhealthy;
|
||||
assert_ne!(outcome, PodmanHealOutcome::Healthy);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,14 @@ pub struct Config {
|
||||
/// Tor SOCKS5 proxy (e.g. 127.0.0.1:9050). When set, ALL Nostr traffic routes through Tor.
|
||||
#[serde(default)]
|
||||
pub nostr_tor_proxy: Option<String>,
|
||||
/// Phase 3.2 of v1.7.52: route orchestrator-managed backend installs
|
||||
/// through Quadlet (`.container` units in ~/.config/containers/systemd
|
||||
/// + systemctl --user start) instead of `podman create + start`. Default
|
||||
/// off so the legacy path stays the production path until the harness
|
||||
/// at tests/lifecycle/run-20x.sh has gone green against the new path
|
||||
/// on .228 + .198. See `project_v1_7_52_phase3_quadlet_design`.
|
||||
#[serde(default)]
|
||||
pub use_quadlet_backends: bool,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -132,9 +140,12 @@ impl Config {
|
||||
config.log_level = level;
|
||||
}
|
||||
|
||||
// Dev mode configuration
|
||||
if let Ok(dev_mode) = std::env::var("ARCHIPELAGO_DEV_MODE") {
|
||||
config.dev_mode = dev_mode.parse().unwrap_or(false);
|
||||
// Production binaries must not be switched into dev orchestration by
|
||||
// host environment. Several live nodes carried a stale systemd
|
||||
// ARCHIPELAGO_DEV_MODE override, which rewrote production volume
|
||||
// mounts into /tmp and prevented real installs from starting.
|
||||
if std::env::var("ARCHIPELAGO_DEV_MODE").is_ok() {
|
||||
tracing::warn!("Ignoring ARCHIPELAGO_DEV_MODE in production config");
|
||||
}
|
||||
|
||||
if let Ok(runtime) = std::env::var("ARCHIPELAGO_CONTAINER_RUNTIME") {
|
||||
@@ -171,6 +182,17 @@ impl Config {
|
||||
config.nostr_tor_proxy = if s.is_empty() { None } else { Some(s) };
|
||||
}
|
||||
|
||||
// Phase 3.2 of v1.7.52. Truthy values (1, true, yes, on — case-insensitive)
|
||||
// route backend installs through the Quadlet path without requiring a
|
||||
// config.json edit + archipelago.service restart (which would trigger
|
||||
// FM3 cgroup cascade until 3.5 ships). Anything else (or unset) leaves
|
||||
// the config.json value untouched.
|
||||
if let Ok(v) = std::env::var("ARCHIPELAGO_USE_QUADLET_BACKENDS") {
|
||||
if parse_truthy_env(&v) {
|
||||
config.use_quadlet_backends = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Host IP for container env vars (detect if not set)
|
||||
if let Ok(ip) = std::env::var("ARCHIPELAGO_HOST_IP") {
|
||||
config.host_ip = ip;
|
||||
@@ -218,10 +240,23 @@ impl Default for Config {
|
||||
"wss://relay.nostr.info".into(),
|
||||
],
|
||||
nostr_tor_proxy: Some("127.0.0.1:9050".into()),
|
||||
use_quadlet_backends: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Recognise the canonical "the user meant true" forms for boolean env
|
||||
/// vars: 1, true, yes, on (case-insensitive, surrounding whitespace
|
||||
/// trimmed). Anything else — including the typo'd "ture" or the empty
|
||||
/// string — counts as false. Centralised so future env flags stay
|
||||
/// consistent with each other.
|
||||
fn parse_truthy_env(raw: &str) -> bool {
|
||||
matches!(
|
||||
raw.trim().to_ascii_lowercase().as_str(),
|
||||
"1" | "true" | "yes" | "on"
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -425,4 +460,24 @@ mod tests {
|
||||
assert!(!config.nostr_relays.is_empty());
|
||||
assert!(config.nostr_relays.iter().all(|r| r.starts_with("wss://")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_truthy_env_recognises_canonical_forms() {
|
||||
for t in ["1", "true", "TRUE", "yes", "Yes", "on", "ON", " true "] {
|
||||
assert!(parse_truthy_env(t), "{t:?} should parse truthy");
|
||||
}
|
||||
for f in ["", "0", "false", "no", "off", "ture", "anything else", " "] {
|
||||
assert!(!parse_truthy_env(f), "{f:?} should NOT parse truthy");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_use_quadlet_backends_defaults_off() {
|
||||
// Phase 3.2 of v1.7.52 — the new path stays gated until the 20×
|
||||
// harness goes green on .228 and .198. Flipping this default
|
||||
// ahead of that would route every backend install through code
|
||||
// we haven't fleet-validated yet.
|
||||
let config = Config::default();
|
||||
assert!(!config.use_quadlet_backends);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,5 +15,13 @@ server {
|
||||
add_header Access-Control-Allow-Headers "Content-Type, Authorization";
|
||||
if ($request_method = OPTIONS) { return 204; }
|
||||
}
|
||||
location /bitcoin-status {
|
||||
proxy_pass http://127.0.0.1:5678/bitcoin-status;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
add_header Cache-Control "no-store";
|
||||
}
|
||||
location / { try_files $uri $uri/ /index.html; }
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
//!
|
||||
@@ -29,6 +29,11 @@ pub struct BootReconciler {
|
||||
orchestrator: Arc<ProdContainerOrchestrator>,
|
||||
interval: Duration,
|
||||
shutdown: Arc<Notify>,
|
||||
/// Run the companion-unit repair stage each tick. Default true.
|
||||
/// Tests disable this — companion reconcile shells out to
|
||||
/// `systemctl --user` and `podman`, which both block real time
|
||||
/// and would race the paused-clock test fixtures.
|
||||
companion_stage: bool,
|
||||
}
|
||||
|
||||
impl BootReconciler {
|
||||
@@ -41,28 +46,46 @@ impl BootReconciler {
|
||||
orchestrator,
|
||||
interval,
|
||||
shutdown,
|
||||
companion_stage: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Disable the companion-unit reconcile stage. Used by unit tests
|
||||
/// that exercise loop cadence without the real systemd / podman
|
||||
/// surface. Production must not call this.
|
||||
#[cfg(test)]
|
||||
pub fn without_companion_stage(mut self) -> Self {
|
||||
self.companion_stage = false;
|
||||
self
|
||||
}
|
||||
|
||||
/// Run the reconcile loop until `shutdown` is notified.
|
||||
///
|
||||
/// Does one reconcile immediately, then sleeps `interval` between
|
||||
/// subsequent passes. A `shutdown.notify_one()` call unblocks the sleep
|
||||
/// and the task returns after the *next* pass completes.
|
||||
///
|
||||
/// Never panics: per-app failures are absorbed into `ReconcileReport` by
|
||||
/// the orchestrator, and `reconcile_all` itself returns infallibly.
|
||||
/// Each pass is two stages:
|
||||
/// 1. App reconcile: `reconcile_all()` keeps every loaded manifest's
|
||||
/// container running.
|
||||
/// 2. Companion reconcile: any expected Quadlet companion unit that
|
||||
/// is missing or inactive is repaired (writes the unit, daemon-
|
||||
/// reloads, starts the service). This is the safety net for the
|
||||
/// "someone deleted my unit file" / "systemd lost the service"
|
||||
/// failure modes.
|
||||
///
|
||||
/// Never panics: per-app failures are absorbed into `ReconcileReport`
|
||||
/// by the orchestrator, and companion failures are logged but never
|
||||
/// propagated.
|
||||
pub async fn run_forever(self) {
|
||||
// Initial pass: no delay.
|
||||
let report = self.orchestrator.reconcile_all().await;
|
||||
Self::log_report(&report);
|
||||
self.tick().await;
|
||||
|
||||
loop {
|
||||
let deadline = Instant::now() + self.interval;
|
||||
tokio::select! {
|
||||
_ = time::sleep_until(deadline) => {
|
||||
let report = self.orchestrator.reconcile_all().await;
|
||||
Self::log_report(&report);
|
||||
self.tick().await;
|
||||
}
|
||||
_ = self.shutdown.notified() => {
|
||||
tracing::info!("boot reconciler: shutdown requested, exiting loop");
|
||||
@@ -72,6 +95,23 @@ impl BootReconciler {
|
||||
}
|
||||
}
|
||||
|
||||
async fn tick(&self) {
|
||||
let report = self.orchestrator.reconcile_existing().await;
|
||||
Self::log_report(&report);
|
||||
|
||||
if !self.companion_stage {
|
||||
return;
|
||||
}
|
||||
let installed = self.orchestrator.manifest_ids().await;
|
||||
for (companion, err) in crate::container::companion::reconcile(&installed).await {
|
||||
tracing::warn!(
|
||||
companion = %companion,
|
||||
error = %err,
|
||||
"companion reconcile failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn log_report(report: &ReconcileReport) {
|
||||
for (app_id, action) in &report.actions {
|
||||
tracing::debug!(app_id = %app_id, action = ?action, "reconcile action");
|
||||
@@ -200,10 +240,11 @@ mod tests {
|
||||
async fn orch_with_one_running_manifest(
|
||||
rt: Arc<CountingRuntime>,
|
||||
) -> Arc<ProdContainerOrchestrator> {
|
||||
let orch = Arc::new(ProdContainerOrchestrator::with_runtime(
|
||||
rt,
|
||||
PathBuf::from("/nonexistent-for-tests"),
|
||||
));
|
||||
let mut orch =
|
||||
ProdContainerOrchestrator::with_runtime(rt, PathBuf::from("/nonexistent-for-tests"));
|
||||
let tmp = tempfile::tempdir().unwrap().keep();
|
||||
orch.set_data_dir(tmp);
|
||||
let orch = Arc::new(orch);
|
||||
orch.insert_manifest_for_test(
|
||||
pull_manifest("bitcoin-knots", "docker.io/bitcoin/knots:28"),
|
||||
PathBuf::from("/tmp/bk"),
|
||||
@@ -218,7 +259,8 @@ mod tests {
|
||||
let orch = orch_with_one_running_manifest(rt.clone()).await;
|
||||
let shutdown = Arc::new(Notify::new());
|
||||
let reconciler =
|
||||
BootReconciler::new(orch.clone(), Duration::from_secs(30), shutdown.clone());
|
||||
BootReconciler::new(orch.clone(), Duration::from_secs(30), shutdown.clone())
|
||||
.without_companion_stage();
|
||||
let handle = tokio::spawn(reconciler.run_forever());
|
||||
|
||||
// Yield so the spawned task gets CPU to run its initial reconcile.
|
||||
@@ -242,7 +284,8 @@ mod tests {
|
||||
let orch = orch_with_one_running_manifest(rt.clone()).await;
|
||||
let shutdown = Arc::new(Notify::new());
|
||||
let reconciler =
|
||||
BootReconciler::new(orch.clone(), Duration::from_secs(30), shutdown.clone());
|
||||
BootReconciler::new(orch.clone(), Duration::from_secs(30), shutdown.clone())
|
||||
.without_companion_stage();
|
||||
let handle = tokio::spawn(reconciler.run_forever());
|
||||
|
||||
tokio::task::yield_now().await;
|
||||
@@ -271,7 +314,8 @@ mod tests {
|
||||
let orch = orch_with_one_running_manifest(rt.clone()).await;
|
||||
let shutdown = Arc::new(Notify::new());
|
||||
let reconciler =
|
||||
BootReconciler::new(orch.clone(), Duration::from_secs(30), shutdown.clone());
|
||||
BootReconciler::new(orch.clone(), Duration::from_secs(30), shutdown.clone())
|
||||
.without_companion_stage();
|
||||
let handle = tokio::spawn(reconciler.run_forever());
|
||||
tokio::task::yield_now().await;
|
||||
tokio::task::yield_now().await;
|
||||
@@ -294,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"),
|
||||
@@ -305,7 +352,8 @@ mod tests {
|
||||
.await;
|
||||
let shutdown = Arc::new(Notify::new());
|
||||
let reconciler =
|
||||
BootReconciler::new(orch.clone(), Duration::from_secs(30), shutdown.clone());
|
||||
BootReconciler::new(orch.clone(), Duration::from_secs(30), shutdown.clone())
|
||||
.without_companion_stage();
|
||||
let handle = tokio::spawn(reconciler.run_forever());
|
||||
|
||||
tokio::task::yield_now().await;
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
//! Companion UI container lifecycle, entirely Quadlet-managed.
|
||||
//!
|
||||
//! A "companion" is a small nginx-based container that exposes a
|
||||
//! browser-friendly UI on top of a headless backend service:
|
||||
//!
|
||||
//! | Backend | Companion | Purpose |
|
||||
//! |------------------|--------------------|--------------------------|
|
||||
//! | bitcoin-knots | archy-bitcoin-ui | RPC viewer |
|
||||
//! | bitcoin-core | archy-bitcoin-ui | RPC viewer |
|
||||
//! | lnd | archy-lnd-ui | wallet/channel UI |
|
||||
//! | electrumx | archy-electrs-ui | indexer status UI |
|
||||
//!
|
||||
//! Lifecycle: `install` writes a Quadlet `.container` unit to
|
||||
//! `~/.config/containers/systemd/`, daemon-reloads, then starts the
|
||||
//! generated `.service`. systemd owns supervision from that point on
|
||||
//! — archipelago can crash, restart, or be uninstalled without
|
||||
//! touching the companion.
|
||||
//!
|
||||
//! This replaces the old `tokio::spawn { podman run }` block in
|
||||
//! `install.rs` (~165 lines of fire-and-forget shellouts) with a
|
||||
//! single declarative call.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use std::path::PathBuf;
|
||||
use tokio::fs;
|
||||
use tokio::process::Command;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::container::quadlet::{self, BindMount, NetworkMode, QuadletUnit};
|
||||
use archipelago_container::image_uses_insecure_registry;
|
||||
|
||||
const COMPANION_REGISTRY: &str = "146.59.87.168:3000/lfg2025";
|
||||
|
||||
/// Static description of one companion. The full list per backend
|
||||
/// app_id lives in `companions_for`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CompanionSpec {
|
||||
/// Container + unit name (e.g. "archy-bitcoin-ui").
|
||||
pub name: &'static str,
|
||||
/// Image base name in the lfg2025 registry namespace
|
||||
/// (e.g. "bitcoin-ui" → "146.59.87.168:3000/lfg2025/bitcoin-ui:latest").
|
||||
pub image_base: &'static str,
|
||||
/// Filesystem locations to look for a local Dockerfile (build wins
|
||||
/// over registry pull). Searched in order; first hit wins.
|
||||
pub build_dir_candidates: &'static [&'static str],
|
||||
/// Optional pre-start hook that renders config files referenced
|
||||
/// by `bind_mounts`. Returns Ok(()) on success; bind-mount must
|
||||
/// be present at start time or the companion will 502.
|
||||
pub pre_start: Option<PreStartHook>,
|
||||
/// 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<()>>;
|
||||
|
||||
/// Companions to install when `package_id` lands. Empty for apps
|
||||
/// without a companion UI.
|
||||
pub fn companions_for(package_id: &str) -> &'static [CompanionSpec] {
|
||||
match package_id {
|
||||
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => BITCOIN_UI,
|
||||
"lnd" => LND_UI,
|
||||
"electrumx" | "electrs" | "mempool-electrs" => ELECTRS_UI,
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
const BITCOIN_UI: &[CompanionSpec] = &[CompanionSpec {
|
||||
name: "archy-bitcoin-ui",
|
||||
image_base: "bitcoin-ui",
|
||||
build_dir_candidates: &[
|
||||
"/opt/archipelago/docker/bitcoin-ui",
|
||||
"/home/archipelago/archy/docker/bitcoin-ui",
|
||||
"/home/archipelago/Projects/archy/docker/bitcoin-ui",
|
||||
],
|
||||
pre_start: Some(render_bitcoin_ui),
|
||||
bind_mounts: &[(
|
||||
"/var/lib/archipelago/bitcoin-ui/nginx.conf",
|
||||
"/etc/nginx/conf.d/default.conf",
|
||||
)],
|
||||
ports: &[],
|
||||
host_network: true,
|
||||
}];
|
||||
|
||||
const LND_UI: &[CompanionSpec] = &[CompanionSpec {
|
||||
name: "archy-lnd-ui",
|
||||
image_base: "lnd-ui",
|
||||
build_dir_candidates: &[
|
||||
"/opt/archipelago/docker/lnd-ui",
|
||||
"/home/archipelago/archy/docker/lnd-ui",
|
||||
"/home/archipelago/Projects/archy/docker/lnd-ui",
|
||||
],
|
||||
pre_start: None,
|
||||
bind_mounts: &[],
|
||||
ports: &[(18083, 80)],
|
||||
host_network: false,
|
||||
}];
|
||||
|
||||
const ELECTRS_UI: &[CompanionSpec] = &[CompanionSpec {
|
||||
name: "archy-electrs-ui",
|
||||
image_base: "electrs-ui",
|
||||
build_dir_candidates: &[
|
||||
"/opt/archipelago/docker/electrs-ui",
|
||||
"/home/archipelago/archy/docker/electrs-ui",
|
||||
"/home/archipelago/Projects/archy/docker/electrs-ui",
|
||||
],
|
||||
pre_start: None,
|
||||
bind_mounts: &[],
|
||||
ports: &[],
|
||||
host_network: true,
|
||||
}];
|
||||
|
||||
fn render_bitcoin_ui() -> futures_util::future::BoxFuture<'static, Result<()>> {
|
||||
Box::pin(async {
|
||||
let paths = crate::container::bitcoin_ui::RenderPaths::default();
|
||||
crate::container::bitcoin_ui::render(&paths)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.context("render bitcoin-ui nginx.conf")
|
||||
})
|
||||
}
|
||||
|
||||
/// Provision and start every companion for `package_id`. Each
|
||||
/// companion is independent — a failure in one is logged but does
|
||||
/// not abort the others.
|
||||
pub async fn install_for(package_id: &str) -> Vec<(String, anyhow::Error)> {
|
||||
let mut failures = Vec::new();
|
||||
for spec in companions_for(package_id) {
|
||||
if let Err(e) = install_one(spec).await {
|
||||
warn!(companion = spec.name, error = %e, "companion install failed");
|
||||
failures.push((spec.name.to_string(), e));
|
||||
}
|
||||
}
|
||||
failures
|
||||
}
|
||||
|
||||
/// Stop and remove every companion for `package_id`. Best effort:
|
||||
/// errors are logged but do not abort the sequence.
|
||||
pub async fn remove_for(package_id: &str) {
|
||||
let dir = match quadlet::unit_dir().await {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
warn!("companion remove: cannot resolve quadlet dir: {e:#}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
for spec in companions_for(package_id) {
|
||||
if let Err(e) = quadlet::disable_remove(spec.name, &dir).await {
|
||||
warn!(companion = spec.name, error = %e, "companion remove failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Provision one companion: pre-start hook → image present → write
|
||||
/// quadlet → daemon-reload → start.
|
||||
pub async fn install_one(spec: &CompanionSpec) -> Result<()> {
|
||||
if let Some(hook) = spec.pre_start {
|
||||
hook().await.with_context(|| {
|
||||
format!(
|
||||
"pre-start hook failed for {} — companion will not start",
|
||||
spec.name
|
||||
)
|
||||
})?;
|
||||
}
|
||||
let image = ensure_image_present(spec).await?;
|
||||
let unit = build_unit(spec, &image);
|
||||
let dir = quadlet::unit_dir().await?;
|
||||
let changed = quadlet::write_if_changed(&unit, &dir).await?;
|
||||
if changed {
|
||||
info!(companion = spec.name, "wrote quadlet unit");
|
||||
quadlet::daemon_reload_user().await?;
|
||||
}
|
||||
// Start is idempotent — if already running, systemctl returns 0.
|
||||
quadlet::enable_now(&unit.service_name()).await?;
|
||||
info!(companion = spec.name, "companion started");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build companion image locally if a Dockerfile exists, otherwise
|
||||
/// pull from the lfg2025 registry. Returns the image ref the quadlet
|
||||
/// should reference (`localhost/<base>:latest` for build, registry
|
||||
/// URL for pull).
|
||||
async fn ensure_image_present(spec: &CompanionSpec) -> Result<String> {
|
||||
let local_image = format!("localhost/{}:latest", spec.image_base);
|
||||
let registry_image = format!("{}/{}:latest", COMPANION_REGISTRY, spec.image_base);
|
||||
|
||||
// Prefer local build — companions can carry build-time customizations
|
||||
// (e.g. nginx.conf templates baked in). Search known candidates.
|
||||
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", "-t", &local_image, dir])
|
||||
.output()
|
||||
.await
|
||||
.context("spawn podman build")?;
|
||||
if out.status.success() {
|
||||
return Ok(local_image);
|
||||
}
|
||||
warn!(
|
||||
companion = spec.name,
|
||||
"local build failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
);
|
||||
// Fall through to registry pull rather than fail outright.
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Registry pull. Use insecure flag only for whitelisted hosts.
|
||||
let mut cmd = Command::new("podman");
|
||||
cmd.arg("pull");
|
||||
if image_uses_insecure_registry(®istry_image) {
|
||||
cmd.arg("--tls-verify=false");
|
||||
}
|
||||
cmd.arg(®istry_image);
|
||||
let out = cmd.output().await.context("spawn podman pull")?;
|
||||
if !out.status.success() {
|
||||
anyhow::bail!(
|
||||
"no local Dockerfile and registry pull failed for {}: {}",
|
||||
spec.name,
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
);
|
||||
}
|
||||
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(),
|
||||
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.
|
||||
user: Some("0:0".into()),
|
||||
memory_mb: Some(128),
|
||||
cap_drop_all: true,
|
||||
cap_add: vec![
|
||||
"CHOWN".into(),
|
||||
"DAC_OVERRIDE".into(),
|
||||
"NET_BIND_SERVICE".into(),
|
||||
"SETUID".into(),
|
||||
"SETGID".into(),
|
||||
],
|
||||
bind_mounts: spec
|
||||
.bind_mounts
|
||||
.iter()
|
||||
.map(|(host, container)| BindMount {
|
||||
host: PathBuf::from(*host),
|
||||
container: PathBuf::from(*container),
|
||||
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;
|
||||
// the renderer skips empty/false directives so the rendered
|
||||
// bytes are unchanged from before quadlet.rs grew the new fields.
|
||||
..QuadletUnit::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Is a user systemd manager reachable? In production archipelago.service
|
||||
/// inherits XDG_RUNTIME_DIR from systemd; in unit tests / CI sandboxes it
|
||||
/// is unset, in which case `systemctl --user` would fail and write to
|
||||
/// HOME would be an unwanted side effect. The reconciler skips its
|
||||
/// companion stage when this is false.
|
||||
fn user_systemd_available() -> bool {
|
||||
std::env::var_os("XDG_RUNTIME_DIR")
|
||||
.map(|v| !v.is_empty())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Reconcile companion presence: every expected companion for the
|
||||
/// given installed apps must have its quadlet unit on disk and its
|
||||
/// service active. Returns a list of (companion, error) for anything
|
||||
/// that needed correction and failed.
|
||||
///
|
||||
/// Called from `boot_reconciler` so a deleted unit file or a stopped
|
||||
/// service is repaired within one tick. No-ops if the user systemd
|
||||
/// manager is not reachable (CI / test environments).
|
||||
pub async fn reconcile(installed_apps: &[String]) -> Vec<(String, anyhow::Error)> {
|
||||
if !user_systemd_available() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut failures = Vec::new();
|
||||
for app_id in installed_apps {
|
||||
for spec in companions_for(app_id) {
|
||||
match needs_repair(spec).await {
|
||||
Ok(false) => {}
|
||||
Ok(true) => {
|
||||
info!(
|
||||
companion = spec.name,
|
||||
"reconcile: companion not active, repairing"
|
||||
);
|
||||
if let Err(e) = install_one(spec).await {
|
||||
failures.push((spec.name.to_string(), e));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(companion = spec.name, error = %e, "reconcile probe failed");
|
||||
failures.push((spec.name.to_string(), e));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
failures
|
||||
}
|
||||
|
||||
/// Does this companion need install_one to be re-run? Returns true if
|
||||
/// the unit file is missing OR the service is not active.
|
||||
async fn needs_repair(spec: &CompanionSpec) -> Result<bool> {
|
||||
let dir = quadlet::unit_dir().await?;
|
||||
let unit_path = dir.join(format!("{}.container", spec.name));
|
||||
if !fs::try_exists(&unit_path).await.unwrap_or(false) {
|
||||
return Ok(true);
|
||||
}
|
||||
let svc = format!("{}.service", spec.name);
|
||||
Ok(!quadlet::is_active(&svc).await)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn companions_for_known_apps_returns_expected_set() {
|
||||
assert_eq!(companions_for("bitcoin-knots").len(), 1);
|
||||
assert_eq!(companions_for("bitcoin-core").len(), 1);
|
||||
assert_eq!(companions_for("bitcoin").len(), 1);
|
||||
assert_eq!(companions_for("lnd").len(), 1);
|
||||
assert_eq!(companions_for("electrumx").len(), 1);
|
||||
assert_eq!(companions_for("electrs").len(), 1);
|
||||
assert_eq!(companions_for("mempool-electrs").len(), 1);
|
||||
assert_eq!(companions_for("nextcloud").len(), 0);
|
||||
assert_eq!(companions_for("not-a-real-app").len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_unit_uses_host_network_and_drops_caps() {
|
||||
let spec = &BITCOIN_UI[0];
|
||||
let u = build_unit(spec, "localhost/bitcoin-ui:latest");
|
||||
assert_eq!(u.name, "archy-bitcoin-ui");
|
||||
assert!(matches!(u.network, NetworkMode::Host));
|
||||
assert!(u.cap_drop_all);
|
||||
assert!(u.cap_add.iter().any(|c| c == "NET_BIND_SERVICE"));
|
||||
assert_eq!(u.user.as_deref(), Some("0:0"));
|
||||
assert_eq!(u.memory_mb, Some(128));
|
||||
assert_eq!(u.bind_mounts.len(), 1);
|
||||
assert_eq!(
|
||||
u.bind_mounts[0].container,
|
||||
PathBuf::from("/etc/nginx/conf.d/default.conf")
|
||||
);
|
||||
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())]);
|
||||
}
|
||||
}
|
||||
@@ -63,10 +63,14 @@ impl DockerPackageScanner {
|
||||
"indeedhub-build_ffmpeg-worker_1",
|
||||
];
|
||||
|
||||
// First pass: collect UI containers
|
||||
// First pass: collect running UI containers. Custom UI-backed apps must
|
||||
// not advertise a launch URL unless their companion is actually alive.
|
||||
let mut ui_containers: HashMap<String, String> = HashMap::new();
|
||||
for container in &containers {
|
||||
if container.name.ends_with("-ui") {
|
||||
if !matches!(container.state, ContainerState::Running) {
|
||||
continue;
|
||||
}
|
||||
// Map fedimint-ui -> fedimint, lnd-ui -> lnd (normalize archy- prefix for lookup)
|
||||
let parent_app = container
|
||||
.name
|
||||
@@ -76,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<String> {
|
||||
None
|
||||
}
|
||||
|
||||
fn companion_lan_address(app_id: &str) -> Option<String> {
|
||||
match app_id {
|
||||
"bitcoin" | "bitcoin-knots" | "bitcoin-core" => Some("http://localhost:8334".to_string()),
|
||||
"electrumx" | "mempool-electrs" | "electrs" => Some("http://localhost:50002".to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_state(container_state: &ContainerState) -> (PackageState, ServiceStatus) {
|
||||
match container_state {
|
||||
ContainerState::Running => (PackageState::Running, ServiceStatus::Running),
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
//! lnd config bootstrap helper.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use base64::Engine;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use tokio::fs;
|
||||
|
||||
use crate::update::host_sudo;
|
||||
|
||||
pub const DEFAULT_DATA_DIR: &str = "/var/lib/archipelago/lnd";
|
||||
pub const DEFAULT_CONF_PATH: &str = "/var/lib/archipelago/lnd/lnd.conf";
|
||||
pub const WALLET_PASSWORD: &str = "hellohello";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EnsurePaths {
|
||||
pub data_dir: PathBuf,
|
||||
pub conf_path: PathBuf,
|
||||
}
|
||||
|
||||
impl Default for EnsurePaths {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
data_dir: PathBuf::from(DEFAULT_DATA_DIR),
|
||||
conf_path: PathBuf::from(DEFAULT_CONF_PATH),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum EnsureOutcome {
|
||||
Written,
|
||||
Unchanged,
|
||||
}
|
||||
|
||||
pub async fn ensure_config(paths: &EnsurePaths, rpc_pass: &str) -> Result<EnsureOutcome> {
|
||||
fs::create_dir_all(&paths.data_dir)
|
||||
.await
|
||||
.with_context(|| format!("creating {}", paths.data_dir.display()))?;
|
||||
|
||||
if paths.conf_path.exists() {
|
||||
let existing = fs::read_to_string(&paths.conf_path)
|
||||
.await
|
||||
.with_context(|| format!("reading {}", paths.conf_path.display()))?;
|
||||
if has_required_lnd_flags(&existing) {
|
||||
return Ok(EnsureOutcome::Unchanged);
|
||||
}
|
||||
}
|
||||
|
||||
let conf = format!(
|
||||
"debuglevel=info\n\
|
||||
maxpendingchannels=10\n\
|
||||
alias=Archipelago Node\n\
|
||||
color=#f7931a\n\
|
||||
listen=0.0.0.0:9735\n\
|
||||
rpclisten=0.0.0.0:10009\n\
|
||||
restlisten=0.0.0.0:8080\n\
|
||||
bitcoin.active=true\n\
|
||||
bitcoin.mainnet=true\n\
|
||||
bitcoin.node=bitcoind\n\
|
||||
bitcoind.rpchost=bitcoin-knots:8332\n\
|
||||
bitcoind.rpcuser=archipelago\n\
|
||||
bitcoind.rpcpass={}\n\
|
||||
bitcoind.rpcpolling=true\n\
|
||||
bitcoind.estimatemode=ECONOMICAL\n",
|
||||
rpc_pass
|
||||
);
|
||||
|
||||
write_config_atomically(paths, &conf).await?;
|
||||
|
||||
Ok(EnsureOutcome::Written)
|
||||
}
|
||||
|
||||
pub async fn ensure_wallet_initialized() -> Result<()> {
|
||||
let admin_macaroon = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
|
||||
let wallet_db = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/wallet.db";
|
||||
if file_exists_as_root(wallet_db).await {
|
||||
if file_exists_as_root(admin_macaroon).await && lnd_getinfo_ready(admin_macaroon).await {
|
||||
return Ok(());
|
||||
}
|
||||
unlock_existing_wallet().await?;
|
||||
wait_for_admin_macaroon(admin_macaroon).await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
init_wallet_via_rest().await?;
|
||||
wait_for_admin_macaroon(admin_macaroon).await
|
||||
}
|
||||
|
||||
async fn file_exists_as_root(path: &str) -> bool {
|
||||
if std::path::Path::new(path).exists() {
|
||||
return true;
|
||||
}
|
||||
tokio::process::Command::new("sudo")
|
||||
.args(["test", "-f", path])
|
||||
.status()
|
||||
.await
|
||||
.map(|status| status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
async fn read_file_as_root(path: &str) -> Result<Vec<u8>> {
|
||||
match fs::read(path).await {
|
||||
Ok(bytes) => Ok(bytes),
|
||||
Err(direct_err) => {
|
||||
let out = tokio::process::Command::new("sudo")
|
||||
.args(["cat", path])
|
||||
.output()
|
||||
.await
|
||||
.with_context(|| format!("reading {path} via sudo"))?;
|
||||
if out.status.success() {
|
||||
Ok(out.stdout)
|
||||
} else {
|
||||
anyhow::bail!(
|
||||
"reading {path} failed (direct: {direct_err}; sudo: {})",
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn unlock_existing_wallet() -> Result<()> {
|
||||
let mut last_err = None;
|
||||
for _ in 0..60 {
|
||||
let mut cmd = tokio::process::Command::new("podman");
|
||||
cmd.args(["exec", "-i", "lnd", "lncli", "unlock", "--stdin"]);
|
||||
cmd.stdin(std::process::Stdio::piped());
|
||||
cmd.stdout(std::process::Stdio::piped());
|
||||
cmd.stderr(std::process::Stdio::piped());
|
||||
|
||||
let mut child = cmd.spawn().context("spawning lncli wallet unlock")?;
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
stdin
|
||||
.write_all(format!("{}\n", WALLET_PASSWORD).as_bytes())
|
||||
.await
|
||||
.context("writing lncli password")?;
|
||||
}
|
||||
let out = child
|
||||
.wait_with_output()
|
||||
.await
|
||||
.context("waiting for lncli")?;
|
||||
if out.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let msg = format!("{stderr}{stdout}");
|
||||
if msg.contains("wallet already unlocked") || msg.contains("already unlocked") {
|
||||
return Ok(());
|
||||
}
|
||||
last_err = Some(msg);
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
}
|
||||
anyhow::bail!(
|
||||
"lncli wallet unlock failed: {}",
|
||||
last_err.unwrap_or_else(|| "unknown error".to_string())
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GenSeedResponse {
|
||||
cipher_seed_mnemonic: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum UnlockerResponse<T> {
|
||||
Value(T),
|
||||
WalletAlreadyExists,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct InitWalletRequest {
|
||||
wallet_password: String,
|
||||
cipher_seed_mnemonic: Vec<String>,
|
||||
}
|
||||
|
||||
async fn init_wallet_via_rest() -> Result<()> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(20))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.context("building LND REST client")?;
|
||||
|
||||
let seed: GenSeedResponse = match get_lnd_unlocker_json(&client, "/v1/genseed")
|
||||
.await
|
||||
.context("generating LND wallet seed")?
|
||||
{
|
||||
UnlockerResponse::Value(seed) => seed,
|
||||
UnlockerResponse::WalletAlreadyExists => {
|
||||
unlock_existing_wallet().await?;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
if seed.cipher_seed_mnemonic.is_empty() {
|
||||
anyhow::bail!("LND genseed returned no seed words");
|
||||
}
|
||||
|
||||
let wallet_password = base64::engine::general_purpose::STANDARD.encode(WALLET_PASSWORD);
|
||||
let req = InitWalletRequest {
|
||||
wallet_password,
|
||||
cipher_seed_mnemonic: seed.cipher_seed_mnemonic,
|
||||
};
|
||||
match post_lnd_unlocker_json::<serde_json::Value>(
|
||||
&client,
|
||||
"/v1/initwallet",
|
||||
serde_json::to_value(req)?,
|
||||
)
|
||||
.await
|
||||
.context("initializing LND wallet")?
|
||||
{
|
||||
UnlockerResponse::Value(_) => {}
|
||||
UnlockerResponse::WalletAlreadyExists => unlock_existing_wallet().await?,
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_lnd_unlocker_json<T: for<'de> Deserialize<'de>>(
|
||||
client: &reqwest::Client,
|
||||
path: &str,
|
||||
) -> Result<UnlockerResponse<T>> {
|
||||
let url = format!("https://127.0.0.1:8080{path}");
|
||||
let mut last_err = None;
|
||||
for _ in 0..60 {
|
||||
match client.get(&url).send().await {
|
||||
Ok(resp) => match decode_lnd_unlocker_response(resp, path).await {
|
||||
Ok(value) => return Ok(value),
|
||||
Err(e) => last_err = Some(e.to_string()),
|
||||
},
|
||||
Err(e) => last_err = Some(e.to_string()),
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
}
|
||||
anyhow::bail!(
|
||||
"LND REST {path} unavailable: {}",
|
||||
last_err.unwrap_or_else(|| "unknown error".to_string())
|
||||
)
|
||||
}
|
||||
|
||||
async fn post_lnd_unlocker_json<T: for<'de> Deserialize<'de>>(
|
||||
client: &reqwest::Client,
|
||||
path: &str,
|
||||
body: serde_json::Value,
|
||||
) -> Result<UnlockerResponse<T>> {
|
||||
let url = format!("https://127.0.0.1:8080{path}");
|
||||
let mut last_err = None;
|
||||
for _ in 0..60 {
|
||||
match client.post(&url).json(&body).send().await {
|
||||
Ok(resp) => match decode_lnd_unlocker_response(resp, path).await {
|
||||
Ok(value) => return Ok(value),
|
||||
Err(e) => last_err = Some(e.to_string()),
|
||||
},
|
||||
Err(e) => last_err = Some(e.to_string()),
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
}
|
||||
anyhow::bail!(
|
||||
"LND REST {path} unavailable: {}",
|
||||
last_err.unwrap_or_else(|| "unknown error".to_string())
|
||||
)
|
||||
}
|
||||
|
||||
async fn decode_lnd_unlocker_response<T: for<'de> Deserialize<'de>>(
|
||||
resp: reqwest::Response,
|
||||
path: &str,
|
||||
) -> Result<UnlockerResponse<T>> {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
if status.is_success() {
|
||||
let value = serde_json::from_str(&text)
|
||||
.with_context(|| format!("parsing LND REST response from {path}"))?;
|
||||
return Ok(UnlockerResponse::Value(value));
|
||||
}
|
||||
if text.contains("wallet already exists") {
|
||||
return Ok(UnlockerResponse::WalletAlreadyExists);
|
||||
}
|
||||
anyhow::bail!("LND REST {path} returned {status}: {text}")
|
||||
}
|
||||
|
||||
async fn lnd_getinfo_ready(admin_macaroon: &str) -> bool {
|
||||
let Ok(macaroon) = read_file_as_root(admin_macaroon).await else {
|
||||
return false;
|
||||
};
|
||||
let Ok(client) = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
client
|
||||
.get("https://127.0.0.1:8080/v1/getinfo")
|
||||
.header("Grpc-Metadata-macaroon", hex::encode(macaroon))
|
||||
.send()
|
||||
.await
|
||||
.map(|resp| resp.status().is_success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
async fn wait_for_admin_macaroon(admin_macaroon: &str) -> Result<()> {
|
||||
for _ in 0..60 {
|
||||
if file_exists_as_root(admin_macaroon).await {
|
||||
return Ok(());
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
}
|
||||
anyhow::bail!("LND admin macaroon not created after wallet init")
|
||||
}
|
||||
|
||||
async fn write_config_atomically(paths: &EnsurePaths, conf: &str) -> Result<()> {
|
||||
let tmp = paths.conf_path.with_extension("tmp");
|
||||
match fs::write(&tmp, conf).await {
|
||||
Ok(()) => {
|
||||
fs::rename(&tmp, &paths.conf_path).await.with_context(|| {
|
||||
format!(
|
||||
"renaming {} -> {}",
|
||||
tmp.display(),
|
||||
paths.conf_path.display()
|
||||
)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
|
||||
let script = format!(
|
||||
"set -eu\ncat > '{}' <<'LNDCONF'\n{}LNDCONF\n",
|
||||
shell_quote(&paths.conf_path.to_string_lossy()),
|
||||
conf
|
||||
);
|
||||
let status = host_sudo(&["sh", "-lc", &script])
|
||||
.await
|
||||
.context("writing lnd.conf via sudo")?;
|
||||
if !status.success() {
|
||||
anyhow::bail!("writing lnd.conf via sudo exited with {status}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e).with_context(|| format!("writing tmp {}", tmp.display())),
|
||||
}
|
||||
}
|
||||
|
||||
fn shell_quote(s: &str) -> String {
|
||||
s.replace('\'', "'\\''")
|
||||
}
|
||||
|
||||
fn has_required_lnd_flags(conf: &str) -> bool {
|
||||
[
|
||||
"bitcoin.active=true",
|
||||
"bitcoin.mainnet=true",
|
||||
"bitcoin.node=bitcoind",
|
||||
"bitcoind.rpchost=bitcoin-knots:8332",
|
||||
]
|
||||
.iter()
|
||||
.all(|needle| conf.lines().any(|line| line.trim() == *needle))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn ensure_config_writes_required_bitcoin_network_flags() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let paths = EnsurePaths {
|
||||
data_dir: tmp.path().join("lnd"),
|
||||
conf_path: tmp.path().join("lnd/lnd.conf"),
|
||||
};
|
||||
|
||||
let out = ensure_config(&paths, "secret").await.unwrap();
|
||||
assert_eq!(out, EnsureOutcome::Written);
|
||||
let conf = fs::read_to_string(&paths.conf_path).await.unwrap();
|
||||
assert!(conf.contains("bitcoin.active=true"));
|
||||
assert!(conf.contains("bitcoin.mainnet=true"));
|
||||
assert!(conf.contains("bitcoin.node=bitcoind"));
|
||||
assert!(conf.contains("bitcoind.rpchost=bitcoin-knots:8332"));
|
||||
assert!(conf.contains("bitcoind.rpcpass=secret"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ensure_config_is_idempotent() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let paths = EnsurePaths {
|
||||
data_dir: tmp.path().join("lnd"),
|
||||
conf_path: tmp.path().join("lnd/lnd.conf"),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
ensure_config(&paths, "first").await.unwrap(),
|
||||
EnsureOutcome::Written
|
||||
);
|
||||
assert_eq!(
|
||||
ensure_config(&paths, "second").await.unwrap(),
|
||||
EnsureOutcome::Unchanged
|
||||
);
|
||||
let conf = fs::read_to_string(&paths.conf_path).await.unwrap();
|
||||
assert!(conf.contains("bitcoind.rpcpass=first"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ensure_config_repairs_incomplete_existing_config() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let paths = EnsurePaths {
|
||||
data_dir: tmp.path().join("lnd"),
|
||||
conf_path: tmp.path().join("lnd/lnd.conf"),
|
||||
};
|
||||
fs::create_dir_all(&paths.data_dir).await.unwrap();
|
||||
fs::write(&paths.conf_path, "debuglevel=info\n")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
ensure_config(&paths, "repaired").await.unwrap(),
|
||||
EnsureOutcome::Written
|
||||
);
|
||||
let conf = fs::read_to_string(&paths.conf_path).await.unwrap();
|
||||
assert!(conf.contains("bitcoin.mainnet=true"));
|
||||
assert!(conf.contains("bitcoind.rpcpass=repaired"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wallet_password_is_valid_for_lncli() {
|
||||
assert!(WALLET_PASSWORD.len() > 8);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,19 @@
|
||||
pub mod bitcoin_ui;
|
||||
pub mod boot_reconciler;
|
||||
pub mod companion;
|
||||
pub mod data_manager;
|
||||
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;
|
||||
pub mod traits;
|
||||
|
||||
pub use boot_reconciler::{BootReconciler, DEFAULT_INTERVAL as RECONCILER_DEFAULT_INTERVAL};
|
||||
pub use dev_orchestrator::DevContainerOrchestrator;
|
||||
pub use docker_packages::DockerPackageScanner;
|
||||
pub use prod_orchestrator::{
|
||||
compute_container_name, AdoptionReport, ProdContainerOrchestrator, ReconcileAction,
|
||||
ReconcileReport,
|
||||
};
|
||||
pub use prod_orchestrator::ProdContainerOrchestrator;
|
||||
pub use traits::ContainerOrchestrator;
|
||||
|
||||
@@ -8,9 +8,10 @@ use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use tokio::fs;
|
||||
use tracing::{debug, info};
|
||||
|
||||
const REGISTRY_FILE: &str = "config/registries.json";
|
||||
const OVH_REGISTRY_URL: &str = "146.59.87.168:3000/lfg2025";
|
||||
const TX1138_REGISTRY_URL: &str = "git.tx1138.com/lfg2025";
|
||||
|
||||
/// A single container registry.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -44,14 +45,14 @@ impl Default for RegistryConfig {
|
||||
Self {
|
||||
registries: vec![
|
||||
Registry {
|
||||
url: "146.59.87.168:3000/lfg2025".to_string(),
|
||||
url: OVH_REGISTRY_URL.to_string(),
|
||||
name: "Server 1 (OVH)".to_string(),
|
||||
tls_verify: false,
|
||||
enabled: true,
|
||||
priority: 0,
|
||||
},
|
||||
Registry {
|
||||
url: "git.tx1138.com/lfg2025".to_string(),
|
||||
url: TX1138_REGISTRY_URL.to_string(),
|
||||
name: "Server 2 (tx1138)".to_string(),
|
||||
tls_verify: true,
|
||||
enabled: true,
|
||||
@@ -139,6 +140,19 @@ pub async fn load_registries(data_dir: &Path) -> Result<RegistryConfig> {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
let before_order: Vec<(String, bool, u32)> = config
|
||||
.registries
|
||||
.iter()
|
||||
.map(|r| (r.url.clone(), r.enabled, r.priority))
|
||||
.collect();
|
||||
force_ovh_registry_primary(&mut config);
|
||||
changed = changed
|
||||
|| before_order
|
||||
!= config
|
||||
.registries
|
||||
.iter()
|
||||
.map(|r| (r.url.clone(), r.enabled, r.priority))
|
||||
.collect::<Vec<_>>();
|
||||
if changed {
|
||||
// Persist so the next load doesn't have to re-merge.
|
||||
let _ = save_registries(data_dir, &config).await;
|
||||
@@ -146,6 +160,37 @@ pub async fn load_registries(data_dir: &Path) -> Result<RegistryConfig> {
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn force_ovh_registry_primary(config: &mut RegistryConfig) {
|
||||
let defaults = RegistryConfig::default();
|
||||
for def in defaults.registries {
|
||||
if !config.registries.iter().any(|r| r.url == def.url) {
|
||||
config.registries.push(def);
|
||||
}
|
||||
}
|
||||
|
||||
for registry in config.registries.iter_mut() {
|
||||
match registry.url.as_str() {
|
||||
OVH_REGISTRY_URL => {
|
||||
registry.name = "Server 1 (OVH)".to_string();
|
||||
registry.tls_verify = false;
|
||||
registry.enabled = true;
|
||||
registry.priority = 0;
|
||||
}
|
||||
TX1138_REGISTRY_URL => {
|
||||
registry.name = "Server 2 (tx1138)".to_string();
|
||||
registry.tls_verify = true;
|
||||
registry.enabled = true;
|
||||
registry.priority = 10;
|
||||
}
|
||||
_ => {
|
||||
if registry.priority <= 10 {
|
||||
registry.priority = registry.priority.saturating_add(20);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Save registry config to disk.
|
||||
pub async fn save_registries(data_dir: &Path, config: &RegistryConfig) -> Result<()> {
|
||||
let dir = data_dir.join("config");
|
||||
|
||||
@@ -412,6 +412,19 @@ pub async fn start_stopped_containers(data_dir: &Path) -> RecoveryReport {
|
||||
};
|
||||
}
|
||||
|
||||
let names: Vec<String> = names
|
||||
.into_iter()
|
||||
.filter(|n| should_auto_start_stopped_container(n))
|
||||
.collect();
|
||||
|
||||
if names.is_empty() {
|
||||
return RecoveryReport {
|
||||
total: 0,
|
||||
recovered: 0,
|
||||
failed: Vec::new(),
|
||||
};
|
||||
}
|
||||
|
||||
// Sort by startup tier: databases first, then core, then dependent services, then apps
|
||||
let mut records: Vec<RunningContainerRecord> = names
|
||||
.iter()
|
||||
@@ -430,6 +443,13 @@ pub async fn start_stopped_containers(data_dir: &Path) -> RecoveryReport {
|
||||
recover_containers(&records).await
|
||||
}
|
||||
|
||||
fn should_auto_start_stopped_container(name: &str) -> bool {
|
||||
// Keep generic boot recovery narrow. The Rust manifest reconciler owns
|
||||
// managed app stacks; starting every exited Podman container here races
|
||||
// it and resurrects legacy/orphan helper containers.
|
||||
matches!(name, "filebrowser" | "nostr-rs-relay")
|
||||
}
|
||||
|
||||
/// Simple tier ordering for boot recovery (mirrors health_monitor tiers).
|
||||
fn container_boot_tier(name: &str) -> u8 {
|
||||
let id = name.strip_prefix("archy-").unwrap_or(name);
|
||||
@@ -603,4 +623,13 @@ mod tests {
|
||||
let result = check_for_crash(tmp.path()).await.unwrap();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generic_boot_recovery_skips_manifest_owned_and_legacy_stacks() {
|
||||
assert!(should_auto_start_stopped_container("filebrowser"));
|
||||
assert!(should_auto_start_stopped_container("nostr-rs-relay"));
|
||||
assert!(!should_auto_start_stopped_container("bitcoin-knots"));
|
||||
assert!(!should_auto_start_stopped_container("lnd"));
|
||||
assert!(!should_auto_start_stopped_container("indeedhub-postgres"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,6 +255,12 @@ pub struct InstallProgress {
|
||||
/// a fixed UI percentage and a descriptive label.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub phase: Option<InstallPhase>,
|
||||
/// Optional explicit message — used to surface install failures so
|
||||
/// the UI can keep the app card visible with an error description
|
||||
/// instead of silently removing the entry on fail. UI's PHASE_INFO
|
||||
/// label takes precedence when phase is set.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
/// Phases of the install / update pipeline, surfaced to the UI so users
|
||||
|
||||
@@ -30,9 +30,11 @@ async fn bitcoin_rpc_auth() -> String {
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ElectrsSyncStatus {
|
||||
pub indexed_height: u64,
|
||||
pub bitcoin_height: u64,
|
||||
pub network_height: u64,
|
||||
pub progress_pct: f64,
|
||||
pub status: String,
|
||||
pub stale: bool,
|
||||
pub error: Option<String>,
|
||||
/// Index data size in human-readable format (e.g. "11.2 GB")
|
||||
pub index_size: Option<String>,
|
||||
@@ -44,9 +46,11 @@ impl Default for ElectrsSyncStatus {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
indexed_height: 0,
|
||||
bitcoin_height: 0,
|
||||
network_height: 0,
|
||||
progress_pct: 0.0,
|
||||
status: "starting".to_string(),
|
||||
stale: false,
|
||||
error: None,
|
||||
index_size: None,
|
||||
tor_onion: None,
|
||||
@@ -64,15 +68,31 @@ fn cache() -> &'static RwLock<ElectrsSyncStatus> {
|
||||
/// Spawn background task that refreshes ElectrumX status every CACHE_REFRESH_SECS.
|
||||
pub fn spawn_status_cache() {
|
||||
tokio::spawn(async {
|
||||
// Initial delay — let services start up before first query
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(CACHE_REFRESH_SECS));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let fresh = fetch_electrs_sync_status().await;
|
||||
let mut fresh = fetch_electrs_sync_status().await;
|
||||
let mut cached = cache().write().await;
|
||||
if fresh.indexed_height == 0
|
||||
&& cached.indexed_height > 0
|
||||
&& matches!(fresh.status.as_str(), "indexing" | "waiting")
|
||||
{
|
||||
fresh.indexed_height = cached.indexed_height;
|
||||
if fresh.network_height == 0 {
|
||||
fresh.network_height = cached.network_height;
|
||||
}
|
||||
if fresh.bitcoin_height == 0 {
|
||||
fresh.bitcoin_height = cached.bitcoin_height;
|
||||
}
|
||||
if fresh.progress_pct <= 0.0 {
|
||||
fresh.progress_pct = cached.progress_pct;
|
||||
}
|
||||
fresh.stale = true;
|
||||
fresh.error = Some(fresh.error.unwrap_or_else(|| {
|
||||
"ElectrumX is reconnecting; showing last known indexed height.".to_string()
|
||||
}));
|
||||
}
|
||||
*cached = fresh;
|
||||
drop(cached);
|
||||
tokio::time::sleep(Duration::from_secs(CACHE_REFRESH_SECS)).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -187,13 +207,69 @@ async fn electrumx_indexed_height() -> Result<u64> {
|
||||
Ok(height)
|
||||
}
|
||||
|
||||
/// Fetch Bitcoin network height via JSON-RPC.
|
||||
async fn bitcoin_network_height() -> Result<u64> {
|
||||
fn parse_electrumx_height_from_logs(logs: &str) -> Option<u64> {
|
||||
let mut height = None;
|
||||
|
||||
for line in logs.lines() {
|
||||
if let Some(idx) = line.find("BlockProcessor:our height:") {
|
||||
let rest = &line[idx + "BlockProcessor:our height:".len()..];
|
||||
if let Some(parsed) = parse_first_u64_token(rest) {
|
||||
height = Some(parsed);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(idx) = line.find("DB:height:") {
|
||||
let rest = &line[idx + "DB:height:".len()..];
|
||||
if let Some(parsed) = parse_first_u64_token(rest) {
|
||||
height = Some(parsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
height
|
||||
}
|
||||
|
||||
fn parse_first_u64_token(input: &str) -> Option<u64> {
|
||||
let token: String = input
|
||||
.trim_start()
|
||||
.chars()
|
||||
.take_while(|c| c.is_ascii_digit() || *c == ',')
|
||||
.filter(|c| *c != ',')
|
||||
.collect();
|
||||
|
||||
if token.is_empty() {
|
||||
None
|
||||
} else {
|
||||
token.parse().ok()
|
||||
}
|
||||
}
|
||||
|
||||
async fn electrumx_log_indexed_height() -> Result<u64> {
|
||||
let output = tokio::process::Command::new("podman")
|
||||
.args(["logs", "--tail", "500", "electrumx"])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to read ElectrumX logs")?;
|
||||
|
||||
if !output.status.success() {
|
||||
anyhow::bail!(
|
||||
"podman logs electrumx failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
);
|
||||
}
|
||||
|
||||
let logs = String::from_utf8_lossy(&output.stdout);
|
||||
parse_electrumx_height_from_logs(&logs).context("No ElectrumX indexed height in logs")
|
||||
}
|
||||
|
||||
/// Fetch Bitcoin local block height and best-known network header height via JSON-RPC.
|
||||
async fn bitcoin_chain_heights() -> Result<(u64, u64)> {
|
||||
let client = reqwest::Client::new();
|
||||
let body = serde_json::json!({
|
||||
"jsonrpc": "1.0",
|
||||
"id": "electrs-status",
|
||||
"method": "getblockcount",
|
||||
"method": "getblockchaininfo",
|
||||
"params": []
|
||||
});
|
||||
let resp = client
|
||||
@@ -211,11 +287,18 @@ async fn bitcoin_network_height() -> Result<u64> {
|
||||
}
|
||||
|
||||
let json: serde_json::Value = resp.json().await?;
|
||||
let height = json
|
||||
let result = json
|
||||
.get("result")
|
||||
.and_then(|r| r.as_u64())
|
||||
.context("Missing result in Bitcoin RPC")?;
|
||||
Ok(height)
|
||||
let blocks = result
|
||||
.get("blocks")
|
||||
.and_then(|h| h.as_u64())
|
||||
.context("Missing blocks in Bitcoin RPC")?;
|
||||
let headers = result
|
||||
.get("headers")
|
||||
.and_then(|h| h.as_u64())
|
||||
.unwrap_or(blocks);
|
||||
Ok((blocks, headers.max(blocks)))
|
||||
}
|
||||
|
||||
/// Fetch fresh ElectrumX sync status (called by background cache task).
|
||||
@@ -260,8 +343,8 @@ async fn fetch_electrs_sync_status() -> ElectrsSyncStatus {
|
||||
onion
|
||||
};
|
||||
|
||||
let network_height = match bitcoin_network_height().await {
|
||||
Ok(h) => h,
|
||||
let (bitcoin_blocks, network_height) = match bitcoin_chain_heights().await {
|
||||
Ok(heights) => heights,
|
||||
Err(e) => {
|
||||
let err_msg = e.to_string();
|
||||
if is_transient_error(&err_msg) {
|
||||
@@ -271,9 +354,11 @@ async fn fetch_electrs_sync_status() -> ElectrsSyncStatus {
|
||||
}
|
||||
return ElectrsSyncStatus {
|
||||
indexed_height: 0,
|
||||
bitcoin_height: 0,
|
||||
network_height: 0,
|
||||
progress_pct: 0.0,
|
||||
status: "waiting".to_string(),
|
||||
stale: false,
|
||||
error: Some("Waiting for Bitcoin node...".to_string()),
|
||||
index_size,
|
||||
tor_onion,
|
||||
@@ -283,21 +368,25 @@ async fn fetch_electrs_sync_status() -> ElectrsSyncStatus {
|
||||
|
||||
let indexed_height = match electrumx_indexed_height().await {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
let err_msg = e.to_string();
|
||||
if is_transient_error(&err_msg) {
|
||||
// ElectrumX is starting up or busy — estimate from data size
|
||||
let progress_pct = if data_bytes > 0 {
|
||||
((data_bytes as f64 / ESTIMATED_FULL_INDEX_BYTES) * 100.0).min(99.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let size_str = index_size.clone().unwrap_or_else(|| "0 MB".to_string());
|
||||
return ElectrsSyncStatus {
|
||||
Err(e) => match electrumx_log_indexed_height().await {
|
||||
Ok(h) if h > 0 => h,
|
||||
_ => {
|
||||
let err_msg = e.to_string();
|
||||
if is_transient_error(&err_msg) {
|
||||
// ElectrumX is starting up or busy — estimate from data size
|
||||
let progress_pct = if data_bytes > 0 {
|
||||
((data_bytes as f64 / ESTIMATED_FULL_INDEX_BYTES) * 100.0).min(99.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let size_str = index_size.clone().unwrap_or_else(|| "0 MB".to_string());
|
||||
return ElectrsSyncStatus {
|
||||
indexed_height: 0,
|
||||
bitcoin_height: bitcoin_blocks,
|
||||
network_height,
|
||||
progress_pct,
|
||||
status: "indexing".to_string(),
|
||||
stale: false,
|
||||
error: Some(format!(
|
||||
"Building index ({} / ~130 GB estimated). Electrum RPC will be available when complete.",
|
||||
size_str
|
||||
@@ -305,40 +394,90 @@ async fn fetch_electrs_sync_status() -> ElectrsSyncStatus {
|
||||
index_size,
|
||||
tor_onion,
|
||||
};
|
||||
}
|
||||
// Genuine unexpected error
|
||||
warn!("ElectrumX status: unexpected error: {}", err_msg);
|
||||
return ElectrsSyncStatus {
|
||||
indexed_height: 0,
|
||||
bitcoin_height: bitcoin_blocks,
|
||||
network_height,
|
||||
progress_pct: 0.0,
|
||||
status: "error".to_string(),
|
||||
stale: false,
|
||||
error: Some(format!("ElectrumX: {}", err_msg)),
|
||||
index_size,
|
||||
tor_onion,
|
||||
};
|
||||
}
|
||||
// Genuine unexpected error
|
||||
warn!("ElectrumX status: unexpected error: {}", err_msg);
|
||||
return ElectrsSyncStatus {
|
||||
indexed_height: 0,
|
||||
network_height,
|
||||
progress_pct: 0.0,
|
||||
status: "error".to_string(),
|
||||
error: Some(format!("ElectrumX: {}", err_msg)),
|
||||
index_size,
|
||||
tor_onion,
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
let progress_pct = if network_height > 0 {
|
||||
(indexed_height as f64 / network_height as f64) * 100.0
|
||||
let observed_header_height = network_height.max(indexed_height);
|
||||
let bitcoin_catching_up = bitcoin_blocks > 0 && bitcoin_blocks < observed_header_height;
|
||||
let electrum_waiting_on_bitcoin =
|
||||
bitcoin_catching_up && indexed_height >= bitcoin_blocks.saturating_sub(1);
|
||||
let sync_target_height = if bitcoin_blocks > 0 {
|
||||
bitcoin_blocks
|
||||
} else {
|
||||
observed_header_height
|
||||
};
|
||||
|
||||
let progress_pct = if electrum_waiting_on_bitcoin && observed_header_height > 0 {
|
||||
((bitcoin_blocks as f64 / observed_header_height as f64) * 100.0).min(99.9)
|
||||
} else if sync_target_height > 0 {
|
||||
((indexed_height as f64 / sync_target_height as f64) * 100.0).min(100.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let status = if indexed_height >= network_height.saturating_sub(1) {
|
||||
let status = if sync_target_height == 0 {
|
||||
"waiting"
|
||||
} else if electrum_waiting_on_bitcoin {
|
||||
"waiting"
|
||||
} else if indexed_height >= sync_target_height.saturating_sub(1) {
|
||||
"synced"
|
||||
} else {
|
||||
"syncing"
|
||||
};
|
||||
|
||||
let error = if electrum_waiting_on_bitcoin {
|
||||
Some(format!(
|
||||
"ElectrumX is indexed to {:}; waiting for the local Bitcoin node to catch up from {:} to known header {:}.",
|
||||
indexed_height, bitcoin_blocks, observed_header_height
|
||||
))
|
||||
} else if status == "syncing" && bitcoin_blocks < observed_header_height {
|
||||
Some(format!(
|
||||
"Indexing local Bitcoin node height {:} of {:}. Bitcoin node is still catching up to known header {:}.",
|
||||
indexed_height, bitcoin_blocks, observed_header_height
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
ElectrsSyncStatus {
|
||||
indexed_height,
|
||||
network_height,
|
||||
bitcoin_height: bitcoin_blocks,
|
||||
network_height: observed_header_height,
|
||||
progress_pct,
|
||||
status: status.to_string(),
|
||||
error: None,
|
||||
stale: false,
|
||||
error,
|
||||
index_size,
|
||||
tor_onion,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::parse_electrumx_height_from_logs;
|
||||
|
||||
#[test]
|
||||
fn parses_latest_electrumx_progress_height_from_logs() {
|
||||
let logs = r#"
|
||||
INFO:DB:height: 228,238
|
||||
INFO:BlockProcessor:our height: 228,248 daemon: 731,568 UTXOs 1MB hist 1MB
|
||||
INFO:BlockProcessor:our height: 232,117 daemon: 732,108 UTXOs 281MB hist 83MB
|
||||
"#;
|
||||
assert_eq!(parse_electrumx_height_from_logs(logs), Some(232_117));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// handles "created" state containers, resets dependent counters when deps recover,
|
||||
// and sends WebSocket notifications to the UI on failure.
|
||||
|
||||
use crate::data_model::{Notification, NotificationLevel};
|
||||
use crate::data_model::{Notification, NotificationLevel, PackageState};
|
||||
use crate::state::StateManager;
|
||||
use crate::webhooks::{self, WebhookEvent};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -67,14 +67,14 @@ fn container_dependencies(name: &str) -> &'static [&'static str] {
|
||||
let id = name.strip_prefix("archy-").unwrap_or(name);
|
||||
match id {
|
||||
// Bitcoin-dependent chain
|
||||
"lnd" => &["bitcoin-knots"],
|
||||
"electrumx" | "mempool-electrs" | "electrs" => &["bitcoin-knots"],
|
||||
"nbxplorer" => &["bitcoin-knots"],
|
||||
"lnd" => &["bitcoin"],
|
||||
"electrumx" | "mempool-electrs" | "electrs" => &["bitcoin"],
|
||||
"nbxplorer" => &["bitcoin"],
|
||||
"btcpay-server" => &["btcpay-db", "nbxplorer"],
|
||||
"mempool-api" => &["mempool-db", "electrumx"],
|
||||
"mempool-web" => &["mempool-api"],
|
||||
"fedimint" => &["bitcoin-knots"],
|
||||
"fedimint-gateway" => &["bitcoin-knots", "fedimint"],
|
||||
"fedimint" => &["bitcoin"],
|
||||
"fedimint-gateway" => &["bitcoin", "fedimint"],
|
||||
|
||||
// IndeedHub stack
|
||||
"indeedhub-api" => &["indeedhub-postgres", "indeedhub-redis"],
|
||||
@@ -88,7 +88,7 @@ fn container_dependencies(name: &str) -> &'static [&'static str] {
|
||||
"penpot-frontend" => &["penpot-backend"],
|
||||
|
||||
// UI containers
|
||||
"bitcoin-ui" => &["bitcoin-knots"],
|
||||
"bitcoin-ui" => &["bitcoin"],
|
||||
"lnd-ui" => &["lnd"],
|
||||
"electrs-ui" => &["electrumx"],
|
||||
|
||||
@@ -103,6 +103,16 @@ fn deps_are_running(name: &str, containers: &[ContainerHealth]) -> bool {
|
||||
return true;
|
||||
}
|
||||
for dep in deps {
|
||||
if *dep == "bitcoin" {
|
||||
let bitcoin_running = containers.iter().any(|c| {
|
||||
let c_id = c.name.strip_prefix("archy-").unwrap_or(&c.name);
|
||||
matches!(c_id, "bitcoin" | "bitcoin-knots" | "bitcoin-core") && c.state == "running"
|
||||
});
|
||||
if !bitcoin_running {
|
||||
return false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Check both plain name and archy- prefixed name
|
||||
let dep_running = containers.iter().any(|c| {
|
||||
let c_id = c.name.strip_prefix("archy-").unwrap_or(&c.name);
|
||||
@@ -115,6 +125,24 @@ fn deps_are_running(name: &str, containers: &[ContainerHealth]) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn conflicting_bitcoin_variant(name: &str) -> Option<&'static str> {
|
||||
match name.strip_prefix("archy-").unwrap_or(name) {
|
||||
"bitcoin-core" => Some("bitcoin-knots"),
|
||||
"bitcoin-knots" | "bitcoin" => Some("bitcoin-core"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn has_running_bitcoin_conflict(name: &str, containers: &[ContainerHealth]) -> bool {
|
||||
let Some(conflict) = conflicting_bitcoin_variant(name) else {
|
||||
return false;
|
||||
};
|
||||
containers.iter().any(|c| {
|
||||
let id = c.name.strip_prefix("archy-").unwrap_or(&c.name);
|
||||
id == conflict && c.state == "running"
|
||||
})
|
||||
}
|
||||
|
||||
/// Track restart attempts per container with exponential backoff and stability reset.
|
||||
struct RestartTracker {
|
||||
attempts: HashMap<String, u32>,
|
||||
@@ -539,6 +567,30 @@ pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
|
||||
debug!("Skipping uninstalled container: {}", container.name);
|
||||
continue;
|
||||
}
|
||||
if matches!(
|
||||
pkg.state,
|
||||
PackageState::Starting | PackageState::Stopping | PackageState::Restarting
|
||||
) {
|
||||
debug!(
|
||||
"Skipping container during package lifecycle transition: {} ({:?})",
|
||||
container.name, pkg.state
|
||||
);
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
// Orphan: container exists in podman but archipelago has
|
||||
// no package_data entry for it. Common after a variant
|
||||
// switch (bitcoin-core ↔ bitcoin-knots) where the
|
||||
// uninstall removed the package entry but the prior
|
||||
// variant's container survived in stopped state. Without
|
||||
// this guard the health monitor pages every minute with
|
||||
// "Auto-restart failed (attempt N/10)" for an app the
|
||||
// user can no longer see in the dashboard.
|
||||
debug!(
|
||||
"Skipping orphan container (not in package_data): {}",
|
||||
container.name
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if container.healthy {
|
||||
@@ -636,6 +688,14 @@ pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if has_running_bitcoin_conflict(&container.name, &containers) {
|
||||
debug!(
|
||||
"Skipping auto-restart for {} because the other Bitcoin implementation is running",
|
||||
container.name
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// When transitioning to a higher tier, wait briefly for previous tier to stabilize
|
||||
if let Some(prev) = prev_tier {
|
||||
if tier > prev {
|
||||
@@ -902,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"));
|
||||
@@ -943,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);
|
||||
|
||||
@@ -29,6 +29,7 @@ mod auth;
|
||||
mod avatar;
|
||||
mod backup;
|
||||
mod bitcoin_rpc;
|
||||
mod bitcoin_status;
|
||||
mod blobs;
|
||||
mod bootstrap;
|
||||
mod config;
|
||||
@@ -136,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).
|
||||
@@ -289,6 +285,7 @@ async fn main() -> Result<()> {
|
||||
|
||||
// Spawn ElectrumX status cache (refreshes every 15s, serves cached data to avoid race conditions)
|
||||
electrs_status::spawn_status_cache();
|
||||
bitcoin_status::spawn_status_cache();
|
||||
|
||||
let startup_ms = startup_start.elapsed().as_millis();
|
||||
info!(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -313,6 +313,7 @@ impl Server {
|
||||
let scanner = create_docker_scanner(&config).await?;
|
||||
let state = state_manager.clone();
|
||||
let identity_clone = identity.clone();
|
||||
let data_dir = config.data_dir.clone();
|
||||
let scan_kick = api_handler.rpc_handler().scan_kick();
|
||||
let scan_tick = api_handler.rpc_handler().scan_tick();
|
||||
|
||||
@@ -334,6 +335,7 @@ impl Server {
|
||||
&scanner,
|
||||
&state,
|
||||
identity_clone.as_ref(),
|
||||
&data_dir,
|
||||
&mut absence_tracker,
|
||||
&mut transitional_since,
|
||||
)
|
||||
@@ -371,6 +373,7 @@ impl Server {
|
||||
&scanner,
|
||||
&state,
|
||||
identity_clone.as_ref(),
|
||||
&data_dir,
|
||||
&mut absence_tracker,
|
||||
&mut transitional_since,
|
||||
)
|
||||
@@ -865,8 +868,19 @@ fn merge_preserving_transitional(
|
||||
existing: &crate::data_model::PackageDataEntry,
|
||||
fresh: &crate::data_model::PackageDataEntry,
|
||||
) -> crate::data_model::PackageDataEntry {
|
||||
let state = match (&existing.state, &fresh.state) {
|
||||
// Removing with a live running container is stale: uninstall either
|
||||
// failed or Archipelago restarted before the spawned task could revert
|
||||
// state. Let the scanner recover the UI immediately instead of
|
||||
// keeping the app wedged in Removing for 20 minutes.
|
||||
(crate::data_model::PackageState::Removing, crate::data_model::PackageState::Running) => {
|
||||
fresh.state.clone()
|
||||
}
|
||||
_ => existing.state.clone(),
|
||||
};
|
||||
|
||||
crate::data_model::PackageDataEntry {
|
||||
state: existing.state.clone(),
|
||||
state,
|
||||
// install_progress and uninstall_stage are also owned by the
|
||||
// initiating op (same reason as state) — keep them.
|
||||
install_progress: existing.install_progress.clone(),
|
||||
@@ -885,10 +899,18 @@ async fn scan_and_update_packages(
|
||||
scanner: &DockerPackageScanner,
|
||||
state: &StateManager,
|
||||
identity: &NodeIdentity,
|
||||
data_dir: &std::path::Path,
|
||||
absence_tracker: &mut HashMap<String, u32>,
|
||||
transitional_since: &mut HashMap<String, Instant>,
|
||||
) -> Result<()> {
|
||||
let packages = scanner.scan_containers().await?;
|
||||
let mut packages = scanner.scan_containers().await?;
|
||||
let user_stopped = crate::crash_recovery::load_user_stopped(data_dir).await;
|
||||
for (id, pkg) in packages.iter_mut() {
|
||||
if pkg.state == crate::data_model::PackageState::Exited && user_stopped.contains(id) {
|
||||
pkg.state = crate::data_model::PackageState::Stopped;
|
||||
pkg.exit_code = None;
|
||||
}
|
||||
}
|
||||
|
||||
let (current_data, _) = state.get_snapshot().await;
|
||||
let tor_addr = docker_packages::read_tor_address("archipelago").await;
|
||||
@@ -992,6 +1014,18 @@ async fn scan_and_update_packages(
|
||||
// owner (spawn_task) is responsible for clearing state, not us.
|
||||
if let Some(entry) = merged.get(&id) {
|
||||
if is_transitional(&entry.state) {
|
||||
let entered = *transitional_since.entry(id.clone()).or_insert(now);
|
||||
if now.duration_since(entered) > TRANSITIONAL_STUCK_TIMEOUT {
|
||||
warn!(
|
||||
"Container {} stuck in {:?} and absent for >{}s; removing stale transitional state",
|
||||
id,
|
||||
entry.state,
|
||||
TRANSITIONAL_STUCK_TIMEOUT.as_secs()
|
||||
);
|
||||
merged.remove(&id);
|
||||
transitional_since.remove(&id);
|
||||
changed = true;
|
||||
}
|
||||
absence_tracker.remove(&id);
|
||||
continue;
|
||||
}
|
||||
@@ -1170,6 +1204,15 @@ mod merge_tests {
|
||||
assert_eq!(merged.exit_code, Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_removing_recovers_when_container_is_running() {
|
||||
let existing = make_entry(PackageState::Removing, Some("unknown"));
|
||||
let fresh = make_entry(PackageState::Running, Some("healthy"));
|
||||
let merged = merge_preserving_transitional(&existing, &fresh);
|
||||
assert_eq!(merged.state, PackageState::Running);
|
||||
assert_eq!(merged.health.as_deref(), Some("healthy"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_transitional_covers_all_variants() {
|
||||
for s in [
|
||||
|
||||
@@ -163,12 +163,40 @@ pub async fn load_mirrors(data_dir: &Path) -> Result<Vec<UpdateMirror>> {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
let before_order: Vec<String> = list.iter().map(|m| m.url.clone()).collect();
|
||||
force_ovh_update_primary(&mut list);
|
||||
changed = changed || before_order != list.iter().map(|m| m.url.clone()).collect::<Vec<_>>();
|
||||
if changed {
|
||||
let _ = save_mirrors(data_dir, &list).await;
|
||||
}
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
fn force_ovh_update_primary(list: &mut Vec<UpdateMirror>) {
|
||||
let defaults = default_mirrors();
|
||||
for def in &defaults {
|
||||
if !list.iter().any(|m| m.url == def.url) {
|
||||
list.push(def.clone());
|
||||
}
|
||||
}
|
||||
for mirror in list.iter_mut() {
|
||||
if mirror.url == DEFAULT_UPDATE_MANIFEST_URL {
|
||||
mirror.label = "Server 1 (OVH)".to_string();
|
||||
} else if mirror.url == DEFAULT_SECONDARY_MIRROR_URL {
|
||||
mirror.label = "Server 2 (tx1138)".to_string();
|
||||
}
|
||||
}
|
||||
list.sort_by_key(|m| {
|
||||
if m.url == DEFAULT_UPDATE_MANIFEST_URL {
|
||||
0
|
||||
} else if m.url == DEFAULT_SECONDARY_MIRROR_URL {
|
||||
1
|
||||
} else {
|
||||
2
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub async fn save_mirrors(data_dir: &Path, mirrors: &[UpdateMirror]) -> Result<()> {
|
||||
fs::create_dir_all(data_dir)
|
||||
.await
|
||||
@@ -571,15 +599,10 @@ pub async fn check_for_updates(data_dir: &Path) -> Result<UpdateState> {
|
||||
mirror = %manifest_url,
|
||||
"No newer version in manifest"
|
||||
);
|
||||
if state.available_update.is_some() {
|
||||
// A later mirror might still have a
|
||||
// newer version — don't clobber what an
|
||||
// earlier mirror told us. But also don't
|
||||
// break: another mirror could be ahead.
|
||||
continue 'mirrors;
|
||||
}
|
||||
state.manifest_mirror = None;
|
||||
state.available_update = None;
|
||||
handled = true;
|
||||
continue 'mirrors;
|
||||
}
|
||||
handled = true;
|
||||
break 'mirrors;
|
||||
@@ -1173,6 +1196,125 @@ pub async fn apply_update(data_dir: &Path) -> Result<()> {
|
||||
}
|
||||
info!(name = %name, "Frontend archive extracted to /opt/archipelago/web-ui");
|
||||
}
|
||||
_ if name.contains("runtime") && name.ends_with(".tar.gz") => {
|
||||
let ts = chrono::Utc::now().timestamp_millis();
|
||||
let staging_new = format!("/opt/archipelago/runtime.new.{}", ts);
|
||||
let archive = src.to_string_lossy().to_string();
|
||||
|
||||
let mk = host_sudo(&["mkdir", "-p", &staging_new])
|
||||
.await
|
||||
.context("Failed to create runtime staging dir")?;
|
||||
if !mk.success() {
|
||||
anyhow::bail!("mkdir {} failed", staging_new);
|
||||
}
|
||||
|
||||
let extract = host_sudo(&["tar", "-xzf", &archive, "-C", &staging_new])
|
||||
.await
|
||||
.with_context(|| format!("Failed to extract {}", name))?;
|
||||
if !extract.success() {
|
||||
let _ = host_sudo(&["rm", "-rf", &staging_new]).await;
|
||||
anyhow::bail!("tar extraction failed for {}", name);
|
||||
}
|
||||
|
||||
let runtime_paths = [
|
||||
("apps", "apps"),
|
||||
("scripts", "scripts"),
|
||||
("docker", "docker"),
|
||||
(
|
||||
"image-recipe/configs/archipelago-doctor.service",
|
||||
"archipelago-doctor.service",
|
||||
),
|
||||
(
|
||||
"image-recipe/configs/archipelago-doctor.timer",
|
||||
"archipelago-doctor.timer",
|
||||
),
|
||||
];
|
||||
|
||||
for (relative, label) in runtime_paths {
|
||||
let staged_path = format!("{}/{}", staging_new, relative);
|
||||
if !Path::new(&staged_path).exists() {
|
||||
tracing::debug!(path = %relative, "Runtime artifact path absent, skipping");
|
||||
continue;
|
||||
}
|
||||
|
||||
match label {
|
||||
"apps" | "scripts" | "docker" => {
|
||||
let dest = format!("/opt/archipelago/{}", label);
|
||||
let tmp_dest =
|
||||
format!("{}.new.{}", dest, chrono::Utc::now().timestamp_millis());
|
||||
let _ = host_sudo(&["mkdir", "-p", &tmp_dest]).await;
|
||||
let staged_dot = format!("{}/.", staged_path);
|
||||
let copy = host_sudo(&["cp", "-a", &staged_dot, &tmp_dest])
|
||||
.await
|
||||
.with_context(|| format!("Failed to copy runtime {}", label))?;
|
||||
if !copy.success() {
|
||||
let _ = host_sudo(&["rm", "-rf", &tmp_dest]).await;
|
||||
anyhow::bail!("runtime copy failed for {}", label);
|
||||
}
|
||||
let _ = host_sudo(&["mkdir", "-p", &dest]).await;
|
||||
let clean = host_sudo(&[
|
||||
"find",
|
||||
&dest,
|
||||
"-mindepth",
|
||||
"1",
|
||||
"-maxdepth",
|
||||
"1",
|
||||
"-exec",
|
||||
"rm",
|
||||
"-rf",
|
||||
"{}",
|
||||
"+",
|
||||
])
|
||||
.await
|
||||
.with_context(|| format!("Failed to clean runtime {}", label))?;
|
||||
if !clean.success() {
|
||||
let _ = host_sudo(&["rm", "-rf", &tmp_dest]).await;
|
||||
anyhow::bail!("runtime clean failed for {}", label);
|
||||
}
|
||||
let tmp_dot = format!("{}/.", tmp_dest);
|
||||
let promote = host_sudo(&["cp", "-a", &tmp_dot, &dest])
|
||||
.await
|
||||
.with_context(|| format!("Failed to promote runtime {}", label))?;
|
||||
let _ = host_sudo(&["rm", "-rf", &tmp_dest]).await;
|
||||
if !promote.success() {
|
||||
anyhow::bail!("runtime promote failed for {}", label);
|
||||
}
|
||||
if label == "scripts" {
|
||||
let _ = host_sudo(&[
|
||||
"find", &dest, "-type", "f", "-name", "*.sh", "-exec", "chmod",
|
||||
"755", "{}", "+",
|
||||
])
|
||||
.await;
|
||||
}
|
||||
}
|
||||
"archipelago-doctor.service" | "archipelago-doctor.timer" => {
|
||||
let dest = format!("/etc/systemd/system/{}", label);
|
||||
let install = host_sudo(&["install", "-m", "644", &staged_path, &dest])
|
||||
.await
|
||||
.with_context(|| format!("Failed to install {}", label))?;
|
||||
if !install.success() {
|
||||
anyhow::bail!("runtime unit install failed for {}", label);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if Path::new(&format!("{}/scripts/image-versions.sh", staging_new)).exists() {
|
||||
let _ = host_sudo(&[
|
||||
"cp",
|
||||
&format!("{}/scripts/image-versions.sh", staging_new),
|
||||
"/opt/archipelago/image-versions.sh",
|
||||
])
|
||||
.await;
|
||||
}
|
||||
|
||||
let _ = host_sudo(&["systemctl", "daemon-reload"]).await;
|
||||
let _ =
|
||||
host_sudo(&["systemctl", "enable", "--now", "archipelago-doctor.timer"]).await;
|
||||
let _ = host_sudo(&["rm", "-rf", &staging_new]).await;
|
||||
info!(name = %name, "Runtime assets applied to /opt/archipelago");
|
||||
}
|
||||
_ => {
|
||||
debug!(name = %name, "Unknown component, skipping");
|
||||
}
|
||||
|
||||
@@ -1,268 +0,0 @@
|
||||
use crate::manifest::{AppManifest, Dependency};
|
||||
use indexmap::IndexMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum DependencyError {
|
||||
#[error("Circular dependency detected: {0}")]
|
||||
CircularDependency(String),
|
||||
#[error("Missing dependency: {0}")]
|
||||
MissingDependency(String),
|
||||
#[error("Version conflict: {0}")]
|
||||
VersionConflict(String),
|
||||
}
|
||||
|
||||
pub struct DependencyResolver {
|
||||
manifests: IndexMap<String, AppManifest>,
|
||||
}
|
||||
|
||||
impl DependencyResolver {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
manifests: IndexMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_manifest(&mut self, manifest: AppManifest) {
|
||||
self.manifests.insert(manifest.app.id.clone(), manifest);
|
||||
}
|
||||
|
||||
pub fn resolve_dependencies(&self, app_id: &str) -> Result<Vec<String>, DependencyError> {
|
||||
let mut visited = HashSet::new();
|
||||
let mut visiting = HashSet::new();
|
||||
let mut result = Vec::new();
|
||||
|
||||
self.resolve_recursive(app_id, &mut visited, &mut visiting, &mut result)?;
|
||||
|
||||
// Result is already in installation order (dependencies first)
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn resolve_recursive(
|
||||
&self,
|
||||
app_id: &str,
|
||||
visited: &mut HashSet<String>,
|
||||
visiting: &mut HashSet<String>,
|
||||
result: &mut Vec<String>,
|
||||
) -> Result<(), DependencyError> {
|
||||
if visited.contains(app_id) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if visiting.contains(app_id) {
|
||||
return Err(DependencyError::CircularDependency(format!(
|
||||
"Circular dependency detected involving: {}",
|
||||
app_id
|
||||
)));
|
||||
}
|
||||
|
||||
visiting.insert(app_id.to_string());
|
||||
|
||||
let manifest = self.manifests.get(app_id).ok_or_else(|| {
|
||||
DependencyError::MissingDependency(format!("App not found: {}", app_id))
|
||||
})?;
|
||||
|
||||
// Resolve all dependencies first
|
||||
for dep in &manifest.app.dependencies {
|
||||
match dep {
|
||||
Dependency::App {
|
||||
app_id: dep_id,
|
||||
version: _,
|
||||
} => {
|
||||
self.resolve_recursive(dep_id, visited, visiting, result)?;
|
||||
}
|
||||
Dependency::Storage { storage: _ } => {
|
||||
// Storage dependencies are checked but don't require other apps
|
||||
}
|
||||
Dependency::Simple(dep_id) => {
|
||||
self.resolve_recursive(dep_id, visited, visiting, result)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
visiting.remove(app_id);
|
||||
visited.insert(app_id.to_string());
|
||||
|
||||
if !result.contains(&app_id.to_string()) {
|
||||
result.push(app_id.to_string());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn check_conflicts(&self, app_id: &str) -> Result<(), DependencyError> {
|
||||
let manifest = self.manifests.get(app_id).ok_or_else(|| {
|
||||
DependencyError::MissingDependency(format!("App not found: {}", app_id))
|
||||
})?;
|
||||
|
||||
// Check for port conflicts
|
||||
let mut port_usage: HashMap<u16, String> = HashMap::new();
|
||||
|
||||
for (id, m) in &self.manifests {
|
||||
if id == app_id {
|
||||
continue;
|
||||
}
|
||||
|
||||
for port in &m.app.ports {
|
||||
if let Some(existing) = port_usage.get(&port.host) {
|
||||
return Err(DependencyError::VersionConflict(format!(
|
||||
"Port {} already used by {}",
|
||||
port.host, existing
|
||||
)));
|
||||
}
|
||||
port_usage.insert(port.host, id.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Check for new app's ports
|
||||
for port in &manifest.app.ports {
|
||||
if let Some(existing) = port_usage.get(&port.host) {
|
||||
return Err(DependencyError::VersionConflict(format!(
|
||||
"Port {} already used by {}",
|
||||
port.host, existing
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn calculate_resources(&self, app_ids: &[String]) -> ResourceRequirements {
|
||||
let mut total = ResourceRequirements {
|
||||
cpu: 0,
|
||||
memory_mb: 0,
|
||||
disk_gb: 0,
|
||||
};
|
||||
|
||||
for app_id in app_ids {
|
||||
if let Some(manifest) = self.manifests.get(app_id) {
|
||||
if let Some(cpu) = manifest.app.resources.cpu_limit {
|
||||
total.cpu += cpu;
|
||||
}
|
||||
|
||||
if let Some(memory) = &manifest.app.resources.memory_limit {
|
||||
// Parse memory string (e.g., "1Gi", "512Mi")
|
||||
if let Ok(mb) = parse_memory(memory) {
|
||||
total.memory_mb += mb;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(disk) = &manifest.app.resources.disk_limit {
|
||||
// Parse disk string (e.g., "10Gi", "500Mi")
|
||||
if let Ok(gb) = parse_disk(disk) {
|
||||
total.disk_gb += gb;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
total
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_memory(s: &str) -> Result<u32, ()> {
|
||||
let s = s.trim().to_lowercase();
|
||||
if s.ends_with("gi") {
|
||||
let num: f64 = s.trim_end_matches("gi").parse().map_err(|_| ())?;
|
||||
Ok((num * 1024.0) as u32)
|
||||
} else if s.ends_with("mi") {
|
||||
let num: f64 = s.trim_end_matches("mi").parse().map_err(|_| ())?;
|
||||
Ok(num as u32)
|
||||
} else {
|
||||
Err(())
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_disk(s: &str) -> Result<u32, ()> {
|
||||
let s = s.trim().to_lowercase();
|
||||
if s.ends_with("gi") {
|
||||
let num: f64 = s.trim_end_matches("gi").parse().map_err(|_| ())?;
|
||||
Ok(num as u32)
|
||||
} else if s.ends_with("ti") {
|
||||
let num: f64 = s.trim_end_matches("ti").parse().map_err(|_| ())?;
|
||||
Ok((num * 1024.0) as u32)
|
||||
} else {
|
||||
Err(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResourceRequirements {
|
||||
pub cpu: u32,
|
||||
pub memory_mb: u32,
|
||||
pub disk_gb: u32,
|
||||
}
|
||||
|
||||
impl Default for DependencyResolver {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::manifest::{AppDefinition, AppManifest, ContainerConfig};
|
||||
|
||||
fn create_test_manifest(id: &str, deps: Vec<Dependency>) -> AppManifest {
|
||||
AppManifest {
|
||||
app: AppDefinition {
|
||||
id: id.to_string(),
|
||||
name: format!("Test {}", id),
|
||||
version: "1.0.0".to_string(),
|
||||
description: None,
|
||||
container: ContainerConfig {
|
||||
image: Some(format!("test/{}:latest", id)),
|
||||
image_signature: None,
|
||||
pull_policy: "if-not-present".to_string(),
|
||||
build: None,
|
||||
network: None,
|
||||
custom_args: vec![],
|
||||
entrypoint: None,
|
||||
derived_env: vec![],
|
||||
secret_env: vec![],
|
||||
data_uid: None,
|
||||
},
|
||||
dependencies: deps,
|
||||
resources: Default::default(),
|
||||
security: Default::default(),
|
||||
ports: vec![],
|
||||
volumes: vec![],
|
||||
environment: vec![],
|
||||
health_check: None,
|
||||
devices: vec![],
|
||||
extensions: Default::default(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_simple_dependency() {
|
||||
let mut resolver = DependencyResolver::new();
|
||||
resolver.add_manifest(create_test_manifest("app1", vec![]));
|
||||
resolver.add_manifest(create_test_manifest(
|
||||
"app2",
|
||||
vec![Dependency::Simple("app1".to_string())],
|
||||
));
|
||||
|
||||
let deps = resolver.resolve_dependencies("app2").unwrap();
|
||||
assert_eq!(deps, vec!["app1", "app2"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_circular_dependency() {
|
||||
let mut resolver = DependencyResolver::new();
|
||||
resolver.add_manifest(create_test_manifest(
|
||||
"app1",
|
||||
vec![Dependency::Simple("app2".to_string())],
|
||||
));
|
||||
resolver.add_manifest(create_test_manifest(
|
||||
"app2",
|
||||
vec![Dependency::Simple("app1".to_string())],
|
||||
));
|
||||
|
||||
let result = resolver.resolve_dependencies("app1");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
pub mod bitcoin_simulator;
|
||||
pub mod dependency_resolver;
|
||||
pub mod health_monitor;
|
||||
pub mod manifest;
|
||||
pub mod podman_client;
|
||||
@@ -7,13 +6,14 @@ pub mod port_manager;
|
||||
pub mod runtime;
|
||||
|
||||
pub use bitcoin_simulator::{BitcoinSimulationMode, BitcoinSimulator};
|
||||
pub use dependency_resolver::DependencyResolver;
|
||||
pub use health_monitor::HealthMonitor;
|
||||
pub use manifest::{
|
||||
AppManifest, BuildConfig, ContainerConfig, Dependency, DerivedEnv, HealthCheck, HostFacts,
|
||||
ManifestError, ResolvedSource, ResourceLimits, SecretEnv, SecretsProvider, SecurityPolicy,
|
||||
Volume,
|
||||
};
|
||||
pub use podman_client::{ContainerState, ContainerStatus, PodmanClient};
|
||||
pub use podman_client::{
|
||||
image_uses_insecure_registry, ContainerState, ContainerStatus, PodmanClient,
|
||||
};
|
||||
pub use port_manager::{PortError, PortManager};
|
||||
pub use runtime::{AutoRuntime, ContainerRuntime, DockerRuntime, PodmanRuntime};
|
||||
|
||||
@@ -619,6 +619,14 @@ impl ContainerConfig {
|
||||
let mut out = Vec::with_capacity(self.secret_env.len());
|
||||
for e in &self.secret_env {
|
||||
let v = provider.read(&e.secret_file)?;
|
||||
// An empty secret produces e.g. `-rpcpassword=` and crashes
|
||||
// the container on auth before logs are useful. Fail loud.
|
||||
if v.trim().is_empty() {
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"secret_env {} resolved to empty value (file: {})",
|
||||
e.key, e.secret_file
|
||||
)));
|
||||
}
|
||||
out.push(format!("{}={}", e.key, v));
|
||||
}
|
||||
Ok(out)
|
||||
@@ -1051,6 +1059,36 @@ app:
|
||||
assert_eq!(out[1], "FM_GATEWAY_PASSWORD=supersecret2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_secret_env_rejects_empty_value() {
|
||||
let c = ContainerConfig {
|
||||
image: Some("x:latest".to_string()),
|
||||
image_signature: None,
|
||||
pull_policy: "if-not-present".to_string(),
|
||||
build: None,
|
||||
network: None,
|
||||
custom_args: vec![],
|
||||
entrypoint: None,
|
||||
derived_env: vec![],
|
||||
secret_env: vec![SecretEnv {
|
||||
key: "BITCOIN_RPC_PASS".to_string(),
|
||||
secret_file: "bitcoin-rpc-password".to_string(),
|
||||
}],
|
||||
data_uid: None,
|
||||
};
|
||||
let p = MapSecretsProvider {
|
||||
data: HashMap::from([("bitcoin-rpc-password".to_string(), " \n".to_string())]),
|
||||
};
|
||||
let err = c.resolve_secret_env(&p).unwrap_err();
|
||||
match err {
|
||||
ManifestError::Invalid(msg) => assert!(
|
||||
msg.contains("BITCOIN_RPC_PASS") && msg.contains("bitcoin-rpc-password"),
|
||||
"msg should name the env key + file: {msg}"
|
||||
),
|
||||
other => panic!("expected Invalid, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_every_real_manifest() {
|
||||
let app_manifests = list_repo_manifests();
|
||||
|
||||
@@ -109,7 +109,7 @@ impl PodmanClient {
|
||||
pub fn lan_address_for(name: &str) -> Option<String> {
|
||||
let url = match name {
|
||||
"bitcoin-knots" | "bitcoin-ui" => "http://localhost:8334",
|
||||
"lnd" | "archy-lnd-ui" => "http://localhost:8081",
|
||||
"lnd" | "archy-lnd-ui" => "http://localhost:18083",
|
||||
"homeassistant" => "http://localhost:8123",
|
||||
"archy-mempool-web" | "mempool" => "http://localhost:4080",
|
||||
"btcpay-server" => "http://localhost:23000",
|
||||
@@ -257,7 +257,11 @@ impl PodmanClient {
|
||||
pub async fn pull_image(&self, image: &str, _signature: Option<&str>) -> Result<()> {
|
||||
// Image pull uses CLI — it's a streaming operation that the API handles differently
|
||||
let mut cmd = tokio::process::Command::new("podman");
|
||||
cmd.arg("pull").arg(image);
|
||||
cmd.arg("pull");
|
||||
if image_uses_insecure_registry(image) {
|
||||
cmd.arg("--tls-verify=false");
|
||||
}
|
||||
cmd.arg(image);
|
||||
|
||||
let output = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(600), // 10 min for large images
|
||||
@@ -357,20 +361,12 @@ impl PodmanClient {
|
||||
);
|
||||
}
|
||||
|
||||
let net_mode = if let Some(n) = manifest.app.container.network.as_ref() {
|
||||
if n.is_empty() {
|
||||
"bridge"
|
||||
} else {
|
||||
n.as_str()
|
||||
}
|
||||
} else {
|
||||
match manifest.app.security.network_policy.as_str() {
|
||||
"host" => "host",
|
||||
_ => "bridge",
|
||||
}
|
||||
};
|
||||
let (net_mode, custom_network) = podman_network_settings(
|
||||
manifest.app.container.network.as_deref(),
|
||||
manifest.app.security.network_policy.as_str(),
|
||||
);
|
||||
|
||||
let body = serde_json::json!({
|
||||
let mut body = serde_json::json!({
|
||||
"name": name,
|
||||
"image": image_ref,
|
||||
"portmappings": port_mappings,
|
||||
@@ -378,7 +374,10 @@ impl PodmanClient {
|
||||
"env": env_map,
|
||||
"entrypoint": manifest.app.container.entrypoint.clone(),
|
||||
"command": manifest.app.container.custom_args.clone(),
|
||||
"hostadd": ["host.containers.internal:host-gateway"],
|
||||
"hostadd": [
|
||||
"host.containers.internal:host-gateway",
|
||||
"host.archipelago:10.89.0.1",
|
||||
],
|
||||
"devices": manifest.app.devices.iter().map(|d| {
|
||||
serde_json::json!({"path": d})
|
||||
}).collect::<Vec<_>>(),
|
||||
@@ -393,6 +392,14 @@ impl PodmanClient {
|
||||
"nsmode": net_mode
|
||||
},
|
||||
});
|
||||
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: { "aliases": [name] } }),
|
||||
);
|
||||
}
|
||||
|
||||
let result = self
|
||||
.api_request("POST", "libpod/containers/create", Some(body), LONG_TIMEOUT)
|
||||
@@ -601,6 +608,35 @@ impl PodmanClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Registries we ship with as `--tls-verify=false` because they're internal
|
||||
/// HTTP mirrors. Add a host:port here only if it's a controlled mirror that
|
||||
/// the fleet trusts and operators won't ever paste a malicious URL into.
|
||||
const INSECURE_REGISTRY_HOSTS: &[&str] = &["146.59.87.168:3000"];
|
||||
|
||||
pub fn image_uses_insecure_registry(image: &str) -> bool {
|
||||
image
|
||||
.split('/')
|
||||
.next()
|
||||
.is_some_and(|host| INSECURE_REGISTRY_HOSTS.contains(&host))
|
||||
}
|
||||
|
||||
fn podman_network_settings(
|
||||
network: Option<&str>,
|
||||
network_policy: &str,
|
||||
) -> (&'static str, Option<String>) {
|
||||
match network {
|
||||
Some("") => ("bridge", None),
|
||||
Some("host") => ("host", None),
|
||||
Some("bridge") => ("bridge", None),
|
||||
Some("none") => ("none", None),
|
||||
Some("slirp4netns") => ("slirp4netns", None),
|
||||
Some("private") => ("private", None),
|
||||
Some(custom) => ("bridge", Some(custom.to_string())),
|
||||
None if network_policy == "host" => ("host", None),
|
||||
None => ("bridge", None),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────
|
||||
|
||||
fn parse_port_bindings(bindings: &serde_json::Value) -> Vec<String> {
|
||||
@@ -673,6 +709,49 @@ fn parse_memory_limit(limit: &str) -> Option<i64> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn insecure_registry_detection_matches_http_mirrors_only() {
|
||||
assert!(image_uses_insecure_registry(
|
||||
"146.59.87.168:3000/lfg2025/bitcoin-knots:latest"
|
||||
));
|
||||
// The legacy Hetzner mirror at 23.182.128.160 was decommissioned and
|
||||
// is no longer trusted — it must NOT bypass TLS even if a stale
|
||||
// registry config still references it.
|
||||
assert!(!image_uses_insecure_registry(
|
||||
"23.182.128.160:3000/lfg2025/filebrowser:v2.27.0"
|
||||
));
|
||||
assert!(!image_uses_insecure_registry(
|
||||
"git.tx1138.com/lfg2025/bitcoin-knots:latest"
|
||||
));
|
||||
assert!(!image_uses_insecure_registry(
|
||||
"docker.io/library/nginx:latest"
|
||||
));
|
||||
// Spoofing immune: an attacker host that prefixes the trusted IP
|
||||
// string into its own URL still has the attacker host in the
|
||||
// registry-host slot, so it does NOT match.
|
||||
assert!(!image_uses_insecure_registry(
|
||||
"evil.example:80/146.59.87.168:3000/lfg2025/x:latest"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn podman_network_settings_uses_networks_map_for_custom_networks() {
|
||||
assert_eq!(
|
||||
podman_network_settings(Some("archy-net"), "isolated"),
|
||||
("bridge", Some("archy-net".to_string()))
|
||||
);
|
||||
assert_eq!(
|
||||
podman_network_settings(Some("host"), "isolated"),
|
||||
("host", None)
|
||||
);
|
||||
assert_eq!(
|
||||
podman_network_settings(Some(""), "isolated"),
|
||||
("bridge", None)
|
||||
);
|
||||
assert_eq!(podman_network_settings(None, "host"), ("host", None));
|
||||
assert_eq!(podman_network_settings(None, "isolated"), ("bridge", None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_memory_limit_iec_binary_suffixes() {
|
||||
// Kubernetes-style — this is what apps/*/manifest.yml uses.
|
||||
|
||||
@@ -104,7 +104,20 @@ impl ContainerRuntime for PodmanRuntime {
|
||||
}
|
||||
|
||||
async fn list_containers(&self) -> Result<Vec<ContainerStatus>> {
|
||||
self.client.list_containers().await
|
||||
match self.client.list_containers().await {
|
||||
Ok(containers) => Ok(containers),
|
||||
Err(api_err) => {
|
||||
let output = self.podman_cli(&["ps", "-a", "--format", "json"]).await?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(
|
||||
api_err.context(format!("podman ps fallback failed: {}", stderr.trim()))
|
||||
);
|
||||
}
|
||||
parse_podman_ps_json(&output.stdout)
|
||||
.with_context(|| format!("podman API list failed: {api_err}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn image_exists(&self, image_ref: &str) -> Result<bool> {
|
||||
@@ -147,6 +160,83 @@ impl ContainerRuntime for PodmanRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_podman_ps_json(stdout: &[u8]) -> Result<Vec<ContainerStatus>> {
|
||||
let text = String::from_utf8_lossy(stdout);
|
||||
if text.trim().is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let containers: Vec<serde_json::Value> = serde_json::from_str(&text)?;
|
||||
Ok(containers
|
||||
.into_iter()
|
||||
.map(|c| {
|
||||
let name = c
|
||||
.get("Names")
|
||||
.and_then(|v| v.as_array())
|
||||
.and_then(|a| a.first())
|
||||
.and_then(|v| v.as_str())
|
||||
.or_else(|| c.get("Names").and_then(|v| v.as_str()))
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let status = c.get("Status").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let state = c.get("State").and_then(|v| v.as_str()).unwrap_or("unknown");
|
||||
ContainerStatus {
|
||||
id: c
|
||||
.get("Id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
name: name.clone(),
|
||||
state: ContainerState::from(state),
|
||||
health: parse_health_from_status(status),
|
||||
exit_code: c.get("ExitCode").and_then(|v| v.as_i64()).map(|c| c as i32),
|
||||
started_at: c
|
||||
.get("StartedAt")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string()),
|
||||
image: c
|
||||
.get("Image")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
created: c
|
||||
.get("Created")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
ports: parse_podman_ps_ports(c.get("Ports")),
|
||||
lan_address: PodmanClient::lan_address_for(&name),
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn parse_podman_ps_ports(ports: Option<&serde_json::Value>) -> Vec<String> {
|
||||
ports
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|ports| {
|
||||
ports
|
||||
.iter()
|
||||
.filter_map(|port| {
|
||||
let host = port.get("host_port").and_then(|v| v.as_u64())?;
|
||||
let container = port.get("container_port").and_then(|v| v.as_u64())?;
|
||||
let proto = port
|
||||
.get("protocol")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("tcp");
|
||||
Some(format!("0.0.0.0:{host}->{container}/{proto}"))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn parse_health_from_status(status: &str) -> Option<String> {
|
||||
let start = status.rfind('(')?;
|
||||
let end = status.rfind(')')?;
|
||||
(start < end).then(|| status[start + 1..end].to_string())
|
||||
}
|
||||
|
||||
/// Build the argv for `podman build` from a BuildConfig.
|
||||
///
|
||||
/// Extracted so it can be unit-tested without actually invoking podman.
|
||||
@@ -646,4 +736,36 @@ mod tests {
|
||||
let args = build_args_for_podman(&c);
|
||||
assert_eq!(args.last().unwrap(), "/final/context");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_podman_ps_json_handles_cli_output() {
|
||||
let stdout = br#"[
|
||||
{
|
||||
"Id": "abc123",
|
||||
"Names": ["mempool"],
|
||||
"Image": "docker.io/mempool/frontend:latest",
|
||||
"State": "running",
|
||||
"Status": "Up 2 minutes (healthy)",
|
||||
"Created": "2026-05-03T00:00:00Z",
|
||||
"StartedAt": "2026-05-03T00:01:00Z",
|
||||
"ExitCode": 0,
|
||||
"Ports": [
|
||||
{
|
||||
"host_port": 4080,
|
||||
"container_port": 8080,
|
||||
"protocol": "tcp"
|
||||
}
|
||||
]
|
||||
}
|
||||
]"#;
|
||||
|
||||
let containers = parse_podman_ps_json(stdout).unwrap();
|
||||
assert_eq!(containers.len(), 1);
|
||||
assert_eq!(containers[0].id, "abc123");
|
||||
assert_eq!(containers[0].name, "mempool");
|
||||
assert_eq!(containers[0].state, ContainerState::Running);
|
||||
assert_eq!(containers[0].health.as_deref(), Some("healthy"));
|
||||
assert_eq!(containers[0].exit_code, Some(0));
|
||||
assert_eq!(containers[0].ports, vec!["0.0.0.0:4080->8080/tcp"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -606,7 +606,8 @@
|
||||
console.log('[Bitcoin UI] Script loaded, initializing...');
|
||||
|
||||
// RPC Configuration - Use local Nginx proxy within container
|
||||
const RPC_ENDPOINT = '/bitcoin-rpc/';
|
||||
const RPC_ENDPOINT = 'bitcoin-rpc/';
|
||||
const STATUS_ENDPOINT = 'bitcoin-status';
|
||||
console.log('[Bitcoin UI] RPC Endpoint:', RPC_ENDPOINT);
|
||||
|
||||
// Make RPC call to Bitcoin node via local proxy
|
||||
@@ -645,6 +646,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchBitcoinStatus() {
|
||||
const response = await fetch(STATUS_ENDPOINT, { cache: 'no-store' });
|
||||
if (!response.ok) {
|
||||
throw new Error(`status HTTP ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Implementation branding — detected from getnetworkinfo.subversion.
|
||||
// Bitcoin Knots identifies as "/Satoshi:<ver>/Knots:<date>/", Bitcoin Core as "/Satoshi:<ver>/".
|
||||
let brandingApplied = false;
|
||||
@@ -672,22 +681,62 @@
|
||||
|
||||
// Track last block count for animations
|
||||
let lastBlockCount = 0;
|
||||
let consecutiveRpcFailures = 0;
|
||||
let lastSuccessfulUpdateAt = 0;
|
||||
|
||||
function formatPercent(value) {
|
||||
if (!Number.isFinite(value) || value <= 0) return '0.00';
|
||||
if (value < 0.01) return '<0.01';
|
||||
return value.toFixed(2);
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return null;
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let value = bytes;
|
||||
let unit = 0;
|
||||
while (value >= 1000 && unit < units.length - 1) {
|
||||
value /= 1000;
|
||||
unit += 1;
|
||||
}
|
||||
return `${value.toFixed(unit >= 3 ? 1 : 0)} ${units[unit]}`;
|
||||
}
|
||||
|
||||
// Update blockchain info
|
||||
async function updateBlockchainInfo() {
|
||||
console.log('[Bitcoin UI] updateBlockchainInfo() called');
|
||||
try {
|
||||
const blockchainInfo = await callRPC('getblockchaininfo');
|
||||
const status = await fetchBitcoinStatus();
|
||||
const blockchainInfo = status.blockchain_info;
|
||||
console.log('[Bitcoin UI] blockchainInfo:', blockchainInfo);
|
||||
|
||||
if (!blockchainInfo) {
|
||||
console.error('[Bitcoin UI] No blockchain info received');
|
||||
document.getElementById('syncStatusText').textContent = 'Unable to connect to Bitcoin node';
|
||||
document.getElementById('syncStatusText').className = 'text-red-400 text-sm';
|
||||
consecutiveRpcFailures += 1;
|
||||
const syncStatusText = document.getElementById('syncStatusText');
|
||||
const syncIcon = document.getElementById('syncIcon');
|
||||
if (syncStatusText) {
|
||||
if (status.stale) {
|
||||
syncStatusText.textContent = status.error || 'Bitcoin node is reconnecting... showing last known values';
|
||||
syncStatusText.className = 'text-yellow-300 text-sm font-medium';
|
||||
} else if (consecutiveRpcFailures < 6) {
|
||||
syncStatusText.textContent = status.error || 'Connecting to Bitcoin node...';
|
||||
syncStatusText.className = 'text-yellow-300 text-sm font-medium';
|
||||
} else {
|
||||
syncStatusText.textContent = status.error || 'Bitcoin node is not responding yet';
|
||||
syncStatusText.className = 'text-red-400 text-sm font-medium';
|
||||
}
|
||||
}
|
||||
if (syncIcon) {
|
||||
syncIcon.classList.add('animate-spin-slow');
|
||||
syncIcon.classList.remove('text-green-500');
|
||||
}
|
||||
return;
|
||||
}
|
||||
consecutiveRpcFailures = 0;
|
||||
lastSuccessfulUpdateAt = Date.now();
|
||||
|
||||
const networkInfo = await callRPC('getnetworkinfo');
|
||||
const networkInfo = status.network_info;
|
||||
|
||||
applyImplBranding(networkInfo && networkInfo.subversion);
|
||||
|
||||
@@ -743,44 +792,51 @@
|
||||
}
|
||||
|
||||
// Populate Settings — Transaction Index, ZMQ, RPC (fire-and-forget)
|
||||
(async () => {
|
||||
const txIndexEl = document.getElementById('settingsTxIndex');
|
||||
if (txIndexEl) {
|
||||
const idx = await callRPC('getindexinfo');
|
||||
if (idx && typeof idx === 'object') {
|
||||
const names = Object.keys(idx);
|
||||
txIndexEl.textContent = names.length
|
||||
? `Enabled: ${names.join(', ')}`
|
||||
: 'Disabled';
|
||||
} else {
|
||||
txIndexEl.textContent = 'Disabled';
|
||||
}
|
||||
const txIndexEl = document.getElementById('settingsTxIndex');
|
||||
if (txIndexEl) {
|
||||
const idx = status.index_info;
|
||||
if (idx && typeof idx === 'object') {
|
||||
const names = Object.keys(idx);
|
||||
txIndexEl.textContent = names.length
|
||||
? `Enabled: ${names.join(', ')}`
|
||||
: 'Disabled';
|
||||
} else {
|
||||
txIndexEl.textContent = 'Unavailable while node starts';
|
||||
}
|
||||
const zmqEl = document.getElementById('settingsZmq');
|
||||
if (zmqEl) {
|
||||
const zmq = await callRPC('getzmqnotifications');
|
||||
if (Array.isArray(zmq) && zmq.length) {
|
||||
zmqEl.textContent = zmq.map(z => `${z.type}@${z.address}`).join('; ');
|
||||
} else {
|
||||
zmqEl.textContent = 'Not enabled';
|
||||
}
|
||||
}
|
||||
const zmqEl = document.getElementById('settingsZmq');
|
||||
if (zmqEl) {
|
||||
const zmq = status.zmq_notifications;
|
||||
if (Array.isArray(zmq) && zmq.length) {
|
||||
zmqEl.textContent = zmq.map(z => `${z.type}@${z.address}`).join('; ');
|
||||
} else if (Array.isArray(zmq)) {
|
||||
zmqEl.textContent = 'Not enabled';
|
||||
} else {
|
||||
zmqEl.textContent = 'Unavailable while node starts';
|
||||
}
|
||||
const rpcEl = document.getElementById('settingsRpc');
|
||||
if (rpcEl && networkInfo) {
|
||||
const port = chain === 'main' ? 8332 : (chain === 'test' ? 18332 : (chain === 'signet' ? 38332 : 18443));
|
||||
rpcEl.textContent = `Reachable on port ${port}`;
|
||||
}
|
||||
})();
|
||||
}
|
||||
const rpcEl = document.getElementById('settingsRpc');
|
||||
if (rpcEl) {
|
||||
const port = chain === 'main' ? 8332 : (chain === 'test' ? 18332 : (chain === 'signet' ? 38332 : 18443));
|
||||
rpcEl.textContent = status.stale
|
||||
? `Reconnecting on port ${port}`
|
||||
: `Reachable on port ${port}`;
|
||||
}
|
||||
|
||||
// Update sync status
|
||||
const blocks = blockchainInfo.blocks || 0;
|
||||
const headers = blockchainInfo.headers || 0;
|
||||
const verificationProgress = blockchainInfo.verificationprogress || 0;
|
||||
const isSynced = blocks >= headers - 1;
|
||||
const initialBlockDownload = blockchainInfo.initialblockdownload === true;
|
||||
const isSynced = headers > 0 && blocks >= headers - 1 && !initialBlockDownload;
|
||||
const diskSize = formatBytes(blockchainInfo.size_on_disk || 0);
|
||||
const appearsToBeReindexing = initialBlockDownload && blocks === 0 && headers > 0 && (blockchainInfo.size_on_disk || 0) > 1024 * 1024 * 1024;
|
||||
|
||||
// Calculate actual sync percentage based on blocks/headers
|
||||
const actualSyncPercentage = headers > 0 ? ((blocks / headers) * 100).toFixed(2) : '0.00';
|
||||
const verificationPercentage = (verificationProgress * 100).toFixed(2);
|
||||
const actualSyncValue = headers > 0 ? (blocks / headers) * 100 : 0;
|
||||
const actualSyncPercentage = formatPercent(actualSyncValue);
|
||||
const progressWidth = Math.max(0, Math.min(100, actualSyncValue));
|
||||
const verificationPercentage = formatPercent(verificationProgress * 100);
|
||||
|
||||
// Animate block count if it changed
|
||||
const currentHeightElem = document.getElementById('currentHeight');
|
||||
@@ -795,16 +851,27 @@
|
||||
document.getElementById('headers').textContent = headers.toLocaleString();
|
||||
document.getElementById('verificationProgress').textContent = `${verificationPercentage}%`;
|
||||
document.getElementById('syncPercentage').textContent = `${actualSyncPercentage}%`;
|
||||
document.getElementById('currentBlock').textContent = `Block ${blocks.toLocaleString()}`;
|
||||
document.getElementById('syncProgressBar').style.width = `${actualSyncPercentage}%`;
|
||||
document.getElementById('currentBlock').textContent = appearsToBeReindexing
|
||||
? 'Reindexing from disk'
|
||||
: `Block ${blocks.toLocaleString()}`;
|
||||
document.getElementById('syncProgressBar').style.width = `${progressWidth}%`;
|
||||
|
||||
// Update sync status text and icon
|
||||
const syncStatusText = document.getElementById('syncStatusText');
|
||||
const syncIcon = document.getElementById('syncIcon');
|
||||
|
||||
if (isSynced) {
|
||||
syncStatusText.textContent = '✓ Fully synchronized with the network';
|
||||
syncStatusText.className = 'text-green-400 text-sm font-medium';
|
||||
if (appearsToBeReindexing) {
|
||||
syncStatusText.textContent = `Reindexing local block files${diskSize ? ` (${diskSize} on disk)` : ''}`;
|
||||
syncStatusText.className = 'text-orange-400 text-sm font-medium';
|
||||
if (syncIcon) {
|
||||
syncIcon.classList.add('animate-spin-slow');
|
||||
syncIcon.classList.remove('text-green-500');
|
||||
}
|
||||
} else if (isSynced) {
|
||||
syncStatusText.textContent = status.stale
|
||||
? 'Bitcoin node is reconnecting... showing last known synchronized state'
|
||||
: '✓ Fully synchronized with the network';
|
||||
syncStatusText.className = status.stale ? 'text-yellow-300 text-sm font-medium' : 'text-green-400 text-sm font-medium';
|
||||
// Stop spinning when synced
|
||||
if (syncIcon) {
|
||||
syncIcon.classList.remove('animate-spin-slow');
|
||||
@@ -812,8 +879,12 @@
|
||||
}
|
||||
} else {
|
||||
const remaining = headers - blocks;
|
||||
syncStatusText.textContent = `Syncing... ${remaining.toLocaleString()} blocks remaining`;
|
||||
syncStatusText.className = 'text-orange-400 text-sm font-medium';
|
||||
syncStatusText.textContent = status.stale
|
||||
? 'Bitcoin node is reconnecting... showing last known sync state'
|
||||
: initialBlockDownload
|
||||
? `Initial block download... ${remaining.toLocaleString()} blocks remaining`
|
||||
: `Syncing... ${remaining.toLocaleString()} blocks remaining`;
|
||||
syncStatusText.className = status.stale ? 'text-yellow-300 text-sm font-medium' : 'text-orange-400 text-sm font-medium';
|
||||
// Keep spinning while syncing
|
||||
if (syncIcon) {
|
||||
syncIcon.classList.add('animate-spin-slow');
|
||||
@@ -834,8 +905,15 @@
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to update blockchain info:', error);
|
||||
document.getElementById('syncStatusText').textContent = 'Unable to fetch blockchain data';
|
||||
document.getElementById('syncStatusText').className = 'text-red-400 text-sm';
|
||||
consecutiveRpcFailures += 1;
|
||||
const syncStatusText = document.getElementById('syncStatusText');
|
||||
if (syncStatusText) {
|
||||
const hasRecentData = lastSuccessfulUpdateAt > 0 && Date.now() - lastSuccessfulUpdateAt < 120000;
|
||||
syncStatusText.textContent = hasRecentData
|
||||
? 'Bitcoin status bridge is reconnecting... keeping last known values'
|
||||
: 'Connecting to Bitcoin status bridge...';
|
||||
syncStatusText.className = 'text-yellow-300 text-sm font-medium';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@
|
||||
@media (min-width: 768px) {
|
||||
.md-flex-row { flex-direction: row; }
|
||||
.md-grid-cols-4 { grid-template-columns: repeat(4, 1fr); }
|
||||
.md-grid-cols-5 { grid-template-columns: repeat(5, 1fr); }
|
||||
}
|
||||
|
||||
/* Connection details */
|
||||
@@ -147,13 +148,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 md-grid-cols-4 gap-3">
|
||||
<div class="grid grid-cols-2 md-grid-cols-5 gap-3">
|
||||
<div class="info-card">
|
||||
<p class="text-xs text-white-60 mb-1">Indexed Height</p>
|
||||
<p class="text-xs text-white-60 mb-1">Electrum Indexed</p>
|
||||
<p class="text-lg font-semibold text-white" id="indexedHeight">-</p>
|
||||
</div>
|
||||
<div class="info-card">
|
||||
<p class="text-xs text-white-60 mb-1">Network Height</p>
|
||||
<p class="text-xs text-white-60 mb-1">Bitcoin Node</p>
|
||||
<p class="text-lg font-semibold text-white" id="bitcoinHeight">-</p>
|
||||
</div>
|
||||
<div class="info-card">
|
||||
<p class="text-xs text-white-60 mb-1">Known Headers</p>
|
||||
<p class="text-lg font-semibold text-white" id="networkHeight">-</p>
|
||||
</div>
|
||||
<div class="info-card">
|
||||
@@ -370,15 +375,36 @@
|
||||
}
|
||||
|
||||
var indexedH = data.indexed_height || 0;
|
||||
var networkH = data.network_height || 0;
|
||||
var bitcoinH = data.bitcoin_height || 0;
|
||||
var reportedNetworkH = data.network_height || 0;
|
||||
var knownHeaderH = Math.max(reportedNetworkH, indexedH, bitcoinH);
|
||||
var targetH = bitcoinH > 0 ? bitcoinH : knownHeaderH;
|
||||
var pct = data.progress_pct || 0;
|
||||
var hasIndexedHeight = indexedH > 0 || data.stale;
|
||||
var indexedLabel = hasIndexedHeight
|
||||
? indexedH.toLocaleString()
|
||||
: (data.status === 'indexing' ? 'Pending' : '-');
|
||||
var currentBlockLabel;
|
||||
if (hasIndexedHeight && bitcoinH > 0 && indexedH > bitcoinH) {
|
||||
currentBlockLabel = 'Bitcoin node ' + bitcoinH.toLocaleString()
|
||||
+ (knownHeaderH > 0 ? ' of known headers ' + knownHeaderH.toLocaleString() : '')
|
||||
+ '; Electrum index ' + indexedH.toLocaleString();
|
||||
} else if (hasIndexedHeight) {
|
||||
currentBlockLabel = 'Indexed ' + indexedH.toLocaleString() + ' of '
|
||||
+ (targetH > 0 ? targetH.toLocaleString() : 'Bitcoin node height');
|
||||
} else {
|
||||
currentBlockLabel = data.index_size
|
||||
? 'Index building from disk (' + data.index_size + ')'
|
||||
: 'Waiting for Electrum index height';
|
||||
}
|
||||
|
||||
document.getElementById('indexedHeight').textContent = indexedH > 0 ? indexedH.toLocaleString() : (data.status === 'indexing' ? 'Building...' : '-');
|
||||
document.getElementById('networkHeight').textContent = networkH > 0 ? networkH.toLocaleString() : '-';
|
||||
document.getElementById('indexedHeight').textContent = indexedLabel;
|
||||
document.getElementById('bitcoinHeight').textContent = bitcoinH > 0 ? bitcoinH.toLocaleString() : 'Checking...';
|
||||
document.getElementById('networkHeight').textContent = knownHeaderH > 0 ? knownHeaderH.toLocaleString() : 'Checking...';
|
||||
document.getElementById('indexSize').textContent = data.index_size || '-';
|
||||
document.getElementById('progressPct').textContent = pct > 0 ? pct.toFixed(1) + '%' : '-';
|
||||
document.getElementById('currentBlock').textContent = indexedH > 0 ? 'Block ' + indexedH.toLocaleString() : (data.index_size ? 'Index: ' + data.index_size : 'Block 0');
|
||||
document.getElementById('syncPercentage').textContent = pct > 0 ? pct.toFixed(1) + '%' : '0%';
|
||||
document.getElementById('progressPct').textContent = (knownHeaderH > 0 || pct > 0) ? pct.toFixed(1) + '%' : '-';
|
||||
document.getElementById('currentBlock').textContent = currentBlockLabel;
|
||||
document.getElementById('syncPercentage').textContent = (knownHeaderH > 0 || pct > 0) ? pct.toFixed(1) + '%' : '0%';
|
||||
document.getElementById('syncProgressBar').style.width = Math.max(pct, 0.5) + '%';
|
||||
|
||||
var statusTextEl = document.getElementById('syncStatusText');
|
||||
@@ -389,14 +415,16 @@
|
||||
statusTextEl.textContent = data.error || 'Starting up...';
|
||||
statusTextEl.style.color = '#fbbf24';
|
||||
statusDot.className = 'status-dot bg-yellow animate-pulse';
|
||||
document.getElementById('statusText').textContent = 'Starting';
|
||||
document.getElementById('statusText').textContent = data.status === 'waiting' ? 'Waiting' : 'Starting';
|
||||
syncIcon.classList.add('animate-spin-slow');
|
||||
document.getElementById('connSubtitle').textContent = 'Connections will be available once ElectrumX has completed syncing.';
|
||||
} else if (data.status === 'indexing') {
|
||||
statusTextEl.textContent = data.error || 'Building index...';
|
||||
statusTextEl.textContent = data.stale
|
||||
? (data.error || 'ElectrumX is reconnecting; showing last known indexed height.')
|
||||
: (data.error || 'Building index. Indexed height will appear when Electrum RPC is ready.');
|
||||
statusTextEl.style.color = '#fbbf24';
|
||||
statusDot.className = 'status-dot bg-amber animate-pulse';
|
||||
document.getElementById('statusText').textContent = 'Indexing';
|
||||
document.getElementById('statusText').textContent = data.stale ? 'Reconnecting' : 'Indexing';
|
||||
syncIcon.classList.add('animate-spin-slow');
|
||||
document.getElementById('connSubtitle').textContent = 'Connections will be available once ElectrumX has completed syncing.';
|
||||
} else if (data.status === 'error') {
|
||||
@@ -414,8 +442,10 @@
|
||||
syncIcon.style.color = '#4ade80';
|
||||
document.getElementById('connSubtitle').textContent = 'Use the following details to connect your wallet or application to ElectrumX.';
|
||||
} else {
|
||||
var remaining = networkH - indexedH;
|
||||
statusTextEl.textContent = 'Syncing... ' + remaining.toLocaleString() + ' blocks remaining';
|
||||
var remaining = Math.max(targetH - indexedH, 0);
|
||||
statusTextEl.textContent = data.error || (targetH > 0
|
||||
? 'Syncing... ' + remaining.toLocaleString() + ' blocks remaining'
|
||||
: 'Waiting for Bitcoin network height...');
|
||||
statusTextEl.style.color = '#fb923c';
|
||||
statusDot.className = 'status-dot bg-yellow';
|
||||
document.getElementById('statusText').textContent = 'Syncing';
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -56,7 +56,6 @@ listen=1
|
||||
# RPC
|
||||
rpcuser=$RPC_USER
|
||||
rpcpassword=$RPC_PASS
|
||||
rpcbind=0.0.0.0
|
||||
rpcallowip=10.0.0.0/8
|
||||
rpcallowip=172.16.0.0/12
|
||||
rpcallowip=192.168.0.0/16
|
||||
|
||||
@@ -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" "$@"
|
||||
@@ -7,6 +7,6 @@ Type=oneshot
|
||||
# Runs as root: needs to kill orphaned conmon processes, fix permissions
|
||||
User=root
|
||||
ExecStart=/home/archipelago/archy/scripts/container-doctor.sh --local
|
||||
TimeoutStartSec=120
|
||||
TimeoutStartSec=300
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
Description=Archipelago container doctor (periodic)
|
||||
|
||||
[Timer]
|
||||
# First run 5 minutes after boot, then every 30 minutes
|
||||
OnBootSec=5min
|
||||
OnUnitActiveSec=30min
|
||||
# First run 2 minutes after boot, then every 5 minutes. The doctor is
|
||||
# idempotent and exits quickly when no drift exists; this keeps vanished
|
||||
# rootless port listeners and stopped containers from remaining broken.
|
||||
OnBootSec=2min
|
||||
OnUnitActiveSec=5min
|
||||
# Jitter to avoid load spikes
|
||||
RandomizedDelaySec=60
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ Environment="ARCHIPELAGO_BIND=127.0.0.1:5678"
|
||||
# DEV_MODE disabled in production — enabled via override.conf on dev servers
|
||||
Environment="XDG_RUNTIME_DIR=/run/user/1000"
|
||||
# + prefix runs these as root (needed for chown/mkdir outside ReadWritePaths)
|
||||
ExecStartPre=+/bin/bash -c 'mkdir -p /run/user/1000 && chown archipelago:archipelago /run/user/1000 && chmod 700 /run/user/1000'
|
||||
ExecStartPre=+/bin/bash -c 'mkdir -p /run/user/1000 /run/containers /var/lib/containers && chown archipelago:archipelago /run/user/1000 && chmod 700 /run/user/1000'
|
||||
ExecStartPre=+/bin/bash -c 'mkdir -p /var/lib/archipelago && chown archipelago:archipelago /var/lib/archipelago && echo "ARCHIPELAGO_HOST_IP=$(hostname -I 2>/dev/null | awk "{print $$1}")" > /var/lib/archipelago/host-ip.env && chown archipelago:archipelago /var/lib/archipelago/host-ip.env'
|
||||
ExecStart=/usr/local/bin/archipelago
|
||||
Restart=on-failure
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -102,7 +102,7 @@ location /app/endurain/ {
|
||||
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
|
||||
}
|
||||
location /app/lnd/ {
|
||||
proxy_pass http://127.0.0.1:8081/;
|
||||
proxy_pass http://127.0.0.1:18083/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ dist-ssr
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
._*
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"version": "1.7.44-alpha",
|
||||
"version": "1.7.53-alpha",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "neode-ui",
|
||||
"version": "1.7.44-alpha",
|
||||
"version": "1.7.53-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": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"private": true,
|
||||
"version": "1.7.45-alpha",
|
||||
"version": "1.7.53-alpha",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "./start-dev.sh",
|
||||
|
||||
@@ -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',
|
||||
|
||||
|
After Width: | Height: | Size: 408 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 408 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 241 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 241 KiB |
@@ -26,12 +26,12 @@
|
||||
"id": "bitcoin-core",
|
||||
"title": "Bitcoin Core",
|
||||
"version": "28.4",
|
||||
"description": "Reference implementation of the Bitcoin protocol. Run a full node validating and relaying blocks.",
|
||||
"description": "Reference Bitcoin node implementation. Alternative to Bitcoin Knots; uninstall Knots before switching.",
|
||||
"icon": "/assets/img/app-icons/bitcoin-core.svg",
|
||||
"author": "Bitcoin Core contributors",
|
||||
"category": "money",
|
||||
"tier": "optional",
|
||||
"dockerImage": "docker.io/bitcoin/bitcoin:28.4",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/bitcoin:28.4",
|
||||
"repoUrl": "https://github.com/bitcoin/bitcoin"
|
||||
},
|
||||
{
|
||||
@@ -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",
|
||||
@@ -125,7 +125,7 @@
|
||||
"icon": "/assets/img/app-icons/gitea.svg",
|
||||
"author": "Gitea",
|
||||
"category": "development",
|
||||
"dockerImage": "docker.io/gitea/gitea:1.23",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/gitea:1.23",
|
||||
"repoUrl": "https://gitea.com"
|
||||
},
|
||||
{
|
||||
@@ -263,7 +263,7 @@
|
||||
"icon": "/assets/img/app-icons/nextcloud.webp",
|
||||
"author": "Nextcloud",
|
||||
"category": "data",
|
||||
"dockerImage": "docker.io/nextcloud:28",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/nextcloud:28",
|
||||
"repoUrl": "https://github.com/nextcloud/server"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ const NEW_TAB_PORTS = new Set([
|
||||
'8085', // Nextcloud — X-Frame-Options: SAMEORIGIN
|
||||
'3002', // Uptime Kuma — X-Frame-Options: SAMEORIGIN
|
||||
'9001', // Penpot — not reachable
|
||||
// IndeedHub (7777) uses proxy path for NIP-07 nostr-provider.js — NOT new tab
|
||||
// Port 7777 is the Nostr relay; IndeeHub's web UI is exposed on 7778.
|
||||
])
|
||||
|
||||
const NEW_TAB_APP_IDS = new Set([
|
||||
@@ -34,6 +34,7 @@ function mustOpenInNewTab(url: string): boolean {
|
||||
function inferAppIdFromTitle(title?: string): string | null {
|
||||
const t = (title || '').toLowerCase()
|
||||
if (!t) return null
|
||||
if (t.includes('indeehub') || t.includes('indeedhub')) return 'indeedhub'
|
||||
if ((t.includes('uptime') && t.includes('kuma')) || t.includes('uptime-kuma')) return 'uptime-kuma'
|
||||
if ((t.includes('nginx') && t.includes('proxy') && t.includes('manager')) || t.includes('nginx-proxy-manager')) return 'nginx-proxy-manager'
|
||||
if (t.includes('gitea')) return 'gitea'
|
||||
@@ -43,10 +44,19 @@ 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}`
|
||||
|
||||
if (sameHost && appIdHint === 'indeedhub' && u.port === '7777') {
|
||||
return rebuilt('7778')
|
||||
}
|
||||
|
||||
if (sameHost && appIdHint === 'uptime-kuma' && u.port === '3001') {
|
||||
return rebuilt('3002')
|
||||
}
|
||||
@@ -55,7 +65,7 @@ function normalizeLaunchUrl(urlStr: string, appIdHint?: string | null): string {
|
||||
return rebuilt('81')
|
||||
}
|
||||
|
||||
return urlStr
|
||||
return rewrittenLocalhost ? u.toString() : urlStr
|
||||
} catch {
|
||||
return urlStr
|
||||
}
|
||||
@@ -68,7 +78,7 @@ const PORT_TO_APP_ID: Record<string, string> = {
|
||||
'3000': 'grafana',
|
||||
'3002': 'uptime-kuma',
|
||||
'8080': 'endurain',
|
||||
'8081': 'lnd',
|
||||
'18083': 'lnd',
|
||||
'8082': 'vaultwarden',
|
||||
'8083': 'filebrowser',
|
||||
'8085': 'nextcloud',
|
||||
@@ -87,7 +97,7 @@ const PORT_TO_APP_ID: Record<string, string> = {
|
||||
'8175': 'fedimint',
|
||||
'8176': 'fedimint-gateway',
|
||||
'3100': 'dwn',
|
||||
'7777': 'indeedhub',
|
||||
'7778': 'indeedhub',
|
||||
'50002': 'electrumx',
|
||||
'3010': 'thunderhub',
|
||||
}
|
||||
@@ -138,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
|
||||
|
||||
@@ -23,7 +23,7 @@ const CONTAINER_NAME_MAP: Record<string, string[]> = {
|
||||
'bitcoin-knots': ['bitcoin-knots', 'bitcoin-ui'],
|
||||
'lnd': ['lnd', 'archy-lnd-ui'],
|
||||
'btcpay-server': ['btcpay-server'],
|
||||
'mempool': ['archy-mempool-web'],
|
||||
'mempool': ['mempool', 'archy-mempool-web'],
|
||||
'electrumx': ['archy-electrs-ui', 'electrumx', 'mempool-electrs'],
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ export const BUNDLED_APPS: BundledApp[] = [
|
||||
image: 'docker.io/lightninglabs/lnd:v0.18.4-beta',
|
||||
description: 'Lightning Network Daemon for fast Bitcoin payments',
|
||||
icon: '⚡',
|
||||
ports: [{ host: 8081, container: 80 }],
|
||||
ports: [{ host: 18083, container: 80 }],
|
||||
volumes: [{ host: '/var/lib/archipelago/lnd', container: '/root/.lnd' }],
|
||||
category: 'lightning',
|
||||
},
|
||||
|
||||
@@ -63,18 +63,44 @@ export const useServerStore = defineStore('server', () => {
|
||||
if (progress.phase) {
|
||||
const info = PHASE_INFO[progress.phase]
|
||||
if (info) {
|
||||
// Within the PullingImage band (20→70%), interpolate the
|
||||
// bar based on how many images / bytes have landed so far.
|
||||
// Without this, multi-container stacks (indeedhub: 7,
|
||||
// mempool: 3, btcpay: 4) just sit at 20% for the entire
|
||||
// pull duration — exactly what the user reported as
|
||||
// "Downloading sticks at 20% mostly". X-of-N progress
|
||||
// comes from set_install_progress(i, n) in stacks.rs.
|
||||
let bandProgress = info.progress
|
||||
if (progress.phase === 'pulling-image' && progress.size > 0) {
|
||||
const fraction = Math.min(progress.downloaded / progress.size, 1)
|
||||
// PullingImage band: 20% → 70%, so 50pp to interpolate over.
|
||||
bandProgress = 20 + Math.round(fraction * 50)
|
||||
}
|
||||
// Only advance forward — never let the bar step backward
|
||||
// between patches (can happen briefly during scan merges).
|
||||
const nextProgress = Math.max(current.progress, info.progress)
|
||||
const nextProgress = Math.max(current.progress, bandProgress)
|
||||
// Show explicit message when set (e.g. install-fail descriptions
|
||||
// surfaced via install_progress.message) — otherwise PHASE_INFO label.
|
||||
const label = progress.message || info.message
|
||||
installingApps.value.set(appId, {
|
||||
...current,
|
||||
status: info.status,
|
||||
progress: nextProgress,
|
||||
message: info.message,
|
||||
message: label,
|
||||
})
|
||||
continue
|
||||
}
|
||||
}
|
||||
// No phase but message is set (install-fail path) — show the message
|
||||
// even if PHASE_INFO doesn't apply. Status stays whatever the watcher
|
||||
// currently has.
|
||||
if (progress.message) {
|
||||
installingApps.value.set(appId, {
|
||||
...current,
|
||||
message: progress.message,
|
||||
})
|
||||
continue
|
||||
}
|
||||
// Fallback: byte counters (rare — podman usually doesn't
|
||||
// emit parseable progress on a piped stderr).
|
||||
const pct = progress.size > 0 ? Math.round((progress.downloaded / progress.size) * 100) : 0
|
||||
|
||||
@@ -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); }
|
||||
|
||||
|
||||
@@ -166,6 +166,9 @@ export interface InstallProgress {
|
||||
* counters — podman pull doesn't emit parseable progress when
|
||||
* stderr is piped, so byte counters are usually (0,0). */
|
||||
phase?: InstallPhase
|
||||
/** Optional explicit message — surfaced on install failures so the
|
||||
* UI can show what went wrong instead of silently removing the card. */
|
||||
message?: string
|
||||
}
|
||||
|
||||
// RPC Request/Response types
|
||||
|
||||
@@ -504,7 +504,7 @@ export const dummyApps: Record<string, PackageDataEntry> = {
|
||||
'interface-addresses': {
|
||||
main: {
|
||||
'tor-address': '',
|
||||
'lan-address': 'http://localhost:8190'
|
||||
'lan-address': 'http://localhost:7778'
|
||||
}
|
||||
},
|
||||
status: ServiceStatus.Running
|
||||
@@ -749,4 +749,3 @@ export const dummyApps: Record<string, PackageDataEntry> = {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -58,6 +58,11 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="app-session-bar-btn" aria-label="Refresh" @click="refresh">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" :class="{ 'animate-spin': isRefreshing }">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v6h6M20 20v-6h-6M5.64 15.36A8 8 0 0018.36 18M18.36 8.64A8 8 0 005.64 6" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="app-session-bar-btn" aria-label="Open in new tab" @click="openNewTab">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
@@ -139,9 +144,7 @@ const appId = computed(() => {
|
||||
|
||||
const appTitle = computed(() => resolveAppTitle(appId.value))
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
// On mobile (Android WebView), all apps load in the iframe — X-Frame-Options
|
||||
// doesn't apply since the WebView is the top-level browsing context.
|
||||
const mustOpenNewTab = computed(() => isMobile ? false : NEW_TAB_APPS.has(appId.value))
|
||||
const mustOpenNewTab = computed(() => NEW_TAB_APPS.has(appId.value))
|
||||
|
||||
const appUrl = computed(() => {
|
||||
return resolveAppUrl(appId.value, route.query.path as string | undefined)
|
||||
@@ -501,6 +504,17 @@ onBeforeUnmount(() => {
|
||||
|
||||
/* Mobile: full-bleed app sessions — no border, no radius, no shadow */
|
||||
@media (max-width: 767px) {
|
||||
.app-session-root {
|
||||
height: 100%;
|
||||
}
|
||||
.app-session-inline {
|
||||
height: 100%;
|
||||
}
|
||||
.app-session-overlay,
|
||||
.app-session-fullscreen {
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
}
|
||||
.app-session-panel.glass-card {
|
||||
border: none !important;
|
||||
border-radius: 0 !important;
|
||||
@@ -511,14 +525,11 @@ onBeforeUnmount(() => {
|
||||
backdrop-filter: none;
|
||||
background: black;
|
||||
}
|
||||
/* Iframe frame: push content below status bar on mobile */
|
||||
.app-session-frame-safe {
|
||||
padding-top: var(--safe-area-top, env(safe-area-inset-top, 0px));
|
||||
}
|
||||
/* Iframe within padded container: fill remaining space */
|
||||
.app-session-frame-safe iframe {
|
||||
top: var(--safe-area-top, env(safe-area-inset-top, 0px));
|
||||
height: calc(100% - var(--safe-area-top, env(safe-area-inset-top, 0px)));
|
||||
flex: none !important;
|
||||
height: calc(100vh - var(--app-session-mobile-bar-height, 84px));
|
||||
height: calc(100dvh - var(--app-session-mobile-bar-height, 84px));
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -529,24 +540,38 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
.app-session-mobile-bar {
|
||||
display: flex;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 2600;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
padding: 12px 16px;
|
||||
padding-bottom: calc(12px + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)));
|
||||
min-height: var(--app-session-mobile-bar-height, 84px);
|
||||
padding: 10px 16px;
|
||||
padding-bottom: calc(10px + max(var(--safe-area-bottom, 0px), env(safe-area-inset-bottom, 0px), 10px));
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
backdrop-filter: blur(18px);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.app-session-inline .app-session-mobile-bar {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
|
||||
.app-session-bar-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 14px;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
min-height: 52px;
|
||||
border-radius: 13px;
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
transition: color 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
|
||||
@@ -201,6 +201,8 @@ const appLauncher = useAppLauncherStore()
|
||||
|
||||
const selectedCategory = ref('all')
|
||||
const searchQuery = ref('')
|
||||
const bitcoinPruned = ref(false)
|
||||
const electrumxArchiveWarning = 'You need a full archival bitcoin node before downloading ElectrumX'
|
||||
|
||||
const categories = computed(() => [
|
||||
{ id: 'all', name: 'All' },
|
||||
@@ -392,6 +394,11 @@ function launchInstalledApp(app: MarketplaceApp) {
|
||||
}
|
||||
|
||||
function handleInstall(app: MarketplaceApp) {
|
||||
const blocked = installBlockedReason(app.id)
|
||||
if (blocked) {
|
||||
toast.error(blocked)
|
||||
return
|
||||
}
|
||||
if (app.source === 'local') {
|
||||
installApp(app)
|
||||
} else {
|
||||
@@ -432,6 +439,23 @@ onBeforeUnmount(() => {
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
async function loadBitcoinPruneStatus() {
|
||||
try {
|
||||
const res = await fetch('/bitcoin-status', { credentials: 'include', signal: AbortSignal.timeout(8000) })
|
||||
if (!res.ok) return
|
||||
const status = await res.json()
|
||||
bitcoinPruned.value = status?.blockchain_info?.pruned === true
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.warn('[Discover] Bitcoin prune status unavailable:', e)
|
||||
}
|
||||
}
|
||||
|
||||
function installBlockedReason(appId: string): string | undefined {
|
||||
if (!bitcoinPruned.value) return undefined
|
||||
if (appId !== 'electrumx' && appId !== 'electrs' && appId !== 'mempool-electrs') return undefined
|
||||
return electrumxArchiveWarning
|
||||
}
|
||||
|
||||
function queueInstall(app: MarketplaceApp) {
|
||||
serverStore.setInstallProgress(app.id, {
|
||||
id: app.id,
|
||||
@@ -492,6 +516,7 @@ onMounted(() => {
|
||||
if (communityApps.value.length === 0 && !loadingCommunity.value) {
|
||||
loadCommunityMarketplace()
|
||||
}
|
||||
loadBitcoinPruneStatus()
|
||||
})
|
||||
|
||||
const catalogFeatured = ref<CatalogFeatured | null>(null)
|
||||
@@ -512,4 +537,3 @@ async function loadCommunityMarketplace() {
|
||||
loadingCommunity.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -83,9 +83,11 @@ const launchableApps = computed<KioskApp[]>(() => {
|
||||
const pkgs = store.data?.['package-data'] || {}
|
||||
const apps: KioskApp[] = []
|
||||
|
||||
// App URL mappings — use nginx proxy paths for local apps
|
||||
// App URL mappings. Bitcoin UI uses its direct host-network port; loading it
|
||||
// through /app/bitcoin-ui/ can render a blank shell because its assets are
|
||||
// rooted at /.
|
||||
const urlMap: Record<string, string> = {
|
||||
'bitcoin-knots': '/app/bitcoin-ui/',
|
||||
'bitcoin-knots': 'http://' + window.location.hostname + ':8334',
|
||||
'lnd': '/app/lnd/',
|
||||
'mempool': '/app/mempool/',
|
||||
'btcpay-server': '/app/btcpay/',
|
||||
@@ -107,7 +109,7 @@ const launchableApps = computed<KioskApp[]>(() => {
|
||||
'fedimint': '/app/fedimint/',
|
||||
'fedimint-gateway': '/app/fedimint-gateway/',
|
||||
'dwn': '/app/dwn/',
|
||||
'indeedhub': 'http://localhost:8190',
|
||||
'indeedhub': 'http://localhost:7778',
|
||||
'botfights': 'http://localhost:9100',
|
||||
'nwnn': 'https://nwnn.l484.com',
|
||||
'484-kitchen': 'https://484.kitchen',
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
:starting-up="isStartingUp(app.id)"
|
||||
:containers-scanned="containersScanned"
|
||||
:tier-label="getAppTier(app.id)"
|
||||
:install-blocked-reason="installBlockedReason(app.id)"
|
||||
@view="viewAppDetails"
|
||||
@install="app.source === 'local' ? installApp(app) : installCommunityApp(app)"
|
||||
@launch="launchInstalledApp"
|
||||
@@ -157,6 +158,7 @@ const categories = computed(() => [
|
||||
|
||||
// Installation state — uses global store so it persists across navigation
|
||||
const installingApps = server.installingApps
|
||||
const electrumxArchiveWarning = 'You need a full archival bitcoin node before downloading ElectrumX'
|
||||
|
||||
// Install progress tracking is now in serverStore (global watcher on WebSocket data)
|
||||
// so it works regardless of which page is active
|
||||
@@ -174,6 +176,7 @@ const loadingCommunity = ref(false)
|
||||
const communityError = ref('')
|
||||
const communityApps = ref<MarketplaceApp[]>([])
|
||||
const searchQuery = ref('')
|
||||
const bitcoinPruned = ref(false)
|
||||
|
||||
// Nostr community marketplace state
|
||||
const nostrApps = ref<MarketplaceApp[]>([])
|
||||
@@ -309,8 +312,26 @@ onMounted(() => {
|
||||
if (communityApps.value.length === 0 && !loadingCommunity.value) {
|
||||
loadCommunityMarketplace()
|
||||
}
|
||||
loadBitcoinPruneStatus()
|
||||
})
|
||||
|
||||
async function loadBitcoinPruneStatus() {
|
||||
try {
|
||||
const res = await fetch('/bitcoin-status', { credentials: 'include', signal: AbortSignal.timeout(8000) })
|
||||
if (!res.ok) return
|
||||
const status = await res.json()
|
||||
bitcoinPruned.value = status?.blockchain_info?.pruned === true
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.warn('[Marketplace] Bitcoin prune status unavailable:', e)
|
||||
}
|
||||
}
|
||||
|
||||
function installBlockedReason(appId: string): string | undefined {
|
||||
if (!bitcoinPruned.value) return undefined
|
||||
if (appId !== 'electrumx' && appId !== 'electrs' && appId !== 'mempool-electrs') return undefined
|
||||
return electrumxArchiveWarning
|
||||
}
|
||||
|
||||
async function loadCommunityMarketplace() {
|
||||
loadingCommunity.value = true
|
||||
communityError.value = ''
|
||||
@@ -379,6 +400,11 @@ function failInstall(app: MarketplaceApp, err: unknown) {
|
||||
|
||||
async function installApp(app: MarketplaceApp) {
|
||||
if (installingApps.has(app.id) || isInstalled(app.id)) return
|
||||
const blocked = installBlockedReason(app.id)
|
||||
if (blocked) {
|
||||
toast.error(blocked)
|
||||
return
|
||||
}
|
||||
|
||||
queueInstall(app)
|
||||
toast.info("Installing " + (app.title ?? app.id) + " - check My Apps")
|
||||
@@ -399,6 +425,11 @@ async function installApp(app: MarketplaceApp) {
|
||||
|
||||
async function installCommunityApp(app: MarketplaceApp) {
|
||||
if (installingApps.has(app.id) || isInstalled(app.id) || !app.dockerImage) return
|
||||
const blocked = installBlockedReason(app.id)
|
||||
if (blocked) {
|
||||
toast.error(blocked)
|
||||
return
|
||||
}
|
||||
|
||||
queueInstall(app)
|
||||
toast.info("Installing " + (app.title ?? app.id) + " - check My Apps")
|
||||
|
||||
@@ -84,7 +84,8 @@
|
||||
<button
|
||||
v-else
|
||||
@click="installApp"
|
||||
:disabled="installing || (!app.manifestUrl && !app.dockerImage)"
|
||||
:disabled="installing || (!installBlockedReason && !app.manifestUrl && !app.dockerImage)"
|
||||
:title="installBlockedReason || undefined"
|
||||
class="glass-button glass-button-sm px-6 py-2.5 rounded-lg text-sm font-semibold flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg v-if="installing" class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
@@ -94,7 +95,7 @@
|
||||
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
{{ installing ? t('common.installing') : t('common.install') }}
|
||||
{{ installBlockedReason ? 'Bitcoin Pruned' : installing ? t('common.installing') : t('common.install') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -149,7 +150,8 @@
|
||||
<button
|
||||
v-else
|
||||
@click="installApp"
|
||||
:disabled="installing || (!app.manifestUrl && !app.dockerImage)"
|
||||
:disabled="installing || (!installBlockedReason && !app.manifestUrl && !app.dockerImage)"
|
||||
:title="installBlockedReason || undefined"
|
||||
class="glass-button glass-button-sm px-4 py-2.5 rounded-lg text-sm font-semibold flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed col-span-2"
|
||||
>
|
||||
<svg v-if="installing" class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
@@ -159,7 +161,7 @@
|
||||
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
{{ installing ? t('common.installing') : t('common.install') }}
|
||||
{{ installBlockedReason ? 'Bitcoin Pruned' : installing ? t('common.installing') : t('common.install') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -189,6 +191,10 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="installBlockedReason" class="hidden md:block mt-4 p-4 bg-yellow-500/15 border border-yellow-500/30 rounded-lg">
|
||||
<p class="text-yellow-100 font-medium">Bitcoin is in pruned mode</p>
|
||||
<p class="text-yellow-200/80 text-sm mt-1">{{ installBlockedReason }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
@@ -375,9 +381,11 @@ import { rpcClient } from '../api/rpc-client'
|
||||
import { useMarketplaceApp, type MarketplaceAppInfo } from '../composables/useMarketplaceApp'
|
||||
import { useMobileBackButton } from '../composables/useMobileBackButton'
|
||||
import { useAppLauncherStore } from '../stores/appLauncher'
|
||||
import { useToast } from '../composables/useToast'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { bottomPosition } = useMobileBackButton()
|
||||
const toast = useToast()
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -389,6 +397,8 @@ const installing = ref(false)
|
||||
const installingDeps = ref(false)
|
||||
const installError = ref<string | null>(null)
|
||||
const loading = ref(true)
|
||||
const bitcoinPruned = ref(false)
|
||||
const electrumxArchiveWarning = 'You need a full archival bitcoin node before downloading ElectrumX'
|
||||
|
||||
const appId = computed(() => route.params.id as string)
|
||||
|
||||
@@ -471,6 +481,13 @@ const dependencies = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
const installBlockedReason = computed(() => {
|
||||
const id = app.value?.id
|
||||
if (!bitcoinPruned.value || !id) return ''
|
||||
if (id !== 'electrumx' && id !== 'electrs' && id !== 'mempool-electrs') return ''
|
||||
return electrumxArchiveWarning
|
||||
})
|
||||
|
||||
let pendingRedirect: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
onMounted(() => {
|
||||
@@ -495,8 +512,20 @@ onMounted(() => {
|
||||
router.push('/dashboard/marketplace').catch(() => {})
|
||||
}, 500)
|
||||
}
|
||||
loadBitcoinPruneStatus()
|
||||
})
|
||||
|
||||
async function loadBitcoinPruneStatus() {
|
||||
try {
|
||||
const res = await fetch('/bitcoin-status', { credentials: 'include', signal: AbortSignal.timeout(8000) })
|
||||
if (!res.ok) return
|
||||
const status = await res.json()
|
||||
bitcoinPruned.value = status?.blockchain_info?.pruned === true
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.warn('[MarketplaceAppDetails] Bitcoin prune status unavailable:', e)
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (pendingRedirect) { clearTimeout(pendingRedirect); pendingRedirect = null }
|
||||
})
|
||||
@@ -533,6 +562,11 @@ async function installDependencies() {
|
||||
if (installingDeps.value) return
|
||||
const missingDeps = dependencies.value.filter(d => d.status === 'missing')
|
||||
if (!missingDeps.length) return
|
||||
if (bitcoinPruned.value && missingDeps.some(d => d.id === 'electrumx' || d.id === 'electrs' || d.id === 'mempool-electrs')) {
|
||||
installError.value = electrumxArchiveWarning
|
||||
toast.error(electrumxArchiveWarning)
|
||||
return
|
||||
}
|
||||
|
||||
installingDeps.value = true
|
||||
installError.value = null
|
||||
@@ -561,6 +595,11 @@ async function installDependencies() {
|
||||
|
||||
async function installApp() {
|
||||
if (installing.value || !app.value) return
|
||||
if (installBlockedReason.value) {
|
||||
installError.value = installBlockedReason.value
|
||||
toast.error(installBlockedReason.value)
|
||||
return
|
||||
}
|
||||
if (!app.value.manifestUrl && !app.value.dockerImage) {
|
||||
if (import.meta.env.DEV) console.warn('[MarketplaceAppDetails] Cannot install - no manifestUrl or dockerImage:', app.value)
|
||||
return
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<!-- Desktop: Single Row Layout -->
|
||||
<div class="hidden md:flex items-center gap-6">
|
||||
<img
|
||||
:src="pkg['static-files']?.icon || `/assets/img/app-icons/${pkg.manifest?.id || appId}.png`"
|
||||
:src="icon"
|
||||
:alt="pkg.manifest.title"
|
||||
class="w-20 h-20 rounded-xl shadow-xl flex-shrink-0"
|
||||
@error="handleImageError"
|
||||
@@ -117,7 +117,7 @@
|
||||
<div class="md:hidden">
|
||||
<div class="flex items-start gap-4 mb-4">
|
||||
<img
|
||||
:src="pkg['static-files']?.icon || `/assets/img/app-icons/${pkg.manifest?.id || appId}.png`"
|
||||
:src="icon"
|
||||
:alt="pkg.manifest.title"
|
||||
class="w-20 h-20 rounded-xl shadow-xl flex-shrink-0"
|
||||
@error="handleImageError"
|
||||
@@ -226,18 +226,23 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { computed } from 'vue'
|
||||
import type { PackageDataEntry } from '@/types/api'
|
||||
import { resolveAppIcon } from '@/views/apps/appsConfig'
|
||||
import { getStatusClass, getStatusDotClass, getStatusLabel } from './appDetailsData'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
defineProps<{
|
||||
pkg: Record<string, any>
|
||||
const props = defineProps<{
|
||||
pkg: PackageDataEntry
|
||||
appId: string
|
||||
packageKey: string
|
||||
canLaunch: boolean
|
||||
isWebOnly: boolean
|
||||
}>()
|
||||
|
||||
const icon = computed(() => resolveAppIcon(props.pkg.manifest?.id || props.appId, props.pkg))
|
||||
|
||||
defineEmits<{
|
||||
launch: []
|
||||
start: []
|
||||
|
||||
@@ -16,7 +16,7 @@ export const WEB_ONLY_APP_URLS: Record<string, string> = {
|
||||
|
||||
/** Map route/marketplace app IDs to backend package keys (container names). */
|
||||
export const ROUTE_TO_PACKAGE_KEY: Record<string, string> = {
|
||||
mempool: 'mempool-web',
|
||||
mempool: 'mempool',
|
||||
'mempool-electrs': 'mempool-electrs',
|
||||
electrs: 'mempool-electrs',
|
||||
btcpay: 'btcpay-server',
|
||||
@@ -88,7 +88,7 @@ export const APP_URLS: Record<string, { dev: string; prod: string }> = {
|
||||
'portainer': { dev: 'http://localhost:9000', prod: 'http://localhost:9000' },
|
||||
'uptime-kuma': { dev: 'http://localhost:3002', prod: 'http://localhost:3002' },
|
||||
'tailscale': { dev: 'http://localhost:8240', prod: 'http://localhost:8240' },
|
||||
'lnd': { dev: 'http://localhost:8081', prod: 'http://localhost:8081' },
|
||||
'lnd': { dev: 'http://localhost:18083', prod: 'http://localhost:18083' },
|
||||
'bitcoin-knots': { dev: 'http://localhost:8334', prod: 'http://localhost:8334' },
|
||||
'botfights': { dev: 'http://localhost:9100', prod: 'http://localhost:9100' },
|
||||
'nwnn': { dev: 'https://nwnn.l484.com', prod: 'https://nwnn.l484.com' },
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { NEW_TAB_APPS, resolveAppUrl } from '../appSessionConfig'
|
||||
|
||||
describe('appSessionConfig', () => {
|
||||
it('keeps new-tab apps marked on every viewport', () => {
|
||||
expect(NEW_TAB_APPS.has('btcpay-server')).toBe(true)
|
||||
expect(NEW_TAB_APPS.has('grafana')).toBe(true)
|
||||
expect(NEW_TAB_APPS.has('vaultwarden')).toBe(true)
|
||||
})
|
||||
|
||||
it('resolves direct app ports against the current browser host', () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { hostname: '192.168.1.228' },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
expect(resolveAppUrl('mempool')).toBe('http://192.168.1.228:4080')
|
||||
expect(resolveAppUrl('indeedhub')).toBe('http://192.168.1.228:7778')
|
||||
})
|
||||
})
|
||||