Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab27fb97f8 | ||
|
|
d736364ad7 | ||
|
|
e9898ead76 | ||
|
|
b25d41c5c6 | ||
|
|
32902d3891 | ||
|
|
92c578d3d9 | ||
|
|
6240064acf | ||
|
|
19dbf60f03 | ||
|
|
b49d8f1f8a | ||
|
|
ec36ac7e2c | ||
|
|
7104ba0cbf | ||
|
|
d0b08d2790 | ||
|
|
76288f541e | ||
|
|
b701e125b4 | ||
|
|
837ba63466 | ||
|
|
8191d92bed | ||
|
|
ae8359da4b | ||
|
|
d91b858d9b | ||
|
|
19f2125a4d | ||
|
|
a992abcd06 | ||
|
|
4d6b4f76af | ||
|
|
0a94c0097f | ||
|
|
413d50116e | ||
|
|
daad50325b | ||
|
|
e05e356d64 | ||
|
|
cfb304a001 | ||
|
|
7804223152 | ||
|
|
a322b04021 | ||
|
|
645cf69ed7 | ||
|
|
01ec0565a6 | ||
|
|
30505f41ff | ||
|
|
5818541721 | ||
|
|
b8053c00ca | ||
|
|
f95e9a1cd0 | ||
|
|
be50dc3235 | ||
|
|
2ff47f88a7 | ||
|
|
835c525218 | ||
|
|
3202b79e41 |
@@ -7,6 +7,14 @@
|
||||
# Allow demo assets (AIUI pre-built dist)
|
||||
!demo/
|
||||
|
||||
# Allow backend source for ISO source builds
|
||||
!core/
|
||||
!scripts/
|
||||
!image-recipe/
|
||||
image-recipe/build/
|
||||
image-recipe/results/
|
||||
image-recipe/output/
|
||||
|
||||
# Exclude nested node_modules (will npm install in container)
|
||||
neode-ui/node_modules
|
||||
neode-ui/dist
|
||||
|
||||
@@ -57,6 +57,11 @@ coverage/
|
||||
*.dmg
|
||||
*.app
|
||||
|
||||
# Release artifacts live in Gitea Release attachments, not Git history.
|
||||
releases/**
|
||||
!releases/
|
||||
!releases/manifest.json
|
||||
|
||||
# macOS build output
|
||||
build/macos/
|
||||
|
||||
|
||||
+126
-1
@@ -1,5 +1,130 @@
|
||||
# Changelog
|
||||
|
||||
## v1.7.68-alpha (2026-05-19)
|
||||
|
||||
- BTCPay Server now ships on the official `docker.io/btcpayserver/btcpayserver:2.3.9` image, fixing the plugin catalog crash caused by newer plugin dependency version metadata while preserving existing datadirs and Postgres databases.
|
||||
- BTCPay release and first-boot health checks no longer depend on `curl` inside the container; they use a bash TCP probe that works with the official image out of the box.
|
||||
- Host nginx now serves Nginx Proxy Manager HTTP-01 challenge files before the Archipelago SPA fallback and is marked as the default HTTP/HTTPS virtual host, so public proxy hosts can issue certificates without hijacking local API traffic.
|
||||
- Nginx Proxy Manager first-boot, runtime repair, and container-doctor paths now pre-create the ACME webroot, keep bind mounts owned by the rootless Archipelago user, and sync issued public proxy hosts into host nginx vhosts.
|
||||
- The Nginx Proxy Manager host-nginx sync now skips proxy hosts with missing certificate files and rolls back the generated nginx include if validation fails, preventing a bad certificate path from poisoning later nginx reloads.
|
||||
- App session close buttons now return to the previous dashboard screen when possible and otherwise fall back to My Apps, avoiding the 404 page after closing an app launched from an invalid or stale history entry.
|
||||
- System Update confirmation and mirror modals now teleport to the document body with a full-screen overlay, so they cover the whole app instead of only the right-hand dashboard panel.
|
||||
- Mobile app launches stay inside Archipelago's app-session webview and hide desktop-only new-tab launch affordances, including apps such as Home Assistant that previously looked like they would leave the mobile shell.
|
||||
- Live recovery on `100.70.96.88` upgraded only the `btcpay-server` container to `docker.io/btcpayserver/btcpayserver:2.3.9`, preserved the existing datadir and Postgres database, and confirmed the container is healthy after a pre-upgrade backup.
|
||||
- Public validation confirmed `spay.tx1138.com`/`www` redirect to BTCPay login over HTTPS and `sapien.tx1138.com`/`www` serve the L484 page over HTTPS using the issued Let's Encrypt certificates.
|
||||
|
||||
## v1.7.67-alpha (2026-05-18)
|
||||
|
||||
- Home dashboard status cards now keep the last known good system, VPN, Bitcoin, and FIPS values while route changes or transient RPC failures are in flight, avoiding false "not configured" or "not running" flashes.
|
||||
- Home, Web5 Monitoring, and the Monitoring page headline cards now share the same live system-stat snapshot for CPU, memory, disk, uptime, and load so the visible numbers agree across the UI.
|
||||
- Settings What's New is filled through `v1.7.67-alpha`, including the missing historical `v1.7.44-alpha` through `v1.7.66-alpha` entries.
|
||||
- Bitcoin/Knots/Core shell lifecycle specs now match the Rust app config memory policy: 8 GiB on normal hosts, 4 GiB on low-memory hosts, and pruned Knots uses a larger dbcache on hosts with enough RAM to improve IBD throughput.
|
||||
- ElectrumX/electrs shell lifecycle specs now match the 4 GiB memory policy used by the Rust app config, reducing drift between first boot, reconcile, and app lifecycle paths.
|
||||
- Live assessment of `100.70.96.88` identified the current IBD bottlenecks as CPU/thermal/I/O pressure rather than RAM exhaustion, with follow-up work planned for existing-node swap repair, kiosk Chromium CPU reduction, and reconcile failure cleanup.
|
||||
|
||||
## v1.7.66-alpha (2026-05-18)
|
||||
|
||||
- Nginx Proxy Manager stale-port repair now detects stopped or `Created` Podman records by inspecting `podman ps -a` port metadata, covering records where `podman port nginx-proxy-manager` returns no mapping until start.
|
||||
- Live recovery on `100.70.96.88` removed only the stale Nginx Proxy Manager container record and recreated it with `8081:81`, `8084:80`, and `8444:443`, preserving `/var/lib/archipelago/nginx-proxy-manager` data.
|
||||
- Validation confirmed Nginx Proxy Manager recovered as healthy and responds through direct admin port `8081`, host compatibility port `81`, and `/app/nginx-proxy-manager/`.
|
||||
|
||||
## v1.7.65-alpha (2026-05-18)
|
||||
|
||||
- Orchestrator-backed app starts now run the same pre-start repairs as the legacy Podman path, so Nginx Proxy Manager stale `81:81` container metadata is removed and recreated before the orchestrator tries to start it.
|
||||
- Live diagnostics on `100.70.96.88` confirmed host nginx is healthy while Nginx Proxy Manager has no listeners on `8081`, `8084`, or `8444`, causing host nginx `502` responses for NPM proxy paths.
|
||||
|
||||
## v1.7.64-alpha (2026-05-18)
|
||||
|
||||
- Update apply rate limiting is relaxed for authenticated admins from 2 attempts per 10 minutes to 10 attempts per minute, preventing the System Update page from getting stuck behind `429 Too Many Requests` during legitimate OTA retry/troubleshooting flows.
|
||||
- The corrected backend artifact rebuild protection from `v1.7.63-alpha` remains in place, so this release is built from a fresh Rust backend binary before publishing.
|
||||
|
||||
## v1.7.63-alpha (2026-05-18)
|
||||
|
||||
- Release automation now rebuilds the Rust backend after bumping the version and before hashing release artifacts, preventing OTA manifests from pointing at a stale backend binary.
|
||||
- This corrected release carries the Nginx Proxy Manager stale-port repair in an updated backend binary, so nodes running `1.7.61-alpha` can actually receive and execute the fix.
|
||||
- Validation confirmed the previously published `v1.7.62-alpha` backend artifact still contained `1.7.61-alpha`, explaining why nodes did not advance after applying that update.
|
||||
|
||||
## v1.7.62-alpha (2026-05-18)
|
||||
|
||||
- Nginx Proxy Manager start and restart now repair stale Podman containers that still publish the admin UI on host port `81`, which conflicts with host nginx on updated nodes.
|
||||
- The repair recreates only the stale Nginx Proxy Manager container metadata while preserving `/var/lib/archipelago/nginx-proxy-manager` data and using the current `8081:81`, `8084:80`, and `8444:443` mappings.
|
||||
- Runtime stale-listener cleanup for Nginx Proxy Manager is shared across start and restart paths so rootless port helper leftovers are still cleared before lifecycle retries.
|
||||
- Validation passed with `cargo fmt --all --check --manifest-path core/Cargo.toml` and `cargo check -p archipelago --manifest-path core/Cargo.toml`.
|
||||
|
||||
## v1.7.61-alpha (2026-05-18)
|
||||
|
||||
- Multi-container stack installs now keep their app card in the `Installing` state for up to 20 minutes while dependency containers are being pulled and prepared.
|
||||
- BTCPay Server installs no longer appear to vanish or fail after two minutes while Postgres and NBXplorer are still being created before the primary `btcpay-server` container exists.
|
||||
- The stale-transition escape hatch remains short for start, stop, restart, update, and removal operations, so genuinely wedged lifecycle actions still recover quickly.
|
||||
- Live validation on `100.70.96.88` confirmed BTCPay Server completed installation and responds on port `23000` with the expected HTTP redirect.
|
||||
|
||||
## v1.7.60-alpha (2026-05-18)
|
||||
|
||||
- Meshtastic serial detection now rejects malformed or incomplete handshakes instead of accepting unrelated serial devices as a fallback Meshtastic radio.
|
||||
- Mesh radio auto-detection now skips known non-mesh serial devices such as Sierra Wireless LTE modems and Zooz/Z-Wave sticks, avoiding interference with production peripherals.
|
||||
- Meshtastic config sync now sends `want_config_id` with the correct protobuf wire type, fixing radio-side `ignore malformed toradio` errors and allowing node-info/contact ingestion.
|
||||
- The stable `/dev/mesh-radio` udev rule no longer claims every `ttyACM*` device; it only matches known mesh USB serial adapters and known USB CDC ACM radio vendors.
|
||||
- Live validation on `100.70.96.88` confirmed Archipelago selects `/dev/ttyUSB0`, identifies the Meshtastic node, and refreshes 103 mesh contacts.
|
||||
|
||||
## v1.7.59-alpha (2026-05-17)
|
||||
|
||||
- Mobile app launching now keeps known container apps inside Archipelago's app-session flow instead of forcing desktop-only new-tab behavior on phones.
|
||||
- App sessions on mobile now respect the status-bar safe area so foreground iframe content starts below the device chrome while the fullscreen backdrop remains edge-to-edge.
|
||||
- Prepackaged website launch buttons now resolve their curated website URLs before website-container fallback logic, restoring launches for the L484 sites and adding the Arch Presentation bookmark.
|
||||
- Meshtastic contact discovery now drains the radio config stream through completion and retries config sync when the contact cache is empty, so nearby nodes already known by the radio are more likely to appear in Archipelago.
|
||||
- The Apps page now includes a compact sideload button and modal for installing trusted Docker images with optional title, description, and port mapping metadata.
|
||||
- Sideloaded app title and description metadata now persist through the backend app-config file so refreshed package scans do not collapse custom apps back to generic IDs.
|
||||
- Validation passed with `npm test -- appLauncher`, `npm run build`, `cargo check -p archipelago`, and `cargo fmt --all --check`.
|
||||
|
||||
## v1.7.58-alpha (2026-05-17)
|
||||
|
||||
- Mesh networking now supports Meshtastic radios over the Meshtastic serial API in addition to existing MeshCore Companion USB radios.
|
||||
- The mesh listener now probes preferred and auto-detected serial paths for both MeshCore and Meshtastic firmware, preserving the existing reconnect loop so unplug/replug and firmware hot-swap behavior stays consistent.
|
||||
- Meshtastic text packets are translated into the existing Archipelago mesh frame pipeline, so current RPC handlers, transport routing, message storage, typed-message decoding, and UI state continue to work without a separate frontend path.
|
||||
- Meshtastic node information is surfaced as normal mesh contacts using stable synthetic public keys derived from Meshtastic node numbers, allowing peer refresh and message attribution to reuse existing MeshCore contact handling.
|
||||
- Outbound Archipelago mesh messages can now be sent through Meshtastic as channel text packets using the same command path used by MeshCore channel broadcasts.
|
||||
- Device status now reports the detected firmware family as `meshcore` or `meshtastic` from the shared listener abstraction.
|
||||
- Radio udev rules now include USB CDC ACM serial devices (`ttyACM*`) alongside CP2102, CH340, and FTDI adapters so Meshtastic boards are more likely to appear through the stable `/dev/mesh-radio` symlink.
|
||||
- Host nginx now serves `/assets/*` hashed frontend chunks as immutable static files with a hard 404 on misses instead of falling back to `index.html`, preventing strict MIME errors when a browser has a stale pre-update HTML shell.
|
||||
- The SPA HTML shell and service-worker files now revalidate on every load, reducing stale frontend references after OTA updates.
|
||||
- OTA runtime promotion now installs the bundled `nginx-archipelago.conf` into `/etc/nginx/sites-available/archipelago` and reloads nginx after a successful config test, so frontend cache/fallback fixes reach existing nodes without a manual deploy.
|
||||
- Local validation passed with `cargo check -p archipelago`; live SSH testing against `100.70.96.88` was not completed because temporary public-key authentication was rejected on the target.
|
||||
|
||||
## v1.7.57-alpha (2026-05-17)
|
||||
|
||||
- Nginx Proxy Manager now avoids privileged rootless Podman host port `81`, preferring `8081:81` while host nginx keeps a compatibility proxy on `:81` for stale cached launch buttons.
|
||||
- App installs now allocate ports by checking live host bind availability, falling back to a free high port when preferred ports are already occupied.
|
||||
- Portainer-created launchable containers are separated into a `Websites` tab and launch through their discovered published host port instead of hard-coded app URLs.
|
||||
- Internal BuildKit helper containers such as `buildx_buildkit_default` are hidden from the Apps UI.
|
||||
- Portainer works out of the box on Debian 13/Podman installs by including `catatonit` and by preserving the Podman socket mount as a socket rather than creating it as a directory.
|
||||
|
||||
## v1.7.56-alpha (2026-05-15)
|
||||
|
||||
- Health notifications now clear when an app is no longer unhealthy, including stale alerts for removed containers such as Portainer.
|
||||
- Fresh installs now include the full Wi-Fi userspace stack (`wpasupplicant`, `wireless-regdb`, `iw`, `rfkill`, `polkitd`, `pciutils`, and `usbutils`) so NetworkManager can scan and connect with Intel Wi-Fi cards out of the box.
|
||||
- The installed system now grants the `archipelago` service user explicit NetworkManager PolicyKit access for web-triggered Wi-Fi scans and connection changes.
|
||||
- Wi-Fi connect now replaces stale/partial NetworkManager profiles and creates an explicit WPA-PSK profile with the supplied password, avoiding no-secret retry failures after a failed attempt.
|
||||
- Settings password changes now update the Linux/SSH password through non-interactive sudo, so the web password and SSH password stay in sync when the checkbox is enabled.
|
||||
- Quadlet environment values with spaces or shell metacharacters are quoted consistently, preventing env drift recreate loops for apps like nostr-rs-relay and Grafana.
|
||||
- Boot/bootstrap reconcile avoids restarting running Bitcoin containers while repairing RPC config, preserving IBD progress on active nodes.
|
||||
- Exit code 137 is labeled as SIGKILL instead of assuming OOM, avoiding false OOM alerts for orchestrator-managed recreates.
|
||||
- Container reconcile force-recreates Podman records stuck in `Stopping`, preserving bind-mounted app data while recovering wedged containers automatically.
|
||||
- Container health reporting is honest for running containers: Archipelago surfaces Podman's actual health state instead of marking every running container healthy.
|
||||
- Quadlet reconciliation restarts services when stale health gates, port bindings, network aliases, exec commands, or healthchecks drift from the current manifest.
|
||||
- Bitcoin Knots sync performance improves on fresh installs and updates with 8Gi container memory, a 4Gi dbcache, and full CPU parallelism.
|
||||
- ElectrumX initial indexing gets more headroom: CPU caps are removed, memory is raised to 4Gi, cache is raised to 3Gi, and oversized sends are allowed for heavier wallet/indexing workloads.
|
||||
- Mempool/ElectrumX lifecycle qualification respects pruned/non-archival Bitcoin nodes instead of installing a half-running stack with unhealthy dependencies.
|
||||
- LND wallet/RPC helpers are more tolerant of container-owned files and updated REST port metadata, improving LND lifecycle and wallet-connect flows.
|
||||
- Marketplace/catalog metadata carries richer container config so remote lifecycle tests install apps using the same settings users get from the UI.
|
||||
- The app screensaver no longer activates during media-heavy app sessions such as IndeeHub, Jellyfin, Immich, PhotoPrism, and File Browser; apps can also pause/resume it with media playback messages.
|
||||
- A fresh `1.7.56-alpha` unbundled installer ISO is built from the same primary VPS2 release line for easy download and USB flashing.
|
||||
|
||||
## v1.7.55-alpha (2026-05-13)
|
||||
|
||||
- Container reconcile now force-recreates Podman records stuck in `Stopping`, preserving bind-mounted app data while recovering wedged containers automatically.
|
||||
- `.198` is green after the container-layer hardening pass: focused and broad non-destructive lifecycle audits pass, raw Podman health/state sweep is clean, and direct app probes return healthy responses.
|
||||
- Release-candidate artifacts are staged separately from live update publishing while Gitea artifact hosting is repaired.
|
||||
|
||||
## v1.7.54-alpha (2026-05-06)
|
||||
|
||||
- Existing installs now self-repair nginx backend proxy locations for `/bitcoin-status` and `/api/app-catalog`, including hosts where `sites-enabled/archipelago` is a copied active file instead of a symlink.
|
||||
@@ -37,7 +162,7 @@
|
||||
## 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).
|
||||
- ElectrumX initial indexing is faster too. Its CPU cap is removed, container memory is 4GB, and its internal cache is now 3GB (default was 1.2GB).
|
||||
|
||||
## v1.7.46-alpha (2026-04-29)
|
||||
|
||||
|
||||
+72
-14
@@ -52,13 +52,13 @@
|
||||
{
|
||||
"id": "btcpay-server",
|
||||
"title": "BTCPay Server",
|
||||
"version": "1.13.7",
|
||||
"version": "2.3.9",
|
||||
"description": "Self-hosted Bitcoin payment processor.",
|
||||
"icon": "/assets/img/app-icons/btcpay-server.png",
|
||||
"author": "BTCPay Server Foundation",
|
||||
"category": "commerce",
|
||||
"tier": "core",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/btcpayserver:1.13.7",
|
||||
"dockerImage": "docker.io/btcpayserver/btcpayserver:2.3.9",
|
||||
"repoUrl": "https://github.com/btcpayserver/btcpayserver",
|
||||
"requires": [
|
||||
"bitcoin-knots"
|
||||
@@ -115,7 +115,12 @@
|
||||
"author": "BotFights",
|
||||
"category": "community",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/botfights:1.1.0",
|
||||
"repoUrl": "https://botfights.net"
|
||||
"repoUrl": "https://botfights.net",
|
||||
"containerConfig": {
|
||||
"ports": ["9100:9100"],
|
||||
"volumes": ["/var/lib/archipelago/botfights:/app/server/data"],
|
||||
"env": ["NODE_ENV=production", "PORT=9100", "FIGHT_LOOP_ENABLED=true", "ARCHY_EMBEDDED=1"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "gitea",
|
||||
@@ -126,7 +131,12 @@
|
||||
"author": "Gitea",
|
||||
"category": "development",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/gitea:1.23",
|
||||
"repoUrl": "https://gitea.com"
|
||||
"repoUrl": "https://gitea.com",
|
||||
"containerConfig": {
|
||||
"ports": ["3001:3000", "2222:22"],
|
||||
"volumes": ["/var/lib/archipelago/gitea/data:/data", "/var/lib/archipelago/gitea/config:/etc/gitea"],
|
||||
"env": ["GITEA__database__DB_TYPE=sqlite3", "GITEA__server__SSH_PORT=2222", "GITEA__server__SSH_LISTEN_PORT=22", "GITEA__server__LFS_START_SERVER=true", "GITEA__packages__ENABLED=true", "GITEA__repository__ENABLE_PUSH_CREATE_USER=true", "GITEA__repository__ENABLE_PUSH_CREATE_ORG=true", "GITEA__security__X_FRAME_OPTIONS="]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "filebrowser",
|
||||
@@ -138,7 +148,12 @@
|
||||
"category": "data",
|
||||
"tier": "core",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/filebrowser:v2.27.0",
|
||||
"repoUrl": "https://github.com/filebrowser/filebrowser"
|
||||
"repoUrl": "https://github.com/filebrowser/filebrowser",
|
||||
"containerConfig": {
|
||||
"ports": ["8083:80"],
|
||||
"volumes": ["/var/lib/archipelago/filebrowser:/srv", "/var/lib/archipelago/filebrowser-data:/data"],
|
||||
"args": ["--database=/data/database.db", "--root=/srv", "--address=0.0.0.0", "--port=80"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "vaultwarden",
|
||||
@@ -150,7 +165,11 @@
|
||||
"category": "data",
|
||||
"tier": "recommended",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/vaultwarden:1.30.0-alpine",
|
||||
"repoUrl": "https://github.com/dani-garcia/vaultwarden"
|
||||
"repoUrl": "https://github.com/dani-garcia/vaultwarden",
|
||||
"containerConfig": {
|
||||
"ports": ["8082:80"],
|
||||
"volumes": ["/var/lib/archipelago/vaultwarden:/data"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "searxng",
|
||||
@@ -162,7 +181,11 @@
|
||||
"category": "data",
|
||||
"tier": "recommended",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/searxng:latest",
|
||||
"repoUrl": "https://github.com/searxng/searxng"
|
||||
"repoUrl": "https://github.com/searxng/searxng",
|
||||
"containerConfig": {
|
||||
"ports": ["8888:8080"],
|
||||
"volumes": ["/var/lib/archipelago/searxng:/etc/searxng"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "fedimint",
|
||||
@@ -184,7 +207,11 @@
|
||||
"author": "Jellyfin",
|
||||
"category": "data",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/jellyfin:10.8.13",
|
||||
"repoUrl": "https://github.com/jellyfin/jellyfin"
|
||||
"repoUrl": "https://github.com/jellyfin/jellyfin",
|
||||
"containerConfig": {
|
||||
"ports": ["8096:8096"],
|
||||
"volumes": ["/var/lib/archipelago/jellyfin/config:/config", "/var/lib/archipelago/jellyfin/cache:/cache"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "immich",
|
||||
@@ -206,7 +233,12 @@
|
||||
"author": "Home Assistant",
|
||||
"category": "home",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/home-assistant:2024.1",
|
||||
"repoUrl": "https://github.com/home-assistant/core"
|
||||
"repoUrl": "https://github.com/home-assistant/core",
|
||||
"containerConfig": {
|
||||
"ports": ["8123:8123"],
|
||||
"volumes": ["/var/lib/archipelago/home-assistant:/config"],
|
||||
"env": ["TZ=UTC"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "grafana",
|
||||
@@ -218,7 +250,12 @@
|
||||
"category": "data",
|
||||
"tier": "recommended",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/grafana:10.2.0",
|
||||
"repoUrl": "https://github.com/grafana/grafana"
|
||||
"repoUrl": "https://github.com/grafana/grafana",
|
||||
"containerConfig": {
|
||||
"ports": ["3000:3000"],
|
||||
"volumes": ["/var/lib/archipelago/grafana:/var/lib/grafana"],
|
||||
"env": ["GF_PATHS_DATA=/var/lib/grafana", "GF_USERS_ALLOW_SIGN_UP=false"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "tailscale",
|
||||
@@ -230,7 +267,13 @@
|
||||
"category": "networking",
|
||||
"tier": "recommended",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/tailscale:stable",
|
||||
"repoUrl": "https://github.com/tailscale/tailscale"
|
||||
"repoUrl": "https://github.com/tailscale/tailscale",
|
||||
"containerConfig": {
|
||||
"ports": ["8240:8240"],
|
||||
"volumes": ["/var/lib/archipelago/tailscale:/var/lib/tailscale"],
|
||||
"env": ["TS_STATE_DIR=/var/lib/tailscale"],
|
||||
"args": ["sh", "-c", "tailscaled --tun=userspace-networking & sleep 2; tailscale web --listen 0.0.0.0:8240 & wait"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "uptime-kuma",
|
||||
@@ -242,7 +285,13 @@
|
||||
"category": "data",
|
||||
"tier": "recommended",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/uptime-kuma:1",
|
||||
"repoUrl": "https://github.com/louislam/uptime-kuma"
|
||||
"repoUrl": "https://github.com/louislam/uptime-kuma",
|
||||
"containerConfig": {
|
||||
"ports": ["3002:3001"],
|
||||
"volumes": ["/var/lib/archipelago/uptime-kuma:/app/data"],
|
||||
"env": ["TZ=UTC"],
|
||||
"args": ["--", "node", "server/server.js"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "photoprism",
|
||||
@@ -253,7 +302,12 @@
|
||||
"author": "PhotoPrism",
|
||||
"category": "data",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/photoprism:240915",
|
||||
"repoUrl": "https://github.com/photoprism/photoprism"
|
||||
"repoUrl": "https://github.com/photoprism/photoprism",
|
||||
"containerConfig": {
|
||||
"ports": ["2342:2342"],
|
||||
"volumes": ["/var/lib/archipelago/photoprism:/photoprism/storage"],
|
||||
"env": ["PHOTOPRISM_ADMIN_PASSWORD=archipelago", "PHOTOPRISM_DEFAULT_LOCALE=en"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "nextcloud",
|
||||
@@ -264,7 +318,11 @@
|
||||
"author": "Nextcloud",
|
||||
"category": "data",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/nextcloud:28",
|
||||
"repoUrl": "https://github.com/nextcloud/server"
|
||||
"repoUrl": "https://github.com/nextcloud/server",
|
||||
"containerConfig": {
|
||||
"ports": ["8085:80"],
|
||||
"volumes": ["/var/lib/archipelago/nextcloud:/var/www/html"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ This document lists all port assignments for Archipelago apps.
|
||||
| searxng | 8888 | TCP | Web UI | 18888 |
|
||||
| onlyoffice | 8088 | TCP | Web UI | 18088 |
|
||||
| penpot | 8089 | TCP | Web UI | 18089 |
|
||||
| lnd | 9735, 10009, 8080 | TCP | P2P, gRPC, REST | 19735, 20009, 18080 |
|
||||
| lnd | 9735, 10009, 18080 | TCP | P2P, gRPC, REST | 19735, 20009, 28080 |
|
||||
| core-lightning | 9736, 9835 | TCP | P2P, gRPC | 19736, 19835 |
|
||||
| nostr-rs-relay | 8081 | TCP | HTTP/WebSocket | 18081 |
|
||||
| strfry | 8082 | TCP | HTTP/WebSocket | 18082 |
|
||||
|
||||
@@ -26,10 +26,13 @@ app:
|
||||
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}";
|
||||
RPC_USER="$(printenv BITCOIN_RPC_USER)";
|
||||
RPC_PASS="$(printenv BITCOIN_RPC_PASS)";
|
||||
DISK_GB_VALUE="$(printenv DISK_GB || true)";
|
||||
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
|
||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 -rpcuser="$RPC_USER" -rpcpassword="$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}";
|
||||
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="$RPC_USER" -rpcpassword="$RPC_PASS";
|
||||
fi
|
||||
derived_env:
|
||||
- key: DISK_GB
|
||||
@@ -44,7 +47,7 @@ app:
|
||||
|
||||
resources:
|
||||
cpu_limit: 0
|
||||
memory_limit: 4Gi
|
||||
memory_limit: 8Gi
|
||||
disk_limit: 500Gi
|
||||
|
||||
security:
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
app:
|
||||
id: btcpay-server
|
||||
name: BTCPay Server
|
||||
version: 1.13.7
|
||||
version: 2.3.9
|
||||
description: Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries.
|
||||
|
||||
container:
|
||||
image: git.tx1138.com/lfg2025/btcpayserver:1.13.7
|
||||
image: docker.io/btcpayserver/btcpayserver:2.3.9
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
secret_env:
|
||||
|
||||
@@ -12,7 +12,7 @@ app:
|
||||
entrypoint: ["sh", "-lc"]
|
||||
custom_args:
|
||||
- >-
|
||||
export DAEMON_URL="http://archipelago:${BITCOIN_RPC_PASS}@bitcoin-knots:8332/";
|
||||
export DAEMON_URL="http://archipelago:$(printenv BITCOIN_RPC_PASS)@bitcoin-knots:8332/";
|
||||
exec electrumx_server
|
||||
secret_env:
|
||||
- key: BITCOIN_RPC_PASS
|
||||
@@ -24,8 +24,8 @@ app:
|
||||
- storage: 50Gi
|
||||
|
||||
resources:
|
||||
cpu_limit: 2
|
||||
memory_limit: 2Gi
|
||||
cpu_limit: 0
|
||||
memory_limit: 4Gi
|
||||
disk_limit: 50Gi
|
||||
|
||||
security:
|
||||
@@ -48,6 +48,8 @@ app:
|
||||
- COIN=Bitcoin
|
||||
- DB_DIRECTORY=/data
|
||||
- SERVICES=tcp://:50001,rpc://0.0.0.0:8000
|
||||
- CACHE_MB=3072
|
||||
- MAX_SEND=10000000
|
||||
|
||||
health_check:
|
||||
type: tcp
|
||||
|
||||
@@ -53,7 +53,7 @@ app:
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://localhost:8123
|
||||
path: /api/
|
||||
path: /
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
@@ -34,7 +34,7 @@ app:
|
||||
- host: 10009
|
||||
container: 10009
|
||||
protocol: tcp
|
||||
- host: 8080
|
||||
- host: 18080
|
||||
container: 8080
|
||||
protocol: tcp
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ app:
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://localhost:8999
|
||||
path: /
|
||||
path: /api/v1/backend-info
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
@@ -46,8 +46,8 @@ app:
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://localhost:8081
|
||||
path: /health
|
||||
endpoint: http://localhost:8080
|
||||
path: /
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
app:
|
||||
id: searxng
|
||||
name: SearXNG
|
||||
version: latest
|
||||
version: 1.0.0
|
||||
description: Privacy-respecting metasearch engine. Search the web without tracking.
|
||||
|
||||
container:
|
||||
@@ -42,7 +42,7 @@ app:
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://localhost:8888
|
||||
endpoint: http://localhost:8080
|
||||
path: /
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
|
||||
Generated
+1
-1
@@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.7.54-alpha"
|
||||
version = "1.7.67-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.7.54-alpha"
|
||||
version = "1.7.68-alpha"
|
||||
edition = "2021"
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
authors = ["Archipelago Team"]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::build_response;
|
||||
use crate::api::rpc::lnd::LND_REST_BASE_URL;
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use crate::bitcoin_status;
|
||||
use crate::electrs_status;
|
||||
@@ -123,7 +124,7 @@ impl ApiHandler {
|
||||
cors_origin: &str,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
let suffix = path.strip_prefix("/proxy/lnd").unwrap_or("/");
|
||||
let url = format!("http://127.0.0.1:8080{}", suffix);
|
||||
let url = format!("{LND_REST_BASE_URL}{suffix}");
|
||||
match reqwest::get(&url).await {
|
||||
Ok(resp) => {
|
||||
let status = resp.status().as_u16();
|
||||
|
||||
@@ -2,6 +2,10 @@ use super::package::validate_app_id;
|
||||
use super::transitional::Op;
|
||||
use super::RpcHandler;
|
||||
use anyhow::{Context, Result};
|
||||
use std::time::Duration;
|
||||
|
||||
const PODMAN_INSPECT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const PODMAN_PS_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
impl RpcHandler {
|
||||
pub(super) async fn handle_container_install(
|
||||
@@ -379,6 +383,10 @@ 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()) {
|
||||
if let Some(health) = self.stack_health(app_id).await? {
|
||||
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 {
|
||||
@@ -434,6 +442,78 @@ impl RpcHandler {
|
||||
|
||||
Ok(serde_json::Value::Object(health_map))
|
||||
}
|
||||
|
||||
async fn stack_health(&self, app_id: &str) -> Result<Option<String>> {
|
||||
let Some(members) = stack_health_members(app_id) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let orchestrator = self
|
||||
.orchestrator
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available"))?;
|
||||
|
||||
let mut saw_starting = false;
|
||||
let mut saw_unknown = false;
|
||||
for member in members {
|
||||
match member_health(orchestrator.as_ref(), member)
|
||||
.await
|
||||
.as_deref()
|
||||
{
|
||||
Ok(health) if health == "healthy" => {}
|
||||
Ok(health) if health == "starting" => saw_starting = true,
|
||||
Ok(health) if health == "unknown" => saw_unknown = true,
|
||||
Ok(_) => return Ok(Some("unhealthy".to_string())),
|
||||
Err(_) => saw_unknown = true,
|
||||
}
|
||||
}
|
||||
|
||||
if saw_unknown {
|
||||
Ok(Some("unknown".to_string()))
|
||||
} else if saw_starting {
|
||||
Ok(Some("starting".to_string()))
|
||||
} else {
|
||||
Ok(Some("healthy".to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn member_health(
|
||||
orchestrator: &dyn crate::container::traits::ContainerOrchestrator,
|
||||
app_id: &str,
|
||||
) -> Result<String> {
|
||||
if let Ok(health) = orchestrator.health(app_id).await {
|
||||
return Ok(health);
|
||||
}
|
||||
for name in status_container_name_candidates(app_id) {
|
||||
if let Some(health) = inspect_container_health_value(&name).await {
|
||||
return Ok(health);
|
||||
}
|
||||
}
|
||||
Ok("unknown".to_string())
|
||||
}
|
||||
|
||||
fn stack_health_members(app_id: &str) -> Option<&'static [&'static str]> {
|
||||
match app_id {
|
||||
"mempool" | "mempool-web" => {
|
||||
Some(&["archy-mempool-db", "mempool-api", "archy-mempool-web"])
|
||||
}
|
||||
"btcpay-server" | "btcpayserver" | "btcpay" => {
|
||||
Some(&["archy-btcpay-db", "archy-nbxplorer", "btcpay-server"])
|
||||
}
|
||||
"immich" => Some(&["immich_postgres", "immich_redis", "immich_server"]),
|
||||
"indeedhub" => Some(&[
|
||||
"indeedhub-postgres",
|
||||
"indeedhub-redis",
|
||||
"indeedhub-minio",
|
||||
"indeedhub-relay",
|
||||
"indeedhub-api",
|
||||
"indeedhub-ffmpeg",
|
||||
"indeedhub",
|
||||
]),
|
||||
"fedimint" => Some(&["fedimint"]),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn status_app_id_candidates(app_id: &str) -> Vec<String> {
|
||||
@@ -504,15 +584,21 @@ fn status_container_name_candidates(app_id: &str) -> Vec<String> {
|
||||
}
|
||||
|
||||
async fn inspect_container_state_value(name: &str) -> Option<serde_json::Value> {
|
||||
let out = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"inspect",
|
||||
name,
|
||||
"--format",
|
||||
"{{.State.Status}} {{.State.Running}}",
|
||||
])
|
||||
.output()
|
||||
if let Some(v) = ps_container_state_value(name).await {
|
||||
return Some(v);
|
||||
}
|
||||
|
||||
let mut cmd = tokio::process::Command::new("podman");
|
||||
cmd.args([
|
||||
"inspect",
|
||||
name,
|
||||
"--format",
|
||||
"{{.State.Status}} {{.State.Running}} {{if .State.Healthcheck}}{{.State.Healthcheck.Status}}{{else}}none{{end}}",
|
||||
]);
|
||||
cmd.kill_on_drop(true);
|
||||
let out = tokio::time::timeout(PODMAN_INSPECT_TIMEOUT, cmd.output())
|
||||
.await
|
||||
.ok()?
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
@@ -525,20 +611,90 @@ async fn inspect_container_state_value(name: &str) -> Option<serde_json::Value>
|
||||
let mut parts = line.split_whitespace();
|
||||
let status = parts.next().unwrap_or("unknown");
|
||||
let running = parts.next().unwrap_or("false") == "true";
|
||||
let health = parts.next().unwrap_or("none");
|
||||
Some(serde_json::json!({
|
||||
"name": name,
|
||||
"status": status,
|
||||
"state": status,
|
||||
"running": running,
|
||||
"health": health,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn ps_container_state_value(name: &str) -> Option<serde_json::Value> {
|
||||
let mut cmd = tokio::process::Command::new("podman");
|
||||
cmd.args([
|
||||
"ps",
|
||||
"-a",
|
||||
"--filter",
|
||||
&format!("name={name}"),
|
||||
"--format",
|
||||
"{{.Names}}|{{.Status}}",
|
||||
]);
|
||||
cmd.kill_on_drop(true);
|
||||
let out = tokio::time::timeout(PODMAN_PS_TIMEOUT, cmd.output())
|
||||
.await
|
||||
.ok()?
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
for line in stdout.lines() {
|
||||
let mut parts = line.splitn(2, '|');
|
||||
let container_name = parts.next().unwrap_or_default();
|
||||
if container_name != name {
|
||||
continue;
|
||||
}
|
||||
let status = parts.next().unwrap_or_default();
|
||||
let state = state_from_podman_status(status);
|
||||
let health = parse_health_from_status(status).unwrap_or("none");
|
||||
return Some(serde_json::json!({
|
||||
"name": name,
|
||||
"status": state,
|
||||
"state": state,
|
||||
"running": state.eq_ignore_ascii_case("running"),
|
||||
"health": health,
|
||||
}));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn state_from_podman_status(status: &str) -> &str {
|
||||
if status.starts_with("Up ") {
|
||||
"running"
|
||||
} else if status.starts_with("Exited ") {
|
||||
"exited"
|
||||
} else if status.starts_with("Created") {
|
||||
"created"
|
||||
} else if status.starts_with("Stopping") {
|
||||
"stopping"
|
||||
} else if status.starts_with("Removing") {
|
||||
"removing"
|
||||
} else {
|
||||
"unknown"
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_health_from_status(status: &str) -> Option<&str> {
|
||||
let start = status.rfind('(')?;
|
||||
let end = status.rfind(')')?;
|
||||
(start < end).then(|| &status[start + 1..end])
|
||||
}
|
||||
|
||||
async fn inspect_container_health_value(name: &str) -> Option<String> {
|
||||
let v = inspect_container_state_value(name).await?;
|
||||
if let Some(health) = v.get("health").and_then(|s| s.as_str()) {
|
||||
if health != "none" {
|
||||
return Some(health.to_string());
|
||||
}
|
||||
}
|
||||
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()),
|
||||
"stopping" => Some("unhealthy".to_string()),
|
||||
"exited" | "stopped" => Some("unhealthy".to_string()),
|
||||
other => Some(format!("unknown:{other}")),
|
||||
}
|
||||
|
||||
@@ -307,14 +307,62 @@ async fn scan_wifi() -> Result<Vec<serde_json::Value>> {
|
||||
|
||||
/// Connect to a WiFi network using nmcli.
|
||||
async fn connect_wifi(ssid: &str, password: &str) -> Result<()> {
|
||||
let conn_name = format!("archipelago-wifi-{ssid}");
|
||||
|
||||
// Delete prior profiles for this SSID/name first. Failed attempts can leave
|
||||
// a profile with key-mgmt but no saved PSK, causing future retries to fail
|
||||
// with "no secrets" before the supplied password is used.
|
||||
let _ = tokio::process::Command::new("nmcli")
|
||||
.args(["connection", "delete", &conn_name])
|
||||
.output()
|
||||
.await;
|
||||
let _ = tokio::process::Command::new("nmcli")
|
||||
.args(["connection", "delete", ssid])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
let output = tokio::process::Command::new("nmcli")
|
||||
.args(["device", "wifi", "connect", ssid, "password", password])
|
||||
.args([
|
||||
"connection",
|
||||
"add",
|
||||
"type",
|
||||
"wifi",
|
||||
"con-name",
|
||||
&conn_name,
|
||||
"ifname",
|
||||
"*",
|
||||
"ssid",
|
||||
ssid,
|
||||
"wifi-sec.key-mgmt",
|
||||
"wpa-psk",
|
||||
"wifi-sec.psk",
|
||||
password,
|
||||
"ipv4.method",
|
||||
"auto",
|
||||
"ipv6.method",
|
||||
"auto",
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to run nmcli wifi profile create")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
anyhow::bail!("WiFi profile create failed: {}", stderr);
|
||||
}
|
||||
|
||||
let activate = tokio::process::Command::new("nmcli")
|
||||
.args(["connection", "up", &conn_name])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to run nmcli wifi connect")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
if !activate.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&activate.stderr);
|
||||
let _ = tokio::process::Command::new("nmcli")
|
||||
.args(["connection", "delete", &conn_name])
|
||||
.output()
|
||||
.await;
|
||||
anyhow::bail!("WiFi connection failed: {}", stderr);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::info;
|
||||
|
||||
use super::LND_REST_BASE_URL;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ChannelInfo {
|
||||
chan_id: String,
|
||||
@@ -62,7 +64,7 @@ impl RpcHandler {
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
let channels_resp: LndListChannelsResponse = client
|
||||
.get("https://127.0.0.1:8080/v1/channels")
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/channels"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
@@ -72,7 +74,7 @@ impl RpcHandler {
|
||||
.context("Failed to parse LND channels response")?;
|
||||
|
||||
let pending_resp: LndPendingChannelsResponse = match client
|
||||
.get("https://127.0.0.1:8080/v1/channels/pending")
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/channels/pending"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
@@ -211,7 +213,7 @@ impl RpcHandler {
|
||||
"perm": true
|
||||
});
|
||||
let _ = client
|
||||
.post("https://127.0.0.1:8080/v1/peers")
|
||||
.post(format!("{LND_REST_BASE_URL}/v1/peers"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.json(&connect_body)
|
||||
.send()
|
||||
@@ -224,7 +226,7 @@ impl RpcHandler {
|
||||
});
|
||||
|
||||
let resp = client
|
||||
.post("https://127.0.0.1:8080/v1/channels")
|
||||
.post(format!("{LND_REST_BASE_URL}/v1/channels"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.json(&open_body)
|
||||
.send()
|
||||
@@ -291,7 +293,7 @@ impl RpcHandler {
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
let url = format!(
|
||||
"https://127.0.0.1:8080/v1/channels/{}/{}?force={}",
|
||||
"{LND_REST_BASE_URL}/v1/channels/{}/{}?force={}",
|
||||
parts[0], parts[1], force
|
||||
);
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ use anyhow::{Context, Result};
|
||||
use base64::Engine;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{read_lnd_admin_macaroon, LndAmount, LndBalanceResponse};
|
||||
use super::{read_lnd_admin_macaroon, LndAmount, LndBalanceResponse, LND_REST_BASE_URL};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct LndInfo {
|
||||
@@ -44,7 +44,7 @@ impl RpcHandler {
|
||||
.context("Failed to create HTTP client")?;
|
||||
|
||||
let get_info: LndGetInfoResponse = client
|
||||
.get("https://127.0.0.1:8080/v1/getinfo")
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/getinfo"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
@@ -54,7 +54,7 @@ impl RpcHandler {
|
||||
.context("Failed to parse LND getinfo response")?;
|
||||
|
||||
let channel_balance: LndChannelBalanceResponse = match client
|
||||
.get("https://127.0.0.1:8080/v1/balance/channels")
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/balance/channels"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
@@ -70,7 +70,7 @@ impl RpcHandler {
|
||||
};
|
||||
|
||||
let wallet_balance: LndBalanceResponse = match client
|
||||
.get("https://127.0.0.1:8080/v1/balance/blockchain")
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/balance/blockchain"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
@@ -166,7 +166,7 @@ impl RpcHandler {
|
||||
"cert_base64url": cert_b64url,
|
||||
"macaroon_base64url": macaroon_b64url,
|
||||
"tor_onion": tor_onion,
|
||||
"rest_port": 8080,
|
||||
"rest_port": 18080,
|
||||
"grpc_port": 10009,
|
||||
}))
|
||||
}
|
||||
@@ -186,7 +186,7 @@ impl RpcHandler {
|
||||
.context("Failed to build HTTP client")?;
|
||||
|
||||
let resp = client
|
||||
.get("https://127.0.0.1:8080/v1/channels/backup")
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/channels/backup"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
|
||||
@@ -9,6 +9,7 @@ use anyhow::{anyhow, Context, Result};
|
||||
/// Canonical on-host path for LND's admin macaroon.
|
||||
pub(crate) const LND_ADMIN_MACAROON_PATH: &str =
|
||||
"/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
|
||||
pub(in crate::api) const LND_REST_BASE_URL: &str = "https://127.0.0.1:18080";
|
||||
|
||||
// Shared LND response types used by multiple submodules
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
|
||||
@@ -2,6 +2,8 @@ use crate::api::rpc::RpcHandler;
|
||||
use anyhow::{Context, Result};
|
||||
use tracing::info;
|
||||
|
||||
use super::LND_REST_BASE_URL;
|
||||
|
||||
impl RpcHandler {
|
||||
/// Pay a Lightning invoice.
|
||||
pub(in crate::api::rpc) async fn handle_lnd_payinvoice(
|
||||
@@ -35,7 +37,7 @@ impl RpcHandler {
|
||||
});
|
||||
|
||||
let resp = client
|
||||
.post("https://127.0.0.1:8080/v1/channels/transactions")
|
||||
.post(format!("{LND_REST_BASE_URL}/v1/channels/transactions"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.json(&pay_body)
|
||||
.send()
|
||||
@@ -91,7 +93,7 @@ impl RpcHandler {
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
let resp = client
|
||||
.get("https://127.0.0.1:8080/v1/transactions")
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/transactions"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
|
||||
@@ -4,13 +4,15 @@ use base64::Engine;
|
||||
use tracing::info;
|
||||
use zeroize::Zeroize;
|
||||
|
||||
use super::LND_REST_BASE_URL;
|
||||
|
||||
impl RpcHandler {
|
||||
/// Generate a new on-chain Bitcoin address.
|
||||
pub(in crate::api::rpc) async fn handle_lnd_newaddress(&self) -> Result<serde_json::Value> {
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
let resp = client
|
||||
.get("https://127.0.0.1:8080/v1/newaddress")
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/newaddress"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
@@ -69,7 +71,7 @@ impl RpcHandler {
|
||||
});
|
||||
|
||||
let resp = client
|
||||
.post("https://127.0.0.1:8080/v1/transactions")
|
||||
.post(format!("{LND_REST_BASE_URL}/v1/transactions"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.json(&send_body)
|
||||
.send()
|
||||
@@ -129,7 +131,7 @@ impl RpcHandler {
|
||||
});
|
||||
|
||||
let resp = client
|
||||
.post("https://127.0.0.1:8080/v1/invoices")
|
||||
.post(format!("{LND_REST_BASE_URL}/v1/invoices"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.json(&invoice_body)
|
||||
.send()
|
||||
@@ -231,7 +233,7 @@ impl RpcHandler {
|
||||
});
|
||||
|
||||
let resp = client
|
||||
.post("https://127.0.0.1:8080/v2/wallet/psbt/fund")
|
||||
.post(format!("{LND_REST_BASE_URL}/v2/wallet/psbt/fund"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.json(&fund_body)
|
||||
.send()
|
||||
@@ -289,7 +291,7 @@ impl RpcHandler {
|
||||
});
|
||||
|
||||
let resp = client
|
||||
.post("https://127.0.0.1:8080/v2/wallet/psbt/finalize")
|
||||
.post(format!("{LND_REST_BASE_URL}/v2/wallet/psbt/finalize"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.json(&finalize_body)
|
||||
.send()
|
||||
@@ -322,7 +324,7 @@ impl RpcHandler {
|
||||
});
|
||||
|
||||
let pub_resp = client
|
||||
.post("https://127.0.0.1:8080/v2/wallet/tx")
|
||||
.post(format!("{LND_REST_BASE_URL}/v2/wallet/tx"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.json(&publish_body)
|
||||
.send()
|
||||
@@ -387,7 +389,7 @@ impl RpcHandler {
|
||||
});
|
||||
|
||||
let resp = client
|
||||
.post("https://127.0.0.1:8080/v2/wallet/psbt/fund")
|
||||
.post(format!("{LND_REST_BASE_URL}/v2/wallet/psbt/fund"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.json(&fund_body)
|
||||
.send()
|
||||
@@ -416,7 +418,7 @@ impl RpcHandler {
|
||||
});
|
||||
|
||||
let resp = client
|
||||
.post("https://127.0.0.1:8080/v2/wallet/psbt/finalize")
|
||||
.post(format!("{LND_REST_BASE_URL}/v2/wallet/psbt/finalize"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.json(&finalize_body)
|
||||
.send()
|
||||
@@ -514,7 +516,7 @@ impl RpcHandler {
|
||||
});
|
||||
|
||||
let resp = client
|
||||
.post("https://127.0.0.1:8080/v1/initwallet")
|
||||
.post(format!("{LND_REST_BASE_URL}/v1/initwallet"))
|
||||
.json(&init_body)
|
||||
.send()
|
||||
.await
|
||||
|
||||
@@ -188,7 +188,7 @@ impl RpcHandler {
|
||||
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
let url = format!("https://127.0.0.1:8080/v1/invoice/{}", r_hash);
|
||||
let url = format!("{}/v1/invoice/{r_hash}", super::lnd::LND_REST_BASE_URL);
|
||||
let paid = match client
|
||||
.get(&url)
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
|
||||
@@ -53,6 +53,7 @@ pub(super) fn sanitize_error_message(msg: &str) -> String {
|
||||
"Unauthorized",
|
||||
"Forbidden",
|
||||
"Not supported",
|
||||
"Requires",
|
||||
"requires",
|
||||
"must be",
|
||||
"cannot",
|
||||
|
||||
@@ -12,7 +12,7 @@ mod fips;
|
||||
mod handshake;
|
||||
mod identity;
|
||||
mod interfaces;
|
||||
mod lnd;
|
||||
pub(in crate::api) mod lnd;
|
||||
mod marketplace;
|
||||
mod mesh;
|
||||
mod middleware;
|
||||
|
||||
@@ -54,6 +54,7 @@ impl RpcHandler {
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?
|
||||
.to_string();
|
||||
super::validation::validate_app_id(&package_id)?;
|
||||
super::dependencies::check_bitcoin_pruning_compatibility(&package_id).await?;
|
||||
|
||||
// Reject if already in a transitional lifecycle (prevents double-click
|
||||
// queuing two installs on the same package).
|
||||
|
||||
@@ -1,6 +1,96 @@
|
||||
use super::validation::validate_app_id;
|
||||
use crate::port_allocator::PortAllocator;
|
||||
use anyhow::{Context, Result};
|
||||
use std::time::Duration;
|
||||
|
||||
const PODMAN_LIST_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
fn is_platform_managed_app(app_id: &str) -> bool {
|
||||
matches!(
|
||||
app_id,
|
||||
"bitcoin"
|
||||
| "bitcoin-core"
|
||||
| "bitcoin-knots"
|
||||
| "bitcoin-ui"
|
||||
| "lnd"
|
||||
| "lnd-ui"
|
||||
| "electrumx"
|
||||
| "electrs"
|
||||
| "mempool-electrs"
|
||||
| "electrs-ui"
|
||||
| "mempool"
|
||||
| "mempool-web"
|
||||
| "mempool-api"
|
||||
| "archy-mempool-db"
|
||||
| "archy-mempool-web"
|
||||
| "btcpay"
|
||||
| "btcpay-server"
|
||||
| "btcpayserver"
|
||||
| "archy-btcpay-db"
|
||||
| "archy-nbxplorer"
|
||||
| "fedimint"
|
||||
| "fedimint-gateway"
|
||||
| "indeedhub"
|
||||
| "immich"
|
||||
)
|
||||
}
|
||||
|
||||
fn safe_dynamic_arg(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& value.len() <= 512
|
||||
&& !value.chars().any(|c| matches!(c, '\0' | '\n' | '\r'))
|
||||
}
|
||||
|
||||
async fn dynamic_app_config(
|
||||
app_id: &str,
|
||||
) -> Option<(
|
||||
Vec<String>,
|
||||
Vec<String>,
|
||||
Vec<String>,
|
||||
Option<String>,
|
||||
Option<Vec<String>>,
|
||||
)> {
|
||||
if is_platform_managed_app(app_id) {
|
||||
return None;
|
||||
}
|
||||
let config_path = format!("/var/lib/archipelago/app-configs/{}.json", app_id);
|
||||
let data = tokio::fs::read_to_string(&config_path).await.ok()?;
|
||||
let cfg = serde_json::from_str::<serde_json::Value>(&data).ok()?;
|
||||
let string_array = |key: &str| -> Vec<String> {
|
||||
cfg.get(key)
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|v| v.as_str())
|
||||
.filter(|s| safe_dynamic_arg(s))
|
||||
.map(String::from)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
};
|
||||
|
||||
let command = cfg
|
||||
.get("command")
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| safe_dynamic_arg(s))
|
||||
.map(String::from);
|
||||
let args = cfg.get("args").and_then(|v| v.as_array()).map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|v| v.as_str())
|
||||
.filter(|s| safe_dynamic_arg(s))
|
||||
.map(String::from)
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
|
||||
tracing::info!(app_id = %app_id, "loaded catalog runtime config for generic app");
|
||||
Some((
|
||||
string_array("ports"),
|
||||
string_array("volumes"),
|
||||
string_array("env"),
|
||||
command,
|
||||
args.filter(|a| !a.is_empty()),
|
||||
))
|
||||
}
|
||||
|
||||
/// Trusted Docker registries. Only images from these sources are allowed.
|
||||
#[allow(dead_code)]
|
||||
@@ -63,11 +153,13 @@ pub(super) fn get_app_capabilities(app_id: &str) -> Vec<String> {
|
||||
"--cap-add=SETGID".to_string(),
|
||||
"--cap-add=DAC_OVERRIDE".to_string(),
|
||||
],
|
||||
// Nginx Proxy Manager needs to bind low ports
|
||||
// Nginx Proxy Manager initializes/chowns mounted state on first boot.
|
||||
"nginx-proxy-manager" => 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(),
|
||||
],
|
||||
// Bitcoin needs only file-ownership ops + NET_BIND_SERVICE for the
|
||||
@@ -170,15 +262,15 @@ pub(super) fn get_health_check_args(app_id: &str, _rpc_pass: &str) -> Vec<String
|
||||
"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")
|
||||
("bash -ec '</dev/tcp/127.0.0.1/49392'", "30s", "3")
|
||||
}
|
||||
"mempool-api" => (
|
||||
"curl -sf http://localhost:8999/api/v1/backend-info || exit 1",
|
||||
http_probe_cmd("http://localhost:8999/api/v1/backend-info"),
|
||||
"30s",
|
||||
"3",
|
||||
),
|
||||
"mempool" | "mempool-web" | "archy-mempool-web" => {
|
||||
("curl -sf http://localhost:8080/ || exit 1", "30s", "3")
|
||||
(http_probe_cmd("http://localhost:8080/"), "30s", "3")
|
||||
}
|
||||
"electrumx" | "mempool-electrs" | "electrs" => {
|
||||
("curl -sf http://localhost:8000/ || exit 1", "60s", "3")
|
||||
@@ -189,7 +281,7 @@ pub(super) fn get_health_check_args(app_id: &str, _rpc_pass: &str) -> Vec<String
|
||||
"3",
|
||||
),
|
||||
"homeassistant" | "home-assistant" => {
|
||||
("curl -sf http://localhost:8123/api/ || exit 1", "30s", "3")
|
||||
("curl -sf http://localhost:8123/ || exit 1", "30s", "3")
|
||||
}
|
||||
"grafana" => (
|
||||
"test -w /var/lib/grafana && test -w /var/lib/grafana/grafana.db && curl -sf http://localhost:3000/api/health || exit 1",
|
||||
@@ -209,7 +301,7 @@ pub(super) fn get_health_check_args(app_id: &str, _rpc_pass: &str) -> Vec<String
|
||||
"30s",
|
||||
"3",
|
||||
),
|
||||
"searxng" => ("wget -q -O /dev/null http://localhost:8080/ || exit 1", "30s", "3"),
|
||||
"searxng" => (http_probe_cmd("http://localhost:8080/"), "30s", "3"),
|
||||
"photoprism" => (
|
||||
"curl -sf http://localhost:2342/api/v1/status || exit 1",
|
||||
"60s",
|
||||
@@ -229,10 +321,8 @@ pub(super) fn get_health_check_args(app_id: &str, _rpc_pass: &str) -> Vec<String
|
||||
"ollama" => ("curl -sf http://localhost:11434/ || exit 1", "30s", "3"),
|
||||
"fedimint" => ("curl -sf http://localhost:8175/ || exit 1", "60s", "3"),
|
||||
"fedimint-gateway" => ("curl -sf http://localhost:8176/ || exit 1", "60s", "3"),
|
||||
"nostr-rs-relay" | "nostr-relay" => {
|
||||
("curl -sf http://localhost:8080/ || exit 1", "30s", "3")
|
||||
}
|
||||
"nginx-proxy-manager" => ("curl -sf http://localhost:81/api/ || exit 1", "30s", "3"),
|
||||
"nostr-rs-relay" | "nostr-relay" => (http_probe_cmd("http://localhost:8080/"), "30s", "3"),
|
||||
"nginx-proxy-manager" => (http_probe_cmd("http://localhost:81/"), "30s", "3"),
|
||||
"routstr" => (
|
||||
"curl -sf http://localhost:8000/v1/models || exit 1",
|
||||
"30s",
|
||||
@@ -247,10 +337,21 @@ pub(super) fn get_health_check_args(app_id: &str, _rpc_pass: &str) -> Vec<String
|
||||
format!("--health-cmd={}", cmd),
|
||||
format!("--health-interval={}", interval),
|
||||
format!("--health-retries={}", retries),
|
||||
"--health-timeout=10s".to_string(),
|
||||
"--health-start-period=60s".to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
fn http_probe_cmd(url: &'static str) -> &'static str {
|
||||
match url {
|
||||
"http://localhost:8999/api/v1/backend-info" => "if command -v wget >/dev/null 2>&1; then wget -q -T 5 -O /dev/null http://localhost:8999/api/v1/backend-info; elif command -v curl >/dev/null 2>&1; then curl -fsS -m 5 http://localhost:8999/api/v1/backend-info; else exit 0; fi",
|
||||
"http://localhost:8080/" => "if command -v wget >/dev/null 2>&1; then wget -q -T 5 -O /dev/null http://localhost:8080/; elif command -v curl >/dev/null 2>&1; then curl -fsS -m 5 http://localhost:8080/; else exit 0; fi",
|
||||
"http://localhost:81/api/" => "if command -v wget >/dev/null 2>&1; then wget -q -T 5 -O /dev/null http://localhost:81/api/; elif command -v curl >/dev/null 2>&1; then curl -fsS -m 5 http://localhost:81/api/; else exit 0; fi",
|
||||
"http://localhost:81/" => "if command -v wget >/dev/null 2>&1; then wget -q -T 5 -O /dev/null http://localhost:81/; elif command -v curl >/dev/null 2>&1; then curl -fsS -m 5 http://localhost:81/; else exit 0; fi",
|
||||
_ => "exit 0",
|
||||
}
|
||||
}
|
||||
|
||||
/// Get per-app memory limit.
|
||||
pub(super) fn get_memory_limit(app_id: &str) -> &'static str {
|
||||
match app_id {
|
||||
@@ -259,10 +360,10 @@ pub(super) fn get_memory_limit(app_id: &str) -> &'static str {
|
||||
// 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",
|
||||
// ElectrumX: large cache materially speeds initial history indexing.
|
||||
// CACHE_MB=3072 below needs container headroom for Python, rocksdb,
|
||||
// socket buffers, and reorg/indexing spikes.
|
||||
"electrumx" | "mempool-electrs" | "electrs" => "4g",
|
||||
"cryptpad" => "512m",
|
||||
"ollama" => "4g",
|
||||
// Medium apps
|
||||
@@ -407,12 +508,14 @@ pub(super) fn all_container_names(package_id: &str) -> Vec<String> {
|
||||
|
||||
/// Find all running/stopped containers that belong to a given app.
|
||||
/// Uses the canonical name list from all_container_names().
|
||||
pub(super) async fn get_containers_for_app(package_id: &str) -> Result<Vec<String>> {
|
||||
pub(in crate::api::rpc) async fn get_containers_for_app(package_id: &str) -> Result<Vec<String>> {
|
||||
validate_app_id(package_id)?;
|
||||
let output = tokio::process::Command::new("podman")
|
||||
.args(["ps", "-a", "--format", "{{.Names}}"])
|
||||
.output()
|
||||
let mut cmd = tokio::process::Command::new("podman");
|
||||
cmd.args(["ps", "-a", "--format", "{{.Names}}"]);
|
||||
cmd.kill_on_drop(true);
|
||||
let output = tokio::time::timeout(PODMAN_LIST_TIMEOUT, cmd.output())
|
||||
.await
|
||||
.context("podman ps timed out while listing containers")?
|
||||
.context("Failed to list containers")?;
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let all: Vec<&str> = stdout.lines().filter(|s| !s.is_empty()).collect();
|
||||
@@ -540,6 +643,10 @@ pub(super) async fn get_app_config(
|
||||
Option<String>,
|
||||
Option<Vec<String>>,
|
||||
) {
|
||||
if let Some(config) = dynamic_app_config(app_id).await {
|
||||
return config;
|
||||
}
|
||||
|
||||
match app_id {
|
||||
"homeassistant" | "home-assistant" => (
|
||||
vec!["8123:8123".to_string()],
|
||||
@@ -600,7 +707,7 @@ pub(super) async fn get_app_config(
|
||||
vec![
|
||||
"9735:9735".to_string(),
|
||||
"10009:10009".to_string(),
|
||||
"8080:8080".to_string(),
|
||||
"18080:8080".to_string(),
|
||||
],
|
||||
vec!["/var/lib/archipelago/lnd:/root/.lnd".to_string()],
|
||||
vec![],
|
||||
@@ -676,9 +783,10 @@ pub(super) async fn get_app_config(
|
||||
"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(),
|
||||
// history index. Default is 1200MB; the container gets
|
||||
// 4g (config.rs::get_memory_limit) so 3072 fits with
|
||||
// headroom.
|
||||
"CACHE_MB=3072".to_string(),
|
||||
// Block-fetcher concurrency — defaults are conservative
|
||||
// for shared hosts; 4 is plenty for one bitcoind backend.
|
||||
"MAX_SEND=10000000".to_string(),
|
||||
@@ -818,20 +926,34 @@ pub(super) async fn get_app_config(
|
||||
]),
|
||||
)
|
||||
}
|
||||
"nginx-proxy-manager" => (
|
||||
vec![
|
||||
"81:81".to_string(),
|
||||
"8084:80".to_string(),
|
||||
"8443:443".to_string(),
|
||||
],
|
||||
vec![
|
||||
"/var/lib/archipelago/nginx-proxy-manager/data:/data".to_string(),
|
||||
"/var/lib/archipelago/nginx-proxy-manager/letsencrypt:/etc/letsencrypt".to_string(),
|
||||
],
|
||||
vec![],
|
||||
None,
|
||||
None,
|
||||
),
|
||||
"nginx-proxy-manager" => {
|
||||
let admin_port = allocator
|
||||
.allocate_or_get(app_id, 8081, 81)
|
||||
.await
|
||||
.unwrap_or(8081);
|
||||
let http_port = allocator
|
||||
.allocate_or_get("nginx-proxy-manager-http", 8084, 80)
|
||||
.await
|
||||
.unwrap_or(8084);
|
||||
let https_port = allocator
|
||||
.allocate_or_get("nginx-proxy-manager-https", 8444, 443)
|
||||
.await
|
||||
.unwrap_or(8444);
|
||||
(
|
||||
vec![
|
||||
format!("{}:81", admin_port),
|
||||
format!("{}:80", http_port),
|
||||
format!("{}:443", https_port),
|
||||
],
|
||||
vec![
|
||||
"/var/lib/archipelago/nginx-proxy-manager/data:/data".to_string(),
|
||||
"/var/lib/archipelago/nginx-proxy-manager/letsencrypt:/etc/letsencrypt".to_string(),
|
||||
],
|
||||
vec![],
|
||||
None,
|
||||
None,
|
||||
)
|
||||
}
|
||||
"portainer" => (
|
||||
vec!["9000:9000".to_string()],
|
||||
vec![
|
||||
@@ -1049,31 +1171,11 @@ pub(super) async fn get_app_config(
|
||||
None,
|
||||
),
|
||||
_ => {
|
||||
// Unknown app: try to load config from /var/lib/archipelago/app-configs/{id}.json
|
||||
// This allows dynamic apps from the remote catalog to be installed
|
||||
// without hardcoding their config here.
|
||||
let config_path = format!("/var/lib/archipelago/app-configs/{}.json", app_id);
|
||||
if let Ok(data) = tokio::fs::read_to_string(&config_path).await {
|
||||
if let Ok(cfg) = serde_json::from_str::<serde_json::Value>(&data) {
|
||||
let ports = cfg.get("ports")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect())
|
||||
.unwrap_or_default();
|
||||
let volumes = cfg.get("volumes")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect())
|
||||
.unwrap_or_default();
|
||||
let env_vars = cfg.get("env")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect())
|
||||
.unwrap_or_default();
|
||||
tracing::info!("Loaded dynamic config for app: {}", app_id);
|
||||
return (ports, volumes, env_vars, None, None);
|
||||
}
|
||||
}
|
||||
// No config found — use minimal defaults (container's own EXPOSE/VOLUME)
|
||||
tracing::warn!("No config found for app: {} — using minimal defaults", app_id);
|
||||
// No catalog runtime metadata found; use minimal defaults
|
||||
// (container's own EXPOSE/VOLUME). New generic apps should declare
|
||||
// containerConfig in the registry catalog instead of adding Rust cases.
|
||||
tracing::warn!("No catalog runtime config found for app: {} — using minimal defaults", app_id);
|
||||
(vec![], vec![], vec![], None, None)
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ 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"];
|
||||
const ARCHIVAL_BITCOIN_DISK_GB: u64 = 1000;
|
||||
|
||||
fn requires_unpruned_bitcoin(package_id: &str) -> bool {
|
||||
matches!(
|
||||
@@ -17,6 +18,12 @@ fn requires_unpruned_bitcoin(package_id: &str) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
fn archival_bitcoin_required_message(package_id: &str) -> String {
|
||||
format!(
|
||||
"Requires an archival Bitcoin node while indexing: {package_id}. This node is running pruned Bitcoin because it does not have enough disk for full block history. Add enough storage for an archival node (about 1 TB or more), resync Bitcoin without pruning/with txindex, then install {package_id}."
|
||||
)
|
||||
}
|
||||
|
||||
/// Snapshot of which dependency services are currently running.
|
||||
pub(super) struct RunningDeps {
|
||||
pub has_bitcoin: bool,
|
||||
@@ -135,27 +142,57 @@ pub(super) async fn check_bitcoin_pruning_compatibility(package_id: &str) -> Res
|
||||
.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 mut last_error = None;
|
||||
for _ in 0..3 {
|
||||
match client
|
||||
.post(crate::constants::BITCOIN_RPC_URL)
|
||||
.basic_auth(&rpc_user, Some(&rpc_pass))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
match resp.json::<serde_json::Value>().await {
|
||||
Ok(json) if status.is_success() => {
|
||||
if let Some(error) = json.get("error").filter(|e| !e.is_null()) {
|
||||
last_error = Some(format!(
|
||||
"Bitcoin RPC error while checking pruning status: {error}"
|
||||
));
|
||||
} else {
|
||||
return check_blockchain_info_for_pruning(package_id, &json);
|
||||
}
|
||||
}
|
||||
Ok(json) => {
|
||||
last_error = Some(format!(
|
||||
"Bitcoin RPC returned {status} while checking pruning status: {json}"
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
last_error = Some(format!("decode Bitcoin RPC response: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
last_error = Some(format!("checking Bitcoin pruning status: {e}"));
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
}
|
||||
|
||||
if detect_disk_gb() < ARCHIVAL_BITCOIN_DISK_GB {
|
||||
anyhow::bail!(archival_bitcoin_required_message(package_id));
|
||||
}
|
||||
|
||||
anyhow::bail!(
|
||||
"Bitcoin RPC unavailable while checking pruning status: {}",
|
||||
last_error.unwrap_or_else(|| "unknown error".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
fn check_blockchain_info_for_pruning(package_id: &str, json: &serde_json::Value) -> Result<()> {
|
||||
let Some(result) = json.get("result") else {
|
||||
anyhow::bail!("Bitcoin RPC response missing result while checking pruning status");
|
||||
};
|
||||
@@ -164,16 +201,28 @@ pub(super) async fn check_bitcoin_pruning_compatibility(package_id: &str) -> Res
|
||||
.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
|
||||
);
|
||||
anyhow::bail!(archival_bitcoin_required_message(package_id));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn detect_disk_gb() -> u64 {
|
||||
let output = std::process::Command::new("df")
|
||||
.args(["-BG", "/var/lib/archipelago"])
|
||||
.output();
|
||||
let Ok(output) = output else {
|
||||
return u64::MAX;
|
||||
};
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
stdout
|
||||
.lines()
|
||||
.nth(1)
|
||||
.and_then(|line| line.split_whitespace().nth(1))
|
||||
.and_then(|size| size.trim_end_matches('G').parse::<u64>().ok())
|
||||
.unwrap_or(u64::MAX)
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -254,17 +303,23 @@ pub(super) fn startup_order(package_id: &str) -> &'static [&'static str] {
|
||||
/// order for the given app. Unknown containers sort to the end.
|
||||
pub(super) async fn ordered_containers_for_start(package_id: &str) -> Result<Vec<String>> {
|
||||
let containers = get_containers_for_app(package_id).await?;
|
||||
if containers.is_empty() {
|
||||
return Ok(vec![format!("archy-{}", package_id)]);
|
||||
}
|
||||
let order = startup_order(package_id);
|
||||
// If no special order defined, fall back to mempool order (legacy behavior)
|
||||
if order.is_empty() && containers.is_empty() {
|
||||
return Ok(vec![package_id.to_string()]);
|
||||
}
|
||||
let mut sorted = containers;
|
||||
for required in order {
|
||||
if !sorted.iter().any(|name| name == required) {
|
||||
sorted.push((*required).to_string());
|
||||
}
|
||||
}
|
||||
// If no special order is defined, fall back to mempool order for legacy
|
||||
// multi-container names that may still be returned by config lookups.
|
||||
let effective_order: &[&str] = if order.is_empty() {
|
||||
startup_order("mempool")
|
||||
} else {
|
||||
order
|
||||
};
|
||||
let mut sorted = containers;
|
||||
sorted.sort_by_key(|c| effective_order.iter().position(|o| *o == c).unwrap_or(99));
|
||||
Ok(sorted)
|
||||
}
|
||||
@@ -324,7 +379,15 @@ pub(super) fn configure_fedimint_lnd(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::requires_unpruned_bitcoin;
|
||||
use super::{requires_unpruned_bitcoin, startup_order};
|
||||
|
||||
#[test]
|
||||
fn btcpay_start_order_includes_required_stack_members() {
|
||||
assert_eq!(
|
||||
startup_order("btcpay-server"),
|
||||
&["archy-btcpay-db", "archy-nbxplorer", "btcpay-server"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unpruned_bitcoin_required_for_electrum_indexers_and_mempool() {
|
||||
|
||||
@@ -14,6 +14,7 @@ use crate::data_model::InstallPhase;
|
||||
use crate::update::host_sudo;
|
||||
use anyhow::{Context, Result};
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tokio::time::{timeout, Duration};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
const INSTALL_LOG: &str = "/var/log/archipelago/container-installs.log";
|
||||
@@ -221,6 +222,12 @@ impl RpcHandler {
|
||||
self.set_install_phase(package_id, InstallPhase::Preparing)
|
||||
.await;
|
||||
|
||||
if matches!(package_id, "mempool" | "mempool-web") {
|
||||
let deps = self.running_deps_for_install(package_id).await?;
|
||||
check_install_deps(package_id, &deps)?;
|
||||
check_bitcoin_pruning_compatibility(package_id).await?;
|
||||
}
|
||||
|
||||
// Multi-container stacks get their own install path
|
||||
if package_id == "immich" {
|
||||
return self.install_immich_stack().await;
|
||||
@@ -239,15 +246,7 @@ impl RpcHandler {
|
||||
// congested Podman API does not turn an already-running dependency into
|
||||
// a false install failure. Fall back to a bounded direct Podman probe
|
||||
// only when the cache does not show the dependency.
|
||||
let deps = {
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let cached = detect_running_deps_from_package_data(&data.package_data);
|
||||
if dependency_cache_satisfies(package_id, &cached) {
|
||||
cached
|
||||
} else {
|
||||
detect_running_deps().await?
|
||||
}
|
||||
};
|
||||
let deps = self.running_deps_for_install(package_id).await?;
|
||||
check_install_deps(package_id, &deps)?;
|
||||
check_bitcoin_pruning_compatibility(package_id).await?;
|
||||
log_optional_dep_info(package_id, &deps);
|
||||
@@ -362,31 +361,66 @@ impl RpcHandler {
|
||||
if !start_output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&start_output.stderr);
|
||||
install_log(&format!(
|
||||
"INSTALL ADOPT FAIL: {} — start failed: {}",
|
||||
"INSTALL ADOPT RECREATE: {} — start failed, removing wedged container: {}",
|
||||
package_id, stderr
|
||||
))
|
||||
.await;
|
||||
return Err(anyhow::anyhow!(
|
||||
"Container {} exists but failed to start: {}",
|
||||
package_id,
|
||||
stderr
|
||||
));
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["rm", "-f", package_id])
|
||||
.output()
|
||||
.await;
|
||||
} else {
|
||||
wait_for_adopted_container(package_id, package_id).await?;
|
||||
install_log(&format!(
|
||||
"INSTALL ADOPT OK: {} — already running",
|
||||
package_id
|
||||
))
|
||||
.await;
|
||||
if let Err(e) = ensure_host_port_listener(package_id, package_id, &[]).await
|
||||
{
|
||||
install_log(&format!(
|
||||
"INSTALL ADOPT RECREATE: {} — host listener repair failed, removing wedged container: {}",
|
||||
package_id, e
|
||||
))
|
||||
.await;
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["rm", "-f", package_id])
|
||||
.output()
|
||||
.await;
|
||||
} else {
|
||||
return Ok(serde_json::json!({
|
||||
"success": true,
|
||||
"package_id": package_id,
|
||||
"message": format!("Package {} already installed and running", package_id)
|
||||
}));
|
||||
}
|
||||
}
|
||||
// Fall through to the fresh install path. Volume-backed data
|
||||
// remains under /var/lib/archipelago and is not deleted.
|
||||
} else {
|
||||
install_log(&format!(
|
||||
"INSTALL ADOPT OK: {} — already running",
|
||||
package_id
|
||||
))
|
||||
.await;
|
||||
if let Err(e) = ensure_host_port_listener(package_id, package_id, &[]).await {
|
||||
install_log(&format!(
|
||||
"INSTALL ADOPT RECREATE: {} — host listener repair failed, removing wedged container: {}",
|
||||
package_id, e
|
||||
))
|
||||
.await;
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["rm", "-f", package_id])
|
||||
.output()
|
||||
.await;
|
||||
} else {
|
||||
return Ok(serde_json::json!({
|
||||
"success": true,
|
||||
"package_id": package_id,
|
||||
"message": format!("Package {} already installed and running", package_id)
|
||||
}));
|
||||
}
|
||||
|
||||
wait_for_adopted_container(package_id, package_id).await?;
|
||||
}
|
||||
|
||||
install_log(&format!(
|
||||
"INSTALL ADOPT OK: {} — already running",
|
||||
package_id
|
||||
))
|
||||
.await;
|
||||
ensure_host_port_listener(package_id, package_id).await?;
|
||||
return Ok(serde_json::json!({
|
||||
"success": true,
|
||||
"package_id": package_id,
|
||||
"message": format!("Package {} already installed and running", package_id)
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -513,7 +547,20 @@ impl RpcHandler {
|
||||
let network_alias_flag = format!("--network-alias={}", container_name);
|
||||
|
||||
// Network mode
|
||||
if package_id == "uptime-kuma" || package_id == "gitea" || package_id == "tailscale" {
|
||||
if matches!(
|
||||
package_id,
|
||||
"uptime-kuma"
|
||||
| "gitea"
|
||||
| "tailscale"
|
||||
| "vaultwarden"
|
||||
| "homeassistant"
|
||||
| "home-assistant"
|
||||
| "nextcloud"
|
||||
| "searxng"
|
||||
| "jellyfin"
|
||||
| "nginx-proxy-manager"
|
||||
| "portainer"
|
||||
) {
|
||||
// These standalone web UIs have repeatedly lost host listeners
|
||||
// under Podman's rootless pasta backend while staying healthy internally.
|
||||
// Use slirp4netns/rootlessport for this standalone web UI.
|
||||
@@ -599,6 +646,10 @@ impl RpcHandler {
|
||||
self.write_lnd_conf(&rpc_user, &rpc_pass).await?;
|
||||
}
|
||||
|
||||
if package_id == "portainer" {
|
||||
ensure_user_podman_socket().await?;
|
||||
}
|
||||
|
||||
// Pre-install: SearXNG settings.yml (required or container exits immediately)
|
||||
if package_id == "searxng" {
|
||||
let searx_dir = "/var/lib/archipelago/searxng";
|
||||
@@ -705,6 +756,10 @@ impl RpcHandler {
|
||||
if !run_output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&run_output.stderr).to_string();
|
||||
if cleanup_start_conflict(package_id, &stderr).await {
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["rm", "-f", container_name])
|
||||
.output()
|
||||
.await;
|
||||
run_output = cmd.output().await.context("Failed to rerun container")?;
|
||||
}
|
||||
}
|
||||
@@ -750,11 +805,14 @@ impl RpcHandler {
|
||||
.await;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
let status = tokio::process::Command::new("podman")
|
||||
.args(["inspect", container_name, "--format", "{{.State.Status}}"])
|
||||
.output()
|
||||
.await;
|
||||
if let Ok(o) = status {
|
||||
let status = timeout(
|
||||
Duration::from_secs(10),
|
||||
tokio::process::Command::new("podman")
|
||||
.args(["inspect", container_name, "--format", "{{.State.Status}}"])
|
||||
.output(),
|
||||
)
|
||||
.await;
|
||||
if let Ok(Ok(o)) = status {
|
||||
let state = String::from_utf8_lossy(&o.stdout).trim().to_string();
|
||||
if state == "running" {
|
||||
container_running = true;
|
||||
@@ -787,6 +845,8 @@ impl RpcHandler {
|
||||
log_output.chars().take(500).collect::<String>()
|
||||
));
|
||||
}
|
||||
} else {
|
||||
warn!("podman inspect {} timed out during install", container_name);
|
||||
}
|
||||
if i == 11 {
|
||||
warn!(
|
||||
@@ -821,7 +881,7 @@ impl RpcHandler {
|
||||
repair_nextcloud_permissions().await;
|
||||
}
|
||||
|
||||
ensure_host_port_listener(package_id, container_name).await?;
|
||||
ensure_host_port_listener(package_id, container_name, &ports).await?;
|
||||
|
||||
install_log(&format!(
|
||||
"INSTALL OK: {} (container: {})",
|
||||
@@ -838,6 +898,16 @@ impl RpcHandler {
|
||||
}))
|
||||
}
|
||||
|
||||
async fn running_deps_for_install(&self, package_id: &str) -> Result<RunningDeps> {
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let cached = detect_running_deps_from_package_data(&data.package_data);
|
||||
if dependency_cache_satisfies(package_id, &cached) {
|
||||
Ok(cached)
|
||||
} else {
|
||||
detect_running_deps().await
|
||||
}
|
||||
}
|
||||
|
||||
// -- Private helpers for install --
|
||||
|
||||
/// Pull the image from a registry or verify a local image exists.
|
||||
@@ -1788,6 +1858,7 @@ autopilot.active=false\n",
|
||||
async fn cleanup_stale_package_ports(package_id: &str) {
|
||||
match package_id {
|
||||
"grafana" => cleanup_stale_pasta_port("3000").await,
|
||||
"homeassistant" | "home-assistant" => cleanup_stale_pasta_port("8123").await,
|
||||
"searxng" => cleanup_stale_pasta_port("8888").await,
|
||||
"uptime-kuma" => cleanup_stale_pasta_port("3002").await,
|
||||
"gitea" => {
|
||||
@@ -1795,11 +1866,21 @@ async fn cleanup_stale_package_ports(package_id: &str) {
|
||||
cleanup_stale_pasta_port("2222").await;
|
||||
cleanup_stale_pasta_port("3000").await;
|
||||
}
|
||||
"nginx-proxy-manager" => {
|
||||
cleanup_stale_pasta_port("8081").await;
|
||||
cleanup_stale_pasta_port("8084").await;
|
||||
cleanup_stale_pasta_port("8444").await;
|
||||
}
|
||||
"nextcloud" => cleanup_stale_pasta_port("8085").await,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
async fn cleanup_start_conflict(package_id: &str, stderr: &str) -> bool {
|
||||
if stderr.contains("name is already in use") || stderr.contains("name \"") {
|
||||
return true;
|
||||
}
|
||||
|
||||
match package_id {
|
||||
"grafana"
|
||||
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
|
||||
@@ -1807,6 +1888,12 @@ async fn cleanup_start_conflict(package_id: &str, stderr: &str) -> bool {
|
||||
cleanup_stale_pasta_port("3000").await;
|
||||
true
|
||||
}
|
||||
"homeassistant" | "home-assistant"
|
||||
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
|
||||
{
|
||||
cleanup_stale_pasta_port("8123").await;
|
||||
true
|
||||
}
|
||||
"searxng"
|
||||
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
|
||||
{
|
||||
@@ -1825,6 +1912,20 @@ async fn cleanup_start_conflict(package_id: &str, stderr: &str) -> bool {
|
||||
cleanup_stale_pasta_port("3000").await;
|
||||
true
|
||||
}
|
||||
"nginx-proxy-manager"
|
||||
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
|
||||
{
|
||||
cleanup_stale_pasta_port("8081").await;
|
||||
cleanup_stale_pasta_port("8084").await;
|
||||
cleanup_stale_pasta_port("8444").await;
|
||||
true
|
||||
}
|
||||
"nextcloud"
|
||||
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
|
||||
{
|
||||
cleanup_stale_pasta_port("8085").await;
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -1839,6 +1940,11 @@ async fn cleanup_stale_pasta_port(port: &str) {
|
||||
.output()
|
||||
.await;
|
||||
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["fuser", "-k", &format!("{}/tcp", port)])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
let pattern = format!("pasta.*{}", port);
|
||||
let _ = tokio::process::Command::new("pkill")
|
||||
.args(["-f", &pattern])
|
||||
@@ -1863,8 +1969,18 @@ async fn repair_nextcloud_permissions() {
|
||||
}
|
||||
}
|
||||
|
||||
async fn ensure_host_port_listener(package_id: &str, container_name: &str) -> Result<()> {
|
||||
let Some(port) = required_host_port(package_id) else {
|
||||
async fn ensure_host_port_listener(
|
||||
package_id: &str,
|
||||
container_name: &str,
|
||||
runtime_ports: &[String],
|
||||
) -> Result<()> {
|
||||
let Some(port) = runtime_ports
|
||||
.first()
|
||||
.and_then(|p| p.split(':').next())
|
||||
.and_then(|p| p.parse::<u16>().ok())
|
||||
.or_else(|| published_host_port(container_name))
|
||||
.or_else(|| required_host_port(package_id))
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
@@ -1910,12 +2026,56 @@ async fn ensure_host_port_listener(package_id: &str, container_name: &str) -> Re
|
||||
))
|
||||
}
|
||||
|
||||
fn published_host_port(container_name: &str) -> Option<u16> {
|
||||
let output = std::process::Command::new("podman")
|
||||
.args(["port", container_name])
|
||||
.output()
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
stdout.lines().find_map(|line| {
|
||||
line.rsplit(':')
|
||||
.next()
|
||||
.and_then(|p| p.trim().parse::<u16>().ok())
|
||||
})
|
||||
}
|
||||
|
||||
async fn ensure_user_podman_socket() -> Result<()> {
|
||||
let socket_path = "/run/user/1000/podman/podman.sock";
|
||||
if tokio::fs::try_exists(socket_path).await.unwrap_or(false) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let status = tokio::process::Command::new("systemctl")
|
||||
.args(["--user", "restart", "podman.socket"])
|
||||
.status()
|
||||
.await
|
||||
.context("spawn systemctl --user restart podman.socket")?;
|
||||
if !status.success() {
|
||||
anyhow::bail!("systemctl --user restart podman.socket exited {status}");
|
||||
}
|
||||
|
||||
for _ in 0..20 {
|
||||
if tokio::fs::try_exists(socket_path).await.unwrap_or(false) {
|
||||
return Ok(());
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
}
|
||||
|
||||
anyhow::bail!("podman socket {socket_path} did not appear after restart")
|
||||
}
|
||||
|
||||
fn required_host_port(package_id: &str) -> Option<u16> {
|
||||
match package_id {
|
||||
"grafana" => Some(3000),
|
||||
"homeassistant" | "home-assistant" => Some(8123),
|
||||
"searxng" => Some(8888),
|
||||
"uptime-kuma" => Some(3002),
|
||||
"gitea" => Some(3001),
|
||||
"nextcloud" => Some(8085),
|
||||
"nginx-proxy-manager" => Some(8081),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
use super::config::{get_containers_for_app, get_data_dirs_for_app, is_valid_docker_image};
|
||||
use super::config::{
|
||||
get_app_capabilities, get_containers_for_app, get_data_dirs_for_app, get_health_check_args,
|
||||
get_memory_limit, is_valid_docker_image,
|
||||
};
|
||||
use super::dependencies::ordered_containers_for_start;
|
||||
use super::install::install_log;
|
||||
use super::validation::validate_app_id;
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use crate::data_model::PackageState;
|
||||
use anyhow::{Context, Result};
|
||||
use std::process::Output;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tracing::warn;
|
||||
|
||||
const PODMAN_CONTROL_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const PODMAN_LOG_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
/// Per-container graceful shutdown timeout in seconds.
|
||||
/// Bitcoin Core needs 600s to flush UTXO set, LND 330s for channel state,
|
||||
/// indexers 300s for index flush, databases 120s for WAL/transaction commit.
|
||||
@@ -292,10 +300,7 @@ impl RpcHandler {
|
||||
)
|
||||
.await;
|
||||
tracing::info!("Uninstall {}: stopping container {}", package_id, name);
|
||||
let stop_out = tokio::process::Command::new("podman")
|
||||
.args(["stop", "-t", stop_timeout_secs(name), name])
|
||||
.output()
|
||||
.await;
|
||||
let stop_out = podman_control(&["stop", "-t", stop_timeout_secs(name), name]).await;
|
||||
match stop_out {
|
||||
Ok(o) if o.status.success() => stopped += 1,
|
||||
Ok(o) => {
|
||||
@@ -314,10 +319,7 @@ impl RpcHandler {
|
||||
|
||||
// Remove container (without -f to respect graceful shutdown above)
|
||||
tracing::info!("Uninstall {}: removing container {}", package_id, name);
|
||||
let rm_out = tokio::process::Command::new("podman")
|
||||
.args(["rm", name])
|
||||
.output()
|
||||
.await;
|
||||
let rm_out = podman_control(&["rm", name]).await;
|
||||
match rm_out {
|
||||
Ok(o) if o.status.success() => removed += 1,
|
||||
Ok(o) => {
|
||||
@@ -329,10 +331,7 @@ impl RpcHandler {
|
||||
name,
|
||||
stderr.trim()
|
||||
);
|
||||
let force_rm = tokio::process::Command::new("podman")
|
||||
.args(["rm", "-f", name])
|
||||
.output()
|
||||
.await;
|
||||
let force_rm = podman_control(&["rm", "-f", name]).await;
|
||||
match force_rm {
|
||||
Ok(o2) if o2.status.success() => removed += 1,
|
||||
_ => {
|
||||
@@ -353,10 +352,7 @@ impl RpcHandler {
|
||||
self.set_uninstall_stage(package_id, "Cleaning up volumes")
|
||||
.await;
|
||||
// Clean up dangling volumes associated with removed containers
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["volume", "prune", "-f"])
|
||||
.output()
|
||||
.await;
|
||||
let _ = podman_control(&["volume", "prune", "-f"]).await;
|
||||
|
||||
// Clean up app-specific networks (only if no other containers use them)
|
||||
let app_networks: Vec<&str> = match package_id {
|
||||
@@ -366,16 +362,17 @@ impl RpcHandler {
|
||||
_ => vec![],
|
||||
};
|
||||
for net in &app_networks {
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["network", "rm", net])
|
||||
.output()
|
||||
.await;
|
||||
let _ = podman_control(&["network", "rm", net]).await;
|
||||
}
|
||||
|
||||
// Release port allocation
|
||||
{
|
||||
let mut allocator = self.port_allocator.lock().await;
|
||||
let _ = allocator.release(package_id).await;
|
||||
if package_id == "nginx-proxy-manager" {
|
||||
let _ = allocator.release("nginx-proxy-manager-http").await;
|
||||
let _ = allocator.release("nginx-proxy-manager-https").await;
|
||||
}
|
||||
}
|
||||
|
||||
// Clean data directories unless preserve_data
|
||||
@@ -485,18 +482,16 @@ impl RpcHandler {
|
||||
.and_then(|v| v.as_array())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing volumes"))?;
|
||||
|
||||
let check_output = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"ps",
|
||||
"-a",
|
||||
"--format",
|
||||
"{{.Names}}",
|
||||
"--filter",
|
||||
&format!("name={}", app_id),
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to check container")?;
|
||||
let check_output = podman_control(&[
|
||||
"ps",
|
||||
"-a",
|
||||
"--format",
|
||||
"{{.Names}}",
|
||||
"--filter",
|
||||
&format!("name={}", app_id),
|
||||
])
|
||||
.await
|
||||
.context("Failed to check container")?;
|
||||
|
||||
let existing = String::from_utf8_lossy(&check_output.stdout);
|
||||
|
||||
@@ -541,16 +536,14 @@ impl RpcHandler {
|
||||
|
||||
cmd.arg(image);
|
||||
|
||||
let output = cmd.output().await.context("Failed to create container")?;
|
||||
let output = command_with_timeout(cmd, PODMAN_CONTROL_TIMEOUT, "podman run").await?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow::anyhow!("Failed to create container: {}", stderr));
|
||||
}
|
||||
} else {
|
||||
let output = tokio::process::Command::new("podman")
|
||||
.args(["start", app_id])
|
||||
.output()
|
||||
let output = podman_control(&["start", app_id])
|
||||
.await
|
||||
.context("Failed to start container")?;
|
||||
|
||||
@@ -575,9 +568,7 @@ impl RpcHandler {
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
|
||||
validate_app_id(app_id)?;
|
||||
|
||||
let output = tokio::process::Command::new("podman")
|
||||
.args(["stop", "-t", stop_timeout_secs(app_id), app_id])
|
||||
.output()
|
||||
let output = podman_control(&["stop", "-t", stop_timeout_secs(app_id), app_id])
|
||||
.await
|
||||
.context("Failed to stop container")?;
|
||||
|
||||
@@ -611,9 +602,7 @@ async fn do_package_start(to_start: &[String]) -> Result<()> {
|
||||
}
|
||||
repair_before_package_start(name).await;
|
||||
tracing::info!("Starting container: {}", name);
|
||||
let out = tokio::process::Command::new("podman")
|
||||
.args(["start", name])
|
||||
.output()
|
||||
let out = podman_control(&["start", name])
|
||||
.await
|
||||
.context(format!("Failed to exec podman start {}", name))?;
|
||||
if !out.status.success() {
|
||||
@@ -632,17 +621,12 @@ async fn do_package_start(to_start: &[String]) -> Result<()> {
|
||||
// container exits immediately after).
|
||||
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
|
||||
for name in to_start {
|
||||
let status = tokio::process::Command::new("podman")
|
||||
.args(["inspect", name, "--format", "{{.State.Status}}"])
|
||||
.output()
|
||||
.await;
|
||||
let status = podman_control(&["inspect", name, "--format", "{{.State.Status}}"]).await;
|
||||
if let Ok(o) = status {
|
||||
let state = String::from_utf8_lossy(&o.stdout).trim().to_string();
|
||||
if state == "exited" {
|
||||
let logs = tokio::process::Command::new("podman")
|
||||
.args(["logs", "--tail", "5", name])
|
||||
.output()
|
||||
.await;
|
||||
let logs =
|
||||
podman_with_timeout(&["logs", "--tail", "5", name], PODMAN_LOG_TIMEOUT).await;
|
||||
let log_text = logs
|
||||
.map(|o| {
|
||||
let combined = format!(
|
||||
@@ -684,6 +668,7 @@ async fn do_orchestrator_package_start(
|
||||
if i > 0 {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
}
|
||||
repair_before_package_start(name).await;
|
||||
match orchestrator.start(name).await {
|
||||
Ok(()) => wait_after_orchestrator_start(name).await,
|
||||
Err(e) if is_unknown_app_id_error(&e) => {
|
||||
@@ -714,6 +699,39 @@ async fn wait_after_orchestrator_start(name: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
async fn podman_control(args: &[&str]) -> Result<Output> {
|
||||
podman_with_timeout(args, podman_control_timeout(args)).await
|
||||
}
|
||||
|
||||
fn podman_control_timeout(args: &[&str]) -> Duration {
|
||||
args.windows(2)
|
||||
.find_map(|pair| {
|
||||
(pair[0] == "-t")
|
||||
.then(|| pair[1].parse::<u64>().ok())
|
||||
.flatten()
|
||||
})
|
||||
.map(|secs| Duration::from_secs(secs.saturating_add(30)))
|
||||
.unwrap_or(PODMAN_CONTROL_TIMEOUT)
|
||||
}
|
||||
|
||||
async fn podman_with_timeout(args: &[&str], timeout: Duration) -> Result<Output> {
|
||||
let mut cmd = tokio::process::Command::new("podman");
|
||||
cmd.args(args);
|
||||
command_with_timeout(cmd, timeout, &format!("podman {}", args.join(" "))).await
|
||||
}
|
||||
|
||||
async fn command_with_timeout(
|
||||
mut cmd: tokio::process::Command,
|
||||
timeout: Duration,
|
||||
description: &str,
|
||||
) -> Result<Output> {
|
||||
cmd.kill_on_drop(true);
|
||||
tokio::time::timeout(timeout, cmd.output())
|
||||
.await
|
||||
.with_context(|| format!("{} timed out after {}s", description, timeout.as_secs()))?
|
||||
.with_context(|| format!("Failed to exec {}", description))
|
||||
}
|
||||
|
||||
async fn do_orchestrator_package_stop(
|
||||
orchestrator: &dyn crate::container::traits::ContainerOrchestrator,
|
||||
containers: &[String],
|
||||
@@ -757,9 +775,7 @@ async fn do_package_stop(containers: &[String]) -> Result<()> {
|
||||
name,
|
||||
stop_timeout_secs(name)
|
||||
);
|
||||
let out = tokio::process::Command::new("podman")
|
||||
.args(["stop", "-t", stop_timeout_secs(name), name])
|
||||
.output()
|
||||
let out = podman_control(&["stop", "-t", stop_timeout_secs(name), name])
|
||||
.await
|
||||
.context(format!("Failed to exec podman stop {}", name))?;
|
||||
if !out.status.success() {
|
||||
@@ -785,9 +801,7 @@ async fn do_package_restart(containers: &[String]) -> Result<()> {
|
||||
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()
|
||||
let out = podman_control(&["restart", "-t", stop_timeout_secs(name), name])
|
||||
.await
|
||||
.context(format!("Failed to exec podman restart {}", name))?;
|
||||
|
||||
@@ -803,13 +817,8 @@ async fn do_package_restart(containers: &[String]) -> Result<()> {
|
||||
stderr
|
||||
);
|
||||
// Fallback: stop then start
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["stop", "-t", stop_timeout_secs(name), name])
|
||||
.output()
|
||||
.await;
|
||||
let start_out = tokio::process::Command::new("podman")
|
||||
.args(["start", name])
|
||||
.output()
|
||||
let _ = podman_control(&["stop", "-t", stop_timeout_secs(name), name]).await;
|
||||
let start_out = podman_control(&["start", name])
|
||||
.await
|
||||
.context(format!("Failed to exec podman start {}", name))?;
|
||||
if !start_out.status.success() {
|
||||
@@ -852,11 +861,153 @@ async fn repair_before_package_start(container_name: &str) {
|
||||
repair_grafana_dirs().await;
|
||||
cleanup_stale_pasta_port("3000").await;
|
||||
}
|
||||
"vaultwarden" => cleanup_stale_pasta_port("8082").await,
|
||||
"homeassistant" | "home-assistant" => cleanup_stale_pasta_port("8123").await,
|
||||
"nextcloud" => {
|
||||
repair_nextcloud_dirs().await;
|
||||
cleanup_stale_pasta_port("8085").await;
|
||||
}
|
||||
"nginx-proxy-manager" => repair_nginx_proxy_manager_container().await,
|
||||
"gitea" => cleanup_gitea_stale_ports().await,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
async fn repair_nginx_proxy_manager_container() {
|
||||
repair_nginx_proxy_manager_dirs().await;
|
||||
if !nginx_proxy_manager_has_legacy_admin_port().await {
|
||||
cleanup_nginx_proxy_manager_ports().await;
|
||||
return;
|
||||
}
|
||||
|
||||
install_log(
|
||||
"START REPAIR: nginx-proxy-manager - recreating stale container using host port 8081",
|
||||
)
|
||||
.await;
|
||||
let _ = podman_control(&["rm", "-f", "nginx-proxy-manager"]).await;
|
||||
cleanup_nginx_proxy_manager_ports().await;
|
||||
if let Err(err) = recreate_nginx_proxy_manager_container().await {
|
||||
tracing::warn!(error = %err, "failed to recreate stale nginx-proxy-manager container");
|
||||
}
|
||||
}
|
||||
|
||||
async fn repair_nginx_proxy_manager_dirs() {
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args([
|
||||
"mkdir",
|
||||
"-p",
|
||||
"/var/lib/archipelago/nginx-proxy-manager/data/letsencrypt-acme-challenge/.well-known/acme-challenge",
|
||||
"/var/lib/archipelago/nginx-proxy-manager/letsencrypt",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args([
|
||||
"chown",
|
||||
"-R",
|
||||
"1000:1000",
|
||||
"/var/lib/archipelago/nginx-proxy-manager",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn nginx_proxy_manager_has_legacy_admin_port() -> bool {
|
||||
if let Ok(output) = podman_control(&["port", "nginx-proxy-manager", "81/tcp"]).await {
|
||||
if output.status.success()
|
||||
&& String::from_utf8_lossy(&output.stdout).lines().any(|line| {
|
||||
line.rsplit(':')
|
||||
.next()
|
||||
.is_some_and(|port| port.trim() == "81")
|
||||
})
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
let Ok(output) = podman_control(&[
|
||||
"ps",
|
||||
"-a",
|
||||
"--format",
|
||||
"{{.Ports}}",
|
||||
"--filter",
|
||||
"name=^nginx-proxy-manager$",
|
||||
])
|
||||
.await
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if !output.status.success() {
|
||||
return false;
|
||||
}
|
||||
let ports = String::from_utf8_lossy(&output.stdout);
|
||||
ports.contains(":81->81/tcp") || ports.contains(":8443->443/tcp")
|
||||
}
|
||||
|
||||
async fn recreate_nginx_proxy_manager_container() -> Result<()> {
|
||||
tokio::process::Command::new("sudo")
|
||||
.args([
|
||||
"mkdir",
|
||||
"-p",
|
||||
"/var/lib/archipelago/nginx-proxy-manager/data/letsencrypt-acme-challenge/.well-known/acme-challenge",
|
||||
"/var/lib/archipelago/nginx-proxy-manager/letsencrypt",
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
.context("failed to create nginx-proxy-manager data directories")?;
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args([
|
||||
"chown",
|
||||
"-R",
|
||||
"1000:1000",
|
||||
"/var/lib/archipelago/nginx-proxy-manager",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
let image = crate::container::image_versions::pinned_image_for_app("nginx-proxy-manager")
|
||||
.unwrap_or_else(|| "docker.io/jc21/nginx-proxy-manager:latest".to_string());
|
||||
let mut args = vec![
|
||||
"run".to_string(),
|
||||
"-d".to_string(),
|
||||
"--name".to_string(),
|
||||
"nginx-proxy-manager".to_string(),
|
||||
"--restart=unless-stopped".to_string(),
|
||||
"--network=slirp4netns:allow_host_loopback=true".to_string(),
|
||||
"--cap-drop=ALL".to_string(),
|
||||
"--security-opt=no-new-privileges:true".to_string(),
|
||||
"--pids-limit=4096".to_string(),
|
||||
];
|
||||
args.extend(get_app_capabilities("nginx-proxy-manager"));
|
||||
args.extend([
|
||||
"-p".to_string(),
|
||||
"8081:81".to_string(),
|
||||
"-p".to_string(),
|
||||
"8084:80".to_string(),
|
||||
"-p".to_string(),
|
||||
"8444:443".to_string(),
|
||||
"-v".to_string(),
|
||||
"/var/lib/archipelago/nginx-proxy-manager/data:/data".to_string(),
|
||||
"-v".to_string(),
|
||||
"/var/lib/archipelago/nginx-proxy-manager/letsencrypt:/etc/letsencrypt".to_string(),
|
||||
"--memory".to_string(),
|
||||
get_memory_limit("nginx-proxy-manager").to_string(),
|
||||
"--cpus=2".to_string(),
|
||||
]);
|
||||
args.extend(get_health_check_args("nginx-proxy-manager", ""));
|
||||
args.push(image);
|
||||
|
||||
let refs = args.iter().map(String::as_str).collect::<Vec<_>>();
|
||||
let output = podman_control(&refs).await?;
|
||||
if !output.status.success() {
|
||||
anyhow::bail!(
|
||||
"podman run nginx-proxy-manager failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn ensure_runtime_host_port_listener(container_name: &str) -> Result<()> {
|
||||
let Some(port) = runtime_required_host_port(container_name) else {
|
||||
return Ok(());
|
||||
@@ -871,9 +1022,7 @@ async fn ensure_runtime_host_port_listener(container_name: &str) -> Result<()> {
|
||||
container_name, port
|
||||
))
|
||||
.await;
|
||||
let output = tokio::process::Command::new("podman")
|
||||
.args(["restart", container_name])
|
||||
.output()
|
||||
let output = podman_control(&["restart", container_name])
|
||||
.await
|
||||
.context("failed to restart container after missing host port")?;
|
||||
if !output.status.success() {
|
||||
@@ -905,9 +1054,13 @@ async fn ensure_runtime_host_port_listener(container_name: &str) -> Result<()> {
|
||||
fn runtime_required_host_port(container_name: &str) -> Option<u16> {
|
||||
match container_name {
|
||||
"grafana" => Some(3000),
|
||||
"homeassistant" | "home-assistant" => Some(8123),
|
||||
"searxng" => Some(8888),
|
||||
"uptime-kuma" => Some(3002),
|
||||
"vaultwarden" => Some(8082),
|
||||
"gitea" => Some(3001),
|
||||
"nextcloud" => Some(8085),
|
||||
"nginx-proxy-manager" => Some(8081),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -957,16 +1110,14 @@ async fn repair_grafana_dirs() {
|
||||
.args(["mkdir", "-p", "/var/lib/archipelago/grafana"])
|
||||
.output()
|
||||
.await;
|
||||
let podman_chown = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"unshare",
|
||||
"chown",
|
||||
"-R",
|
||||
"472:472",
|
||||
"/var/lib/archipelago/grafana",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
let podman_chown = podman_control(&[
|
||||
"unshare",
|
||||
"chown",
|
||||
"-R",
|
||||
"472:472",
|
||||
"/var/lib/archipelago/grafana",
|
||||
])
|
||||
.await;
|
||||
if !podman_chown.as_ref().is_ok_and(|o| o.status.success()) {
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args([
|
||||
@@ -980,6 +1131,32 @@ async fn repair_grafana_dirs() {
|
||||
}
|
||||
}
|
||||
|
||||
async fn repair_nextcloud_dirs() {
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["mkdir", "-p", "/var/lib/archipelago/nextcloud"])
|
||||
.output()
|
||||
.await;
|
||||
let podman_chown = podman_control(&[
|
||||
"unshare",
|
||||
"chown",
|
||||
"-R",
|
||||
"33:33",
|
||||
"/var/lib/archipelago/nextcloud",
|
||||
])
|
||||
.await;
|
||||
if !podman_chown.as_ref().is_ok_and(|o| o.status.success()) {
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args([
|
||||
"chown",
|
||||
"-R",
|
||||
"100032:100032",
|
||||
"/var/lib/archipelago/nextcloud",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn repair_btcpay_database_password() {
|
||||
let Ok(db_pass) =
|
||||
tokio::fs::read_to_string("/var/lib/archipelago/secrets/btcpay-db-password").await
|
||||
@@ -991,39 +1168,32 @@ async fn repair_btcpay_database_password() {
|
||||
return;
|
||||
}
|
||||
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["start", "archy-btcpay-db"])
|
||||
.output()
|
||||
.await;
|
||||
let _ = podman_control(&["start", "archy-btcpay-db"]).await;
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
|
||||
let escaped = db_pass.replace('\'', "''");
|
||||
let sql = format!("ALTER USER btcpay WITH PASSWORD '{}';", escaped);
|
||||
let _ = 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;
|
||||
let _ = podman_control(&[
|
||||
"exec",
|
||||
"archy-btcpay-db",
|
||||
"psql",
|
||||
"-U",
|
||||
"btcpay",
|
||||
"-d",
|
||||
"btcpay",
|
||||
"-c",
|
||||
&sql,
|
||||
])
|
||||
.await;
|
||||
let _ = podman_control(&[
|
||||
"exec",
|
||||
"archy-btcpay-db",
|
||||
"createdb",
|
||||
"-U",
|
||||
"btcpay",
|
||||
"nbxplorer",
|
||||
])
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn repair_indeedhub_network() {
|
||||
@@ -1040,11 +1210,20 @@ async fn cleanup_start_conflict(container_name: &str, stderr: &str) {
|
||||
return;
|
||||
}
|
||||
|
||||
if container_name != "grafana" {
|
||||
return;
|
||||
match container_name {
|
||||
"grafana" => cleanup_stale_pasta_port("3000").await,
|
||||
"homeassistant" | "home-assistant" => cleanup_stale_pasta_port("8123").await,
|
||||
"vaultwarden" => cleanup_stale_pasta_port("8082").await,
|
||||
"nextcloud" => cleanup_stale_pasta_port("8085").await,
|
||||
"nginx-proxy-manager" => cleanup_nginx_proxy_manager_ports().await,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
cleanup_stale_pasta_port("3000").await;
|
||||
async fn cleanup_nginx_proxy_manager_ports() {
|
||||
cleanup_stale_pasta_port("8081").await;
|
||||
cleanup_stale_pasta_port("8084").await;
|
||||
cleanup_stale_pasta_port("8444").await;
|
||||
}
|
||||
|
||||
async fn cleanup_stale_pasta_port(port: &str) {
|
||||
|
||||
@@ -587,7 +587,7 @@ impl RpcHandler {
|
||||
let images = [
|
||||
&format!("{}/postgres:15.17", REGISTRY),
|
||||
&format!("{}/nbxplorer:2.6.0", REGISTRY),
|
||||
&format!("{}/btcpayserver:1.13.7", REGISTRY),
|
||||
"docker.io/btcpayserver/btcpayserver:2.3.9",
|
||||
];
|
||||
self.set_install_phase("btcpay-server", InstallPhase::PullingImage)
|
||||
.await;
|
||||
@@ -743,7 +743,7 @@ impl RpcHandler {
|
||||
"BTCPAY_POSTGRES=User ID=btcpay;Password={};Host=archy-btcpay-db;Port=5432;Database=btcpay;Include Error Detail=true",
|
||||
db_pass
|
||||
),
|
||||
&format!("{}/btcpayserver:1.13.7", REGISTRY),
|
||||
"docker.io/btcpayserver/btcpayserver:2.3.9",
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
@@ -775,6 +775,8 @@ impl RpcHandler {
|
||||
|
||||
/// Install Mempool stack (mariadb + mempool-api + mempool-web).
|
||||
pub(super) async fn install_mempool_stack(&self) -> Result<serde_json::Value> {
|
||||
super::dependencies::check_bitcoin_pruning_compatibility("mempool").await?;
|
||||
|
||||
if let Some(adopted) = adopt_stack_if_exists(
|
||||
"mempool",
|
||||
"mempool",
|
||||
|
||||
@@ -353,7 +353,7 @@ pub(in crate::api::rpc) fn known_service_port(name: &str) -> u16 {
|
||||
"immich" => 2283,
|
||||
"photoprism" => 2342,
|
||||
"penpot" => 9001,
|
||||
"nginx-proxy-manager" => 81,
|
||||
"nginx-proxy-manager" => 8081,
|
||||
"vaultwarden" => 8343,
|
||||
"indeedhub" => 7778,
|
||||
_ => 0,
|
||||
|
||||
@@ -381,14 +381,14 @@ async fn change_ssh_password(new_password: &str) -> Result<()> {
|
||||
|
||||
// usermod -p writes directly to /etc/shadow, bypassing PAM
|
||||
// Use /usr/sbin/usermod - not always in systemd's PATH
|
||||
let status = tokio::process::Command::new("/usr/sbin/usermod")
|
||||
.args(["-p", &hash, &ssh_user])
|
||||
let status = tokio::process::Command::new("/usr/bin/sudo")
|
||||
.args(["-n", "/usr/sbin/usermod", "-p", &hash, &ssh_user])
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
if !status.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&status.stderr);
|
||||
anyhow::bail!("usermod failed: {}", stderr);
|
||||
anyhow::bail!("sudo usermod failed: {}", stderr);
|
||||
}
|
||||
|
||||
tracing::info!("SSH password updated for user {}", ssh_user);
|
||||
|
||||
@@ -65,7 +65,9 @@ pub async fn ensure_doctor_installed() {
|
||||
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(true) => {
|
||||
info!("Repaired Bitcoin RPC bind settings; running Bitcoin containers left untouched")
|
||||
}
|
||||
Ok(false) => debug!("Bitcoin RPC bind settings already usable"),
|
||||
Err(e) => warn!("Bitcoin RPC repair failed (non-fatal): {:#}", e),
|
||||
}
|
||||
@@ -231,6 +233,24 @@ async fn run_runtime_assets() -> Result<bool> {
|
||||
}
|
||||
|
||||
let configs = runtime_dir.join("image-recipe/configs");
|
||||
let nginx_src = configs.join("nginx-archipelago.conf");
|
||||
if nginx_src.exists() {
|
||||
let src_s = nginx_src.to_string_lossy().to_string();
|
||||
let status = host_sudo(&[
|
||||
"install",
|
||||
"-m",
|
||||
"644",
|
||||
&src_s,
|
||||
"/etc/nginx/sites-available/archipelago",
|
||||
])
|
||||
.await
|
||||
.context("install nginx-archipelago.conf")?;
|
||||
if !status.success() {
|
||||
anyhow::bail!("install nginx-archipelago.conf exited with {}", status);
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
|
||||
for unit in ["archipelago-doctor.service", "archipelago-doctor.timer"] {
|
||||
let src = configs.join(unit);
|
||||
if src.exists() {
|
||||
@@ -248,6 +268,19 @@ async fn run_runtime_assets() -> Result<bool> {
|
||||
|
||||
if changed {
|
||||
let _ = host_sudo(&["systemctl", "daemon-reload"]).await;
|
||||
if nginx_src.exists() {
|
||||
match host_sudo(&["nginx", "-t"]).await {
|
||||
Ok(status) if status.success() => {
|
||||
let _ = host_sudo(&["systemctl", "reload", "nginx"]).await;
|
||||
}
|
||||
Ok(status) => {
|
||||
tracing::warn!("nginx config test failed after runtime sync: {}", status);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("failed to test nginx config after runtime sync: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(changed)
|
||||
}
|
||||
@@ -334,15 +367,11 @@ exit 2
|
||||
.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)
|
||||
}
|
||||
// Do not restart Bitcoin from bootstrap. During IBD, an automatic
|
||||
// restart can cost hours of progress. The repaired file is only a
|
||||
// fallback for future starts; current containers keep their command-line
|
||||
// RPC args until an operator or update intentionally restarts them.
|
||||
Some(2) => Ok(true),
|
||||
_ => {
|
||||
warn!("Bitcoin RPC repair helper exited with {}", status);
|
||||
Ok(false)
|
||||
|
||||
@@ -252,7 +252,8 @@ impl DevContainerOrchestrator {
|
||||
match status.state {
|
||||
archipelago_container::ContainerState::Running => Ok("healthy".to_string()),
|
||||
archipelago_container::ContainerState::Stopped
|
||||
| archipelago_container::ContainerState::Exited => Ok("unhealthy".to_string()),
|
||||
| archipelago_container::ContainerState::Exited
|
||||
| archipelago_container::ContainerState::Stopping => Ok("unhealthy".to_string()),
|
||||
archipelago_container::ContainerState::Created => Ok("starting".to_string()),
|
||||
archipelago_container::ContainerState::Paused => Ok("paused".to_string()),
|
||||
archipelago_container::ContainerState::Unknown(_) => Ok("unknown".to_string()),
|
||||
|
||||
@@ -61,6 +61,7 @@ impl DockerPackageScanner {
|
||||
"indeedhub-build_minio-init_1",
|
||||
"indeedhub-build_relay_1",
|
||||
"indeedhub-build_ffmpeg-worker_1",
|
||||
"buildx_buildkit_default",
|
||||
];
|
||||
|
||||
// First pass: collect running UI containers. Custom UI-backed apps must
|
||||
@@ -123,6 +124,11 @@ impl DockerPackageScanner {
|
||||
continue;
|
||||
}
|
||||
|
||||
if app_id.starts_with("buildx_buildkit") {
|
||||
debug!("Skipping BuildKit helper container: {}", app_id);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip UI containers (they're merged with their parent apps)
|
||||
if app_id.ends_with("-ui") {
|
||||
debug!("Skipping UI container: {}", app_id);
|
||||
@@ -136,11 +142,18 @@ impl DockerPackageScanner {
|
||||
let lan_address = if let Some(ui_address) = ui_containers.get(&app_id) {
|
||||
// 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())
|
||||
reachable_lan_address(&app_id, Some(ui_address.clone())).await
|
||||
} else {
|
||||
// Dynamic: use actual port bindings from container, fall back to static map
|
||||
extract_lan_address(&container.ports)
|
||||
.or_else(|| PodmanClient::lan_address_for(&app_id))
|
||||
// Prefer the known web UI port over arbitrary first binding
|
||||
// (for example Gitea exposes SSH on 2222 before web on 3001).
|
||||
let candidate = if uses_allocated_launch_port(&app_id) {
|
||||
extract_lan_address(&container.ports)
|
||||
.or_else(|| PodmanClient::lan_address_for(&app_id))
|
||||
} else {
|
||||
PodmanClient::lan_address_for(&app_id)
|
||||
.or_else(|| extract_lan_address(&container.ports))
|
||||
};
|
||||
reachable_lan_address(&app_id, candidate).await
|
||||
};
|
||||
|
||||
debug!(
|
||||
@@ -156,21 +169,8 @@ impl DockerPackageScanner {
|
||||
// Extract actual version from container image tag
|
||||
let running_version = image_versions::extract_version_from_image(&container.image);
|
||||
|
||||
// Check for available update by comparing running image vs pinned image
|
||||
let available_update =
|
||||
image_versions::pinned_image_for_app(&app_id).and_then(|pinned| {
|
||||
if pinned != container.image {
|
||||
let pinned_version = image_versions::extract_version_from_image(&pinned);
|
||||
// Don't flag if both are "latest" — no meaningful diff
|
||||
if pinned_version != "latest" || running_version != "latest" {
|
||||
Some(pinned_version)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
image_versions::available_update_for_app(&app_id, &container.image);
|
||||
|
||||
let package = PackageDataEntry {
|
||||
state: package_state.clone(),
|
||||
@@ -557,10 +557,37 @@ fn get_app_metadata(app_id: &str) -> AppMetadata {
|
||||
tier: "",
|
||||
},
|
||||
};
|
||||
apply_dynamic_metadata(app_id, &mut meta);
|
||||
meta.tier = get_app_tier(app_id);
|
||||
meta
|
||||
}
|
||||
|
||||
fn apply_dynamic_metadata(app_id: &str, meta: &mut AppMetadata) {
|
||||
let config_path = format!("/var/lib/archipelago/app-configs/{}.json", app_id);
|
||||
let Ok(data) = std::fs::read_to_string(config_path) else {
|
||||
return;
|
||||
};
|
||||
let Ok(cfg) = serde_json::from_str::<serde_json::Value>(&data) else {
|
||||
return;
|
||||
};
|
||||
if let Some(title) = cfg
|
||||
.get("title")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty() && s.len() <= 80)
|
||||
{
|
||||
meta.title = title.to_string();
|
||||
}
|
||||
if let Some(description) = cfg
|
||||
.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty() && s.len() <= 240)
|
||||
{
|
||||
meta.description = description.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
/// Map app_id to Tor hidden service directory name.
|
||||
/// "archipelago" is the main web UI (nginx port 80).
|
||||
/// Supports container names from deploy (archy-*, btcpay-server, etc.).
|
||||
@@ -631,6 +658,51 @@ fn extract_lan_address(ports: &[String]) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
async fn reachable_lan_address(app_id: &str, candidate: Option<String>) -> Option<String> {
|
||||
let url = candidate?;
|
||||
if !requires_reachable_launch(app_id) {
|
||||
return Some(url);
|
||||
}
|
||||
let Some(port) = url.rsplit(':').next().and_then(|p| p.parse::<u16>().ok()) else {
|
||||
return None;
|
||||
};
|
||||
match tokio::time::timeout(
|
||||
std::time::Duration::from_secs(2),
|
||||
tokio::net::TcpStream::connect(("127.0.0.1", port)),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(_)) => Some(url),
|
||||
_ => {
|
||||
debug!(app_id = %app_id, port, "suppressing unreachable launch URL");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn requires_reachable_launch(app_id: &str) -> bool {
|
||||
matches!(
|
||||
app_id,
|
||||
"botfights"
|
||||
| "btcpay-server"
|
||||
| "fedimint"
|
||||
| "filebrowser"
|
||||
| "grafana"
|
||||
| "homeassistant"
|
||||
| "home-assistant"
|
||||
| "jellyfin"
|
||||
| "mempool"
|
||||
| "nginx-proxy-manager"
|
||||
| "uptime-kuma"
|
||||
| "gitea"
|
||||
| "nextcloud"
|
||||
| "portainer"
|
||||
| "tailscale"
|
||||
| "immich"
|
||||
| "searxng"
|
||||
)
|
||||
}
|
||||
|
||||
fn companion_lan_address(app_id: &str) -> Option<String> {
|
||||
match app_id {
|
||||
"bitcoin" | "bitcoin-knots" | "bitcoin-core" => Some("http://localhost:8334".to_string()),
|
||||
@@ -639,9 +711,17 @@ fn companion_lan_address(app_id: &str) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
fn uses_allocated_launch_port(app_id: &str) -> bool {
|
||||
matches!(
|
||||
app_id,
|
||||
"filebrowser" | "nextcloud" | "nginx-proxy-manager" | "vaultwarden"
|
||||
)
|
||||
}
|
||||
|
||||
fn convert_state(container_state: &ContainerState) -> (PackageState, ServiceStatus) {
|
||||
match container_state {
|
||||
ContainerState::Running => (PackageState::Running, ServiceStatus::Running),
|
||||
ContainerState::Stopping => (PackageState::Stopping, ServiceStatus::Stopped),
|
||||
ContainerState::Stopped => (PackageState::Stopped, ServiceStatus::Stopped),
|
||||
ContainerState::Exited => (PackageState::Exited, ServiceStatus::Stopped),
|
||||
ContainerState::Created => (PackageState::Stopped, ServiceStatus::Stopped),
|
||||
|
||||
@@ -205,6 +205,30 @@ pub fn pinned_image_for_app(app_id: &str) -> Option<String> {
|
||||
images.get(var).cloned()
|
||||
}
|
||||
|
||||
/// Return the pinned tag only when the running image is genuinely behind.
|
||||
/// Registry host changes alone are not app updates, and floating tags are not
|
||||
/// explicit versions we should advertise to users as available updates.
|
||||
pub fn available_update_for_app(app_id: &str, running_image: &str) -> Option<String> {
|
||||
let pinned = pinned_image_for_app(app_id)?;
|
||||
let pinned_version = extract_version_from_image(&pinned);
|
||||
if is_floating_tag(&pinned_version) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let running_version = extract_version_from_image(running_image);
|
||||
if pinned_version == running_version {
|
||||
return None;
|
||||
}
|
||||
|
||||
let pinned_repo = image_without_registry_or_tag(&pinned);
|
||||
let running_repo = image_without_registry_or_tag(running_image);
|
||||
if pinned_repo != running_repo {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(pinned_version)
|
||||
}
|
||||
|
||||
/// Extract version tag from a full image reference.
|
||||
/// e.g. "git.tx1138.com/lfg2025/lnd:v0.18.4-beta" → "v0.18.4-beta"
|
||||
/// Returns "latest" if no tag or tag is empty.
|
||||
@@ -223,6 +247,32 @@ pub fn extract_version_from_image(image: &str) -> String {
|
||||
"latest".to_string()
|
||||
}
|
||||
|
||||
fn is_floating_tag(tag: &str) -> bool {
|
||||
matches!(tag, "latest" | "stable" | "release" | "main")
|
||||
}
|
||||
|
||||
fn image_without_registry_or_tag(image: &str) -> &str {
|
||||
let without_tag = strip_tag(image);
|
||||
match without_tag.split_once('/') {
|
||||
Some((first, rest))
|
||||
if first.contains('.') || first.contains(':') || first == "localhost" =>
|
||||
{
|
||||
rest
|
||||
}
|
||||
_ => without_tag,
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_tag(image: &str) -> &str {
|
||||
if let Some(slash_pos) = image.rfind('/') {
|
||||
let after_slash = &image[slash_pos..];
|
||||
if let Some(colon_pos) = after_slash.rfind(':') {
|
||||
return &image[..slash_pos + colon_pos];
|
||||
}
|
||||
}
|
||||
image
|
||||
}
|
||||
|
||||
/// Container names and their image variable names for multi-container stacks.
|
||||
/// Returns empty vec for single-container apps.
|
||||
pub fn containers_for_stack(app_id: &str) -> Vec<(&'static str, &'static str)> {
|
||||
@@ -286,6 +336,25 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_registry_and_tag_for_image_identity() {
|
||||
assert_eq!(
|
||||
image_without_registry_or_tag("146.59.87.168:3000/lfg2025/lnd:v0.18.4-beta"),
|
||||
"lfg2025/lnd"
|
||||
);
|
||||
assert_eq!(
|
||||
image_without_registry_or_tag("git.tx1138.com/lfg2025/lnd:v0.18.4-beta"),
|
||||
"lfg2025/lnd"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn floating_tags_are_not_explicit_updates() {
|
||||
assert!(is_floating_tag("latest"));
|
||||
assert!(is_floating_tag("stable"));
|
||||
assert!(!is_floating_tag("v0.18.4-beta"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_image_versions() {
|
||||
let content = r#"
|
||||
|
||||
@@ -10,6 +10,7 @@ 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";
|
||||
const LND_REST_BASE_URL: &str = "https://127.0.0.1:18080";
|
||||
pub const WALLET_PASSWORD: &str = "hellohello";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -42,7 +43,7 @@ pub async fn ensure_config(paths: &EnsurePaths, rpc_pass: &str) -> Result<Ensure
|
||||
let existing = fs::read_to_string(&paths.conf_path)
|
||||
.await
|
||||
.with_context(|| format!("reading {}", paths.conf_path.display()))?;
|
||||
if has_required_lnd_flags(&existing) {
|
||||
if has_required_lnd_flags(&existing, rpc_pass) {
|
||||
return Ok(EnsureOutcome::Unchanged);
|
||||
}
|
||||
}
|
||||
@@ -121,6 +122,31 @@ async fn read_file_as_root(path: &str) -> Result<Vec<u8>> {
|
||||
}
|
||||
|
||||
async fn unlock_existing_wallet() -> Result<()> {
|
||||
unlock_existing_wallet_via_rest().await
|
||||
}
|
||||
|
||||
async fn unlock_existing_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 wallet_password = base64::engine::general_purpose::STANDARD.encode(WALLET_PASSWORD);
|
||||
match post_lnd_unlocker_json::<serde_json::Value>(
|
||||
&client,
|
||||
"/v1/unlockwallet",
|
||||
serde_json::json!({ "wallet_password": wallet_password }),
|
||||
)
|
||||
.await
|
||||
.context("unlocking existing LND wallet")?
|
||||
{
|
||||
UnlockerResponse::Value(_) | UnlockerResponse::WalletAlreadyExists => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
async fn unlock_existing_wallet_via_lncli() -> Result<()> {
|
||||
let mut last_err = None;
|
||||
for _ in 0..60 {
|
||||
let mut cmd = tokio::process::Command::new("podman");
|
||||
@@ -221,7 +247,7 @@ 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 url = format!("{LND_REST_BASE_URL}{path}");
|
||||
let mut last_err = None;
|
||||
for _ in 0..60 {
|
||||
match client.get(&url).send().await {
|
||||
@@ -244,7 +270,7 @@ async fn post_lnd_unlocker_json<T: for<'de> Deserialize<'de>>(
|
||||
path: &str,
|
||||
body: serde_json::Value,
|
||||
) -> Result<UnlockerResponse<T>> {
|
||||
let url = format!("https://127.0.0.1:8080{path}");
|
||||
let url = format!("{LND_REST_BASE_URL}{path}");
|
||||
let mut last_err = None;
|
||||
for _ in 0..60 {
|
||||
match client.post(&url).json(&body).send().await {
|
||||
@@ -291,7 +317,7 @@ async fn lnd_getinfo_ready(admin_macaroon: &str) -> bool {
|
||||
return false;
|
||||
};
|
||||
client
|
||||
.get("https://127.0.0.1:8080/v1/getinfo")
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/getinfo"))
|
||||
.header("Grpc-Metadata-macaroon", hex::encode(macaroon))
|
||||
.send()
|
||||
.await
|
||||
@@ -344,12 +370,14 @@ fn shell_quote(s: &str) -> String {
|
||||
s.replace('\'', "'\\''")
|
||||
}
|
||||
|
||||
fn has_required_lnd_flags(conf: &str) -> bool {
|
||||
fn has_required_lnd_flags(conf: &str, rpc_pass: &str) -> bool {
|
||||
let rpc_pass_line = format!("bitcoind.rpcpass={rpc_pass}");
|
||||
[
|
||||
"bitcoin.active=true",
|
||||
"bitcoin.mainnet=true",
|
||||
"bitcoin.node=bitcoind",
|
||||
"bitcoind.rpchost=bitcoin-knots:8332",
|
||||
rpc_pass_line.as_str(),
|
||||
]
|
||||
.iter()
|
||||
.all(|needle| conf.lines().any(|line| line.trim() == *needle))
|
||||
@@ -378,7 +406,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ensure_config_is_idempotent() {
|
||||
async fn ensure_config_repairs_rpc_password_drift() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let paths = EnsurePaths {
|
||||
data_dir: tmp.path().join("lnd"),
|
||||
@@ -391,10 +419,10 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
ensure_config(&paths, "second").await.unwrap(),
|
||||
EnsureOutcome::Unchanged
|
||||
EnsureOutcome::Written
|
||||
);
|
||||
let conf = fs::read_to_string(&paths.conf_path).await.unwrap();
|
||||
assert!(conf.contains("bitcoind.rpcpass=first"));
|
||||
assert!(conf.contains("bitcoind.rpcpass=second"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -47,6 +47,40 @@ use crate::update::host_sudo;
|
||||
/// Keep in sync with the running fixture on .116. Centralized as a constant
|
||||
/// so the rule is visible in one place and unit-testable.
|
||||
const UI_APP_IDS: &[&str] = &["bitcoin-ui", "electrs-ui", "lnd-ui"];
|
||||
const ARCHIVAL_BITCOIN_DISK_GB: u64 = 1000;
|
||||
|
||||
fn is_required_baseline_app(app_id: &str) -> bool {
|
||||
matches!(
|
||||
app_id,
|
||||
"bitcoin-knots"
|
||||
| "electrumx"
|
||||
| "lnd"
|
||||
| "mempool-api"
|
||||
| "mempool"
|
||||
| "archy-mempool-db"
|
||||
| "filebrowser"
|
||||
)
|
||||
}
|
||||
|
||||
fn is_restart_sensitive_app(app_id: &str) -> bool {
|
||||
matches!(
|
||||
app_id,
|
||||
"bitcoin-knots"
|
||||
| "bitcoin-core"
|
||||
| "bitcoin"
|
||||
| "lnd"
|
||||
| "btcpay-server"
|
||||
| "fedimint"
|
||||
| "fedimint-gateway"
|
||||
)
|
||||
}
|
||||
|
||||
fn requires_archival_bitcoin(app_id: &str) -> bool {
|
||||
matches!(
|
||||
app_id,
|
||||
"electrumx" | "mempool-api" | "mempool" | "archy-mempool-db"
|
||||
)
|
||||
}
|
||||
|
||||
/// Compute the podman container name for a manifest.
|
||||
///
|
||||
@@ -129,6 +163,20 @@ async fn wait_for_host_port(port: u16, timeout_secs: u64) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_manifest_host_ports(manifest: &AppManifest, timeout_secs: u64) -> Result<()> {
|
||||
for port in manifest.app.ports.iter().map(|p| p.host) {
|
||||
if !wait_for_host_port(port, timeout_secs).await {
|
||||
return Err(anyhow::anyhow!(
|
||||
"{} host port {} did not become reachable within {}s",
|
||||
manifest.app.id,
|
||||
port,
|
||||
timeout_secs
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn patch_indeedhub_nostr_provider() {
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
@@ -316,6 +364,8 @@ pub struct ProdContainerOrchestrator {
|
||||
/// false so the legacy path remains the production path until the
|
||||
/// 20× lifecycle harness goes green against the new path.
|
||||
use_quadlet_backends: bool,
|
||||
#[cfg(test)]
|
||||
test_disk_gb: Option<u64>,
|
||||
}
|
||||
|
||||
struct FileSecretsProvider {
|
||||
@@ -363,6 +413,8 @@ impl ProdContainerOrchestrator {
|
||||
lnd_paths: lnd::EnsurePaths::default(),
|
||||
secrets_dir: PathBuf::from("/var/lib/archipelago/secrets"),
|
||||
use_quadlet_backends: config.use_quadlet_backends,
|
||||
#[cfg(test)]
|
||||
test_disk_gb: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -380,6 +432,7 @@ impl ProdContainerOrchestrator {
|
||||
lnd_paths: lnd::EnsurePaths::default(),
|
||||
secrets_dir: PathBuf::from("/var/lib/archipelago/secrets"),
|
||||
use_quadlet_backends: false,
|
||||
test_disk_gb: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -411,6 +464,11 @@ impl ProdContainerOrchestrator {
|
||||
self.lnd_paths = paths;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn set_disk_gb_for_test(&mut self, disk_gb: u64) {
|
||||
self.test_disk_gb = Some(disk_gb);
|
||||
}
|
||||
|
||||
/// Walk `manifests_dir` looking for `*/manifest.yml` files. Parses each,
|
||||
/// validates it, and stores it in the in-memory state.
|
||||
///
|
||||
@@ -587,8 +645,19 @@ impl ProdContainerOrchestrator {
|
||||
.collect()
|
||||
};
|
||||
let mut report = ReconcileReport::default();
|
||||
let disk_gb = self.disk_gb();
|
||||
for lm in manifests {
|
||||
let app_id = lm.manifest.app.id.clone();
|
||||
if mode == ReconcileMode::ExistingOnly
|
||||
&& requires_archival_bitcoin(&app_id)
|
||||
&& disk_gb < ARCHIVAL_BITCOIN_DISK_GB
|
||||
{
|
||||
report.record(
|
||||
&app_id,
|
||||
ReconcileAction::Left("requires-archival-bitcoin".into()),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
match self.ensure_running_with_mode(&lm, mode).await {
|
||||
Ok(action) => report.record(&app_id, action),
|
||||
Err(e) => {
|
||||
@@ -657,6 +726,17 @@ impl ProdContainerOrchestrator {
|
||||
return Ok(ReconcileAction::Started);
|
||||
}
|
||||
if self.container_env_drifted(&name, &resolved_manifest).await {
|
||||
if mode == ReconcileMode::ExistingOnly
|
||||
&& is_restart_sensitive_app(&app_id)
|
||||
{
|
||||
tracing::info!(
|
||||
app_id = %app_id,
|
||||
container = %name,
|
||||
"container drift detected during boot reconcile; leaving running restart-sensitive app untouched"
|
||||
);
|
||||
self.run_post_start_hooks(&app_id).await?;
|
||||
return Ok(ReconcileAction::NoOp);
|
||||
}
|
||||
tracing::info!(app_id = %app_id, container = %name, "container env drift detected — recreating");
|
||||
let _ = self.runtime.stop_container(&name).await;
|
||||
let _ = self.runtime.remove_container(&name).await;
|
||||
@@ -691,8 +771,20 @@ impl ProdContainerOrchestrator {
|
||||
return Ok(ReconcileAction::Installed);
|
||||
}
|
||||
self.run_post_start_hooks(&app_id).await?;
|
||||
wait_for_manifest_host_ports(&resolved_manifest, 60).await?;
|
||||
Ok(ReconcileAction::Started)
|
||||
}
|
||||
ContainerState::Stopping => {
|
||||
tracing::warn!(
|
||||
app_id = %app_id,
|
||||
container = %name,
|
||||
"container stuck in stopping state; force-recreating container record"
|
||||
);
|
||||
self.prepare_for_start(&resolved_manifest).await?;
|
||||
let _ = self.runtime.remove_container(&name).await;
|
||||
self.install_fresh(lm).await?;
|
||||
Ok(ReconcileAction::Installed)
|
||||
}
|
||||
ContainerState::Created => {
|
||||
self.prepare_for_start(&resolved_manifest).await?;
|
||||
if self.container_env_drifted(&name, &resolved_manifest).await {
|
||||
@@ -714,6 +806,7 @@ impl ProdContainerOrchestrator {
|
||||
return Ok(ReconcileAction::Installed);
|
||||
}
|
||||
self.run_post_start_hooks(&app_id).await?;
|
||||
wait_for_manifest_host_ports(&resolved_manifest, 60).await?;
|
||||
Ok(ReconcileAction::Started)
|
||||
}
|
||||
ContainerState::Paused => Ok(ReconcileAction::Left("paused".to_string())),
|
||||
@@ -721,7 +814,36 @@ impl ProdContainerOrchestrator {
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
// Container missing entirely → install fresh.
|
||||
// Container missing entirely. With Quadlet backends enabled, an
|
||||
// existing .container file is installed state even if Podman
|
||||
// lost the container record after a crash/reboot. Sync the unit
|
||||
// bytes first (clears stale Notify=healthy/nc probes), then ask
|
||||
// user systemd to start the generated service.
|
||||
if self.use_quadlet_backends && self.quadlet_unit_exists(&name).await? {
|
||||
self.prepare_for_start(&resolved_manifest).await?;
|
||||
self.sync_quadlet_unit(lm, &name).await?;
|
||||
quadlet::enable_now(&format!("{name}.service"))
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!("start existing quadlet service {name}.service")
|
||||
})?;
|
||||
self.run_post_start_hooks(&app_id).await?;
|
||||
wait_for_manifest_host_ports(&resolved_manifest, 60).await?;
|
||||
return Ok(ReconcileAction::Started);
|
||||
}
|
||||
|
||||
// Required baseline services must self-heal even if both the
|
||||
// podman record and Quadlet unit are gone. These are installed
|
||||
// by first boot and are prerequisites for dependent apps; an
|
||||
// "absent" result leaves the node permanently degraded after
|
||||
// crash cleanup.
|
||||
if mode == ReconcileMode::ExistingOnly && is_required_baseline_app(&app_id) {
|
||||
self.install_fresh(lm).await?;
|
||||
return Ok(ReconcileAction::Installed);
|
||||
}
|
||||
|
||||
// Optional container missing entirely → leave absent during
|
||||
// boot reconcile; explicit install/start can create it.
|
||||
if mode == ReconcileMode::ExistingOnly {
|
||||
return Ok(ReconcileAction::Left("absent".to_string()));
|
||||
}
|
||||
@@ -806,6 +928,7 @@ impl ProdContainerOrchestrator {
|
||||
.with_context(|| format!("start_container {name}"))?;
|
||||
}
|
||||
self.run_post_start_hooks(&lm.manifest.app.id).await?;
|
||||
wait_for_manifest_host_ports(&resolved_manifest, 60).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -942,6 +1065,16 @@ impl ProdContainerOrchestrator {
|
||||
Ok(Some(ReconcileAction::Installed))
|
||||
}
|
||||
|
||||
async fn quadlet_unit_exists(&self, name: &str) -> Result<bool> {
|
||||
let unit_dir = quadlet::unit_dir()
|
||||
.await
|
||||
.context("locate user quadlet unit dir for existing unit check")?;
|
||||
let unit_path = unit_dir.join(format!("{name}.container"));
|
||||
tokio::fs::try_exists(&unit_path)
|
||||
.await
|
||||
.with_context(|| format!("check existing quadlet unit {}", unit_path.display()))
|
||||
}
|
||||
|
||||
/// Drift-sync an existing Quadlet unit file's bytes against what the
|
||||
/// current renderer produces. No-op when the flag is off, when the
|
||||
/// app is a companion (companion.rs owns those units), or when no
|
||||
@@ -971,9 +1104,20 @@ impl ProdContainerOrchestrator {
|
||||
if !tokio::fs::try_exists(&unit_path).await.unwrap_or(false) {
|
||||
return Ok(());
|
||||
}
|
||||
let old_body = tokio::fs::read_to_string(&unit_path)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let restart_required = quadlet::contains_stale_health_gate(&old_body);
|
||||
|
||||
let mut resolved = lm.manifest.clone();
|
||||
self.resolve_dynamic_env(&mut resolved)?;
|
||||
let unit = quadlet::QuadletUnit::from_manifest(&resolved, name);
|
||||
let new_body = unit.render();
|
||||
let restart_for_port_change = quadlet::publish_ports_changed(&old_body, &new_body);
|
||||
let restart_for_network_alias_change =
|
||||
quadlet::network_aliases_changed(&old_body, &new_body);
|
||||
let restart_for_exec_change = quadlet::exec_changed(&old_body, &new_body);
|
||||
let restart_for_health_change = quadlet::health_cmd_changed(&old_body, &new_body);
|
||||
let changed = quadlet::write_if_changed(&unit, &unit_dir)
|
||||
.await
|
||||
.with_context(|| format!("drift-sync quadlet unit for {name}"))?;
|
||||
@@ -987,6 +1131,36 @@ impl ProdContainerOrchestrator {
|
||||
"Quadlet unit drift-synced — file rewritten, .service NOT restarted (operator restart picks up new config)"
|
||||
);
|
||||
}
|
||||
if changed
|
||||
&& (restart_required
|
||||
|| restart_for_port_change
|
||||
|| restart_for_network_alias_change
|
||||
|| restart_for_exec_change
|
||||
|| restart_for_health_change)
|
||||
{
|
||||
let service = unit.service_name();
|
||||
let reason = if restart_required {
|
||||
"stale health gate"
|
||||
} else if restart_for_port_change {
|
||||
"port binding drift"
|
||||
} else if restart_for_network_alias_change {
|
||||
"network alias drift"
|
||||
} else if restart_for_health_change {
|
||||
"health command drift"
|
||||
} else {
|
||||
"exec drift"
|
||||
};
|
||||
tracing::info!(
|
||||
app_id = %lm.manifest.app.id,
|
||||
container = %name,
|
||||
service = %service,
|
||||
reason = reason,
|
||||
"Quadlet unit rewrite requires service restart"
|
||||
);
|
||||
quadlet::restart_service(&service)
|
||||
.await
|
||||
.with_context(|| format!("restart drifted quadlet service {service}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1283,7 +1457,10 @@ impl ProdContainerOrchestrator {
|
||||
let mut started = false;
|
||||
match frontend_status.state {
|
||||
ContainerState::Running => {}
|
||||
ContainerState::Stopped | ContainerState::Exited | ContainerState::Created => {
|
||||
ContainerState::Stopped
|
||||
| ContainerState::Exited
|
||||
| ContainerState::Created
|
||||
| ContainerState::Stopping => {
|
||||
self.runtime
|
||||
.start_container("indeedhub")
|
||||
.await
|
||||
@@ -1366,7 +1543,7 @@ impl ProdContainerOrchestrator {
|
||||
fn detect_host_facts(&self) -> HostFacts {
|
||||
let host_ip = Self::detect_host_ip().unwrap_or_else(|| "127.0.0.1".to_string());
|
||||
let host_mdns = Self::detect_host_mdns();
|
||||
let disk_gb = Self::detect_disk_gb();
|
||||
let disk_gb = self.disk_gb();
|
||||
HostFacts {
|
||||
host_ip,
|
||||
host_mdns,
|
||||
@@ -1429,6 +1606,14 @@ impl ProdContainerOrchestrator {
|
||||
kb / 1_000_000
|
||||
}
|
||||
|
||||
fn disk_gb(&self) -> u64 {
|
||||
#[cfg(test)]
|
||||
if let Some(disk_gb) = self.test_disk_gb {
|
||||
return disk_gb;
|
||||
}
|
||||
Self::detect_disk_gb()
|
||||
}
|
||||
|
||||
fn resolve_dynamic_env(&self, manifest: &mut AppManifest) -> Result<()> {
|
||||
let facts = self.detect_host_facts();
|
||||
let mut env = manifest.app.environment.clone();
|
||||
@@ -1555,13 +1740,17 @@ impl ProdContainerOrchestrator {
|
||||
.flatten()
|
||||
.unwrap_or_default();
|
||||
|
||||
let expected_entry = manifest
|
||||
.app
|
||||
.container
|
||||
.entrypoint
|
||||
.clone()
|
||||
.unwrap_or_default();
|
||||
current_entry != expected_entry || current_cmd != manifest.app.container.custom_args
|
||||
if let Some(expected_entry) = &manifest.app.container.entrypoint {
|
||||
if current_entry != *expected_entry {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if !manifest.app.container.custom_args.is_empty()
|
||||
&& current_cmd != manifest.app.container.custom_args
|
||||
{
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
async fn apply_data_uid(&self, manifest: &AppManifest) -> Result<()> {
|
||||
@@ -1691,10 +1880,20 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
|
||||
let action = self.ensure_running(&lm).await?;
|
||||
match action {
|
||||
ReconcileAction::NoOp | ReconcileAction::Started | ReconcileAction::Installed => Ok(()),
|
||||
ReconcileAction::Left(state) => Err(anyhow::anyhow!(
|
||||
"container {} left in {state}",
|
||||
compute_container_name(&lm.manifest)
|
||||
)),
|
||||
ReconcileAction::Left(state) => {
|
||||
let name = compute_container_name(&lm.manifest);
|
||||
tracing::warn!(
|
||||
app_id = %app_id,
|
||||
container = %name,
|
||||
state = %state,
|
||||
"start: container in wedged state, force-recreating"
|
||||
);
|
||||
let lock = self.app_lock(app_id).await;
|
||||
let _guard = lock.lock().await;
|
||||
let _ = self.runtime.stop_container(&name).await;
|
||||
let _ = self.runtime.remove_container(&name).await;
|
||||
self.install_fresh(&lm).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1707,6 +1906,12 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
|
||||
let lock = self.app_lock(app_id).await;
|
||||
let _guard = lock.lock().await;
|
||||
let name = compute_container_name(&lm.manifest);
|
||||
// Quadlet-owned containers are restarted by systemd if only `podman stop`
|
||||
// is used. Stop the user service first, then stop the container as a
|
||||
// defensive fallback for legacy/non-Quadlet installs.
|
||||
if let Err(err) = quadlet::stop_service(&format!("{name}.service")).await {
|
||||
tracing::debug!(container = %name, error = %err, "quadlet stop skipped/failed");
|
||||
}
|
||||
self.runtime
|
||||
.stop_container(&name)
|
||||
.await
|
||||
@@ -1718,6 +1923,24 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
|
||||
let lock = self.app_lock(app_id).await;
|
||||
let _guard = lock.lock().await;
|
||||
let name = compute_container_name(&lm.manifest);
|
||||
|
||||
let service = format!("{name}.service");
|
||||
if self.quadlet_unit_exists(&name).await? {
|
||||
let mut resolved_manifest = lm.manifest.clone();
|
||||
self.resolve_dynamic_env(&mut resolved_manifest)?;
|
||||
self.prepare_for_start(&resolved_manifest).await?;
|
||||
self.sync_quadlet_unit(&lm, &name).await?;
|
||||
if let Err(err) = quadlet::restart_service(&service).await {
|
||||
tracing::warn!(container = %name, error = %err, "quadlet restart failed; trying start");
|
||||
quadlet::enable_now(&service)
|
||||
.await
|
||||
.with_context(|| format!("restart start quadlet service {service}"))?;
|
||||
}
|
||||
self.run_post_start_hooks(app_id).await?;
|
||||
wait_for_manifest_host_ports(&resolved_manifest, 60).await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Best-effort stop (ignored if already stopped), then start.
|
||||
let _ = self.runtime.stop_container(&name).await;
|
||||
self.prepare_for_start(&lm.manifest).await?;
|
||||
@@ -1819,8 +2042,10 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
|
||||
async fn health(&self, app_id: &str) -> Result<String> {
|
||||
let status = <Self as ContainerOrchestrator>::status(self, app_id).await?;
|
||||
Ok(match status.state {
|
||||
ContainerState::Running => "healthy".to_string(),
|
||||
ContainerState::Stopped | ContainerState::Exited => "unhealthy".to_string(),
|
||||
ContainerState::Running => status.health.unwrap_or_else(|| "healthy".to_string()),
|
||||
ContainerState::Stopped | ContainerState::Exited | ContainerState::Stopping => {
|
||||
"unhealthy".to_string()
|
||||
}
|
||||
ContainerState::Created => "starting".to_string(),
|
||||
ContainerState::Paused => "paused".to_string(),
|
||||
ContainerState::Unknown(s) => format!("unknown:{s}"),
|
||||
@@ -1846,6 +2071,8 @@ mod tests {
|
||||
calls: StdMutex<Vec<String>>,
|
||||
/// container_name -> ContainerState. Absence = "doesn't exist".
|
||||
containers: StdMutex<HashMap<String, ContainerState>>,
|
||||
/// container_name -> Podman health status.
|
||||
health: StdMutex<HashMap<String, String>>,
|
||||
/// image_ref -> present. Absence = "not present in local storage".
|
||||
images: StdMutex<HashMap<String, bool>>,
|
||||
/// container_name -> env that create_container received.
|
||||
@@ -1869,6 +2096,12 @@ mod tests {
|
||||
.unwrap()
|
||||
.insert(name.to_string(), state);
|
||||
}
|
||||
fn set_health(&self, name: &str, health: &str) {
|
||||
self.health
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(name.to_string(), health.to_string());
|
||||
}
|
||||
fn mark_image_present(&self, tag: &str) {
|
||||
self.images.lock().unwrap().insert(tag.to_string(), true);
|
||||
}
|
||||
@@ -1929,11 +2162,12 @@ mod tests {
|
||||
.get(name)
|
||||
.cloned()
|
||||
.ok_or_else(|| anyhow::anyhow!("not found: {name}"))?;
|
||||
let health = self.health.lock().unwrap().get(name).cloned();
|
||||
Ok(ContainerStatus {
|
||||
id: format!("id-{name}"),
|
||||
name: name.to_string(),
|
||||
state,
|
||||
health: None,
|
||||
health,
|
||||
exit_code: None,
|
||||
started_at: None,
|
||||
image: "test-image".to_string(),
|
||||
@@ -2042,6 +2276,7 @@ mod tests {
|
||||
runtime,
|
||||
PathBuf::from("/nonexistent-for-tests"),
|
||||
);
|
||||
orch.set_data_dir(PathBuf::from("/nonexistent-for-tests"));
|
||||
// Redirect the bitcoin-ui pre-start hook to a test-scoped
|
||||
// tmpdir, seeded with a fake password file. Shared across
|
||||
// every test in this module (OnceLock), so the hook can run
|
||||
@@ -2049,6 +2284,7 @@ mod tests {
|
||||
// this redirection, any test that installs the bitcoin-ui
|
||||
// fixture would try to write under /var/lib/archipelago.
|
||||
orch.set_bitcoin_ui_paths(test_bitcoin_ui_paths());
|
||||
orch.set_filebrowser_paths(test_filebrowser_paths());
|
||||
orch
|
||||
}
|
||||
|
||||
@@ -2129,6 +2365,17 @@ app:
|
||||
}
|
||||
}
|
||||
|
||||
fn test_filebrowser_paths() -> filebrowser::EnsurePaths {
|
||||
use std::sync::OnceLock;
|
||||
static DIR: OnceLock<tempfile::TempDir> = OnceLock::new();
|
||||
let dir = DIR.get_or_init(|| tempfile::TempDir::new().expect("test tmpdir"));
|
||||
filebrowser::EnsurePaths {
|
||||
srv_root: dir.path().join("filebrowser"),
|
||||
data_dir: dir.path().join("filebrowser-data"),
|
||||
config_path: dir.path().join("filebrowser-data/.filebrowser.json"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn install_fresh_pull() {
|
||||
let rt = Arc::new(MockRuntime::default());
|
||||
@@ -2436,6 +2683,82 @@ app:
|
||||
assert!(calls.iter().any(|c| c == "start_container:bitcoin-knots"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reconcile_existing_installs_missing_required_baseline_app() {
|
||||
let rt = Arc::new(MockRuntime::default());
|
||||
let mut orch = orch_with(rt.clone()).await;
|
||||
orch.set_disk_gb_for_test(500);
|
||||
orch.insert_manifest_for_test(
|
||||
pull_manifest("filebrowser", "docker.io/filebrowser/filebrowser:latest"),
|
||||
PathBuf::from("/tmp/filebrowser"),
|
||||
)
|
||||
.await;
|
||||
|
||||
let report = orch.reconcile_existing().await;
|
||||
|
||||
assert_eq!(
|
||||
report.actions,
|
||||
vec![("filebrowser".to_string(), ReconcileAction::Installed)]
|
||||
);
|
||||
assert!(report.failures.is_empty());
|
||||
let calls = rt.calls();
|
||||
assert!(calls.iter().any(|c| c.starts_with("pull_image:")));
|
||||
assert!(calls
|
||||
.iter()
|
||||
.any(|c| c == "create_container:filebrowser:offset=0"));
|
||||
assert!(calls.iter().any(|c| c == "start_container:filebrowser"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reconcile_existing_skips_archival_baseline_apps_on_pruned_hosts() {
|
||||
let rt = Arc::new(MockRuntime::default());
|
||||
let mut orch = orch_with(rt.clone()).await;
|
||||
orch.set_disk_gb_for_test(500);
|
||||
orch.insert_manifest_for_test(
|
||||
pull_manifest("electrumx", "docker.io/spesmilo/electrumx:latest"),
|
||||
PathBuf::from("/tmp/electrumx"),
|
||||
)
|
||||
.await;
|
||||
|
||||
let report = orch.reconcile_existing().await;
|
||||
|
||||
assert_eq!(
|
||||
report.actions,
|
||||
vec![(
|
||||
"electrumx".to_string(),
|
||||
ReconcileAction::Left("requires-archival-bitcoin".into())
|
||||
)]
|
||||
);
|
||||
assert!(report.failures.is_empty());
|
||||
let calls = rt.calls();
|
||||
assert!(!calls.iter().any(|c| c.starts_with("pull_image:")));
|
||||
assert!(!calls.iter().any(|c| c.starts_with("create_container:")));
|
||||
assert!(!calls.iter().any(|c| c.starts_with("start_container:")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reconcile_existing_leaves_missing_optional_app_absent() {
|
||||
let rt = Arc::new(MockRuntime::default());
|
||||
let orch = orch_with(rt.clone()).await;
|
||||
orch.insert_manifest_for_test(
|
||||
pull_manifest("gitea", "docker.io/gitea/gitea:latest"),
|
||||
PathBuf::from("/tmp/gitea"),
|
||||
)
|
||||
.await;
|
||||
|
||||
let report = orch.reconcile_existing().await;
|
||||
|
||||
assert_eq!(
|
||||
report.actions,
|
||||
vec![("gitea".to_string(), ReconcileAction::Left("absent".into()))]
|
||||
);
|
||||
assert!(report.failures.is_empty());
|
||||
let calls = rt.calls();
|
||||
assert!(!calls.iter().any(|c| c.starts_with("pull_image:")));
|
||||
assert!(!calls.iter().any(|c| c.starts_with("create_container:")));
|
||||
assert!(!calls.iter().any(|c| c.starts_with("start_container:")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reconcile_collects_per_app_failures_without_short_circuiting() {
|
||||
let rt = Arc::new(MockRuntime::default());
|
||||
@@ -2611,6 +2934,32 @@ app:
|
||||
assert!(calls.iter().any(|c| c == "start_container:bitcoin-knots"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reconcile_force_recreates_stopping_container() {
|
||||
let rt = Arc::new(MockRuntime::default());
|
||||
rt.set_state("nostr-rs-relay", ContainerState::Stopping);
|
||||
let orch = orch_with(rt.clone()).await;
|
||||
orch.insert_manifest_for_test(
|
||||
pull_manifest("nostr-rs-relay", "docker.io/scsibug/nostr-rs-relay:0.8.9"),
|
||||
PathBuf::from("/tmp/nostr-rs-relay"),
|
||||
)
|
||||
.await;
|
||||
|
||||
let report = orch.reconcile_all().await;
|
||||
|
||||
assert!(report.failures.is_empty(), "{:?}", report.failures);
|
||||
assert_eq!(
|
||||
report.actions,
|
||||
vec![("nostr-rs-relay".to_string(), ReconcileAction::Installed)]
|
||||
);
|
||||
let calls = rt.calls();
|
||||
assert!(calls.iter().any(|c| c == "remove_container:nostr-rs-relay"));
|
||||
assert!(calls
|
||||
.iter()
|
||||
.any(|c| c == "create_container:nostr-rs-relay:offset=0"));
|
||||
assert!(calls.iter().any(|c| c == "start_container:nostr-rs-relay"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn health_maps_states_to_strings() {
|
||||
let rt = Arc::new(MockRuntime::default());
|
||||
@@ -2620,6 +2969,9 @@ app:
|
||||
.await;
|
||||
assert_eq!(orch.health("lnd").await.unwrap(), "healthy");
|
||||
|
||||
rt.set_health("lnd", "unhealthy");
|
||||
assert_eq!(orch.health("lnd").await.unwrap(), "unhealthy");
|
||||
|
||||
rt.set_state("lnd", ContainerState::Exited);
|
||||
assert_eq!(orch.health("lnd").await.unwrap(), "unhealthy");
|
||||
|
||||
@@ -2628,6 +2980,9 @@ app:
|
||||
|
||||
rt.set_state("lnd", ContainerState::Created);
|
||||
assert_eq!(orch.health("lnd").await.unwrap(), "starting");
|
||||
|
||||
rt.set_state("lnd", ContainerState::Stopping);
|
||||
assert_eq!(orch.health("lnd").await.unwrap(), "unhealthy");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -52,6 +52,10 @@ pub struct BindMount {
|
||||
#[allow(dead_code)] // Bridge is reserved for Phase 5 per-app network isolation.
|
||||
pub enum NetworkMode {
|
||||
#[default]
|
||||
Default,
|
||||
/// Host networking is only for companion/proxy containers that need to
|
||||
/// reach node-local daemons directly. It cannot be combined with
|
||||
/// PublishPort because Podman discards port mappings in host mode.
|
||||
Host,
|
||||
/// A user-defined podman network — quadlet creates the container
|
||||
/// attached to it. The network must already exist (orchestrator's
|
||||
@@ -86,11 +90,11 @@ impl RestartPolicy {
|
||||
}
|
||||
}
|
||||
|
||||
/// Container healthcheck wired through to systemd via `Notify=healthy`.
|
||||
/// When set, `systemctl start <name>.service` blocks until the container's
|
||||
/// own healthcheck reports green — eliminating the "container up but RPC
|
||||
/// not ready" race that the orchestrator currently papers over with
|
||||
/// post-start polling.
|
||||
/// Container healthcheck wired through to Podman.
|
||||
/// Systemd should consider the unit started once the container process is
|
||||
/// running; health probes are app status, not boot ordering. Blocking
|
||||
/// `systemctl start` on health made boot reconciliation hang when an image
|
||||
/// lacked the probe helper binary, even though the service itself was live.
|
||||
///
|
||||
/// Ranges roughly mirror the manifest's HealthCheck struct: `cmd` is the
|
||||
/// shell form (`/usr/bin/curl -fsS http://localhost:8332/health` etc.),
|
||||
@@ -120,8 +124,8 @@ pub struct QuadletUnit {
|
||||
pub extra_podman_args: Vec<String>,
|
||||
pub depends_on: Vec<String>,
|
||||
/// Phase 3.4: when present the rendered unit emits HealthCmd=,
|
||||
/// HealthInterval=, HealthTimeout=, HealthRetries=, AND Notify=healthy
|
||||
/// so systemctl start blocks on a green health probe.
|
||||
/// HealthInterval=, HealthTimeout=, and HealthRetries= for Podman's
|
||||
/// health state without blocking systemd's start job.
|
||||
pub health: Option<HealthSpec>,
|
||||
// Backend-manifest extensions (Phase 3.1). Companion units leave
|
||||
// these defaulted; the renderer skips empty/false directives so a
|
||||
@@ -130,6 +134,7 @@ pub struct QuadletUnit {
|
||||
pub environment: Vec<String>,
|
||||
pub devices: Vec<String>,
|
||||
pub add_hosts: Vec<(String, String)>,
|
||||
pub network_aliases: Vec<String>,
|
||||
pub entrypoint: Option<Vec<String>>,
|
||||
pub command: Vec<String>,
|
||||
pub read_only_root: bool,
|
||||
@@ -172,11 +177,16 @@ impl QuadletUnit {
|
||||
// must surface as a unit start failure, not a silent retry storm.
|
||||
let _ = writeln!(s, "Pull=never");
|
||||
match &self.network {
|
||||
NetworkMode::Default => {}
|
||||
NetworkMode::Host => {
|
||||
let _ = writeln!(s, "Network=host");
|
||||
}
|
||||
NetworkMode::Bridge(net) => {
|
||||
let _ = writeln!(s, "Network={net}");
|
||||
for alias in &self.network_aliases {
|
||||
let _ = writeln!(s, "NetworkAlias={alias}");
|
||||
let _ = writeln!(s, "PodmanArgs=--network-alias={alias}");
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(user) = &self.user {
|
||||
@@ -212,7 +222,7 @@ impl QuadletUnit {
|
||||
for env in &self.environment {
|
||||
// env entries already arrive shaped as "KEY=VALUE"; quadlet
|
||||
// accepts that form on a single Environment= line per pair.
|
||||
let _ = writeln!(s, "Environment={env}");
|
||||
let _ = writeln!(s, "Environment={}", quote_environment(env));
|
||||
}
|
||||
for dev in &self.devices {
|
||||
let _ = writeln!(s, "AddDevice={dev}");
|
||||
@@ -234,11 +244,6 @@ impl QuadletUnit {
|
||||
let _ = writeln!(s, "HealthInterval={}", h.interval);
|
||||
let _ = writeln!(s, "HealthTimeout={}", h.timeout);
|
||||
let _ = writeln!(s, "HealthRetries={}", h.retries);
|
||||
// Notify=healthy: systemd treats the unit as "started" only
|
||||
// after the first green health probe. Start ordering
|
||||
// (Requires=/After=) downstream of this unit therefore
|
||||
// doesn't fire until the app is actually serving requests.
|
||||
let _ = writeln!(s, "Notify=healthy");
|
||||
}
|
||||
if let Some(ep) = &self.entrypoint {
|
||||
// Quadlet's Exec= replaces the image entrypoint+cmd. When
|
||||
@@ -261,20 +266,6 @@ impl QuadletUnit {
|
||||
// OnFailure (clean stops stay stopped).
|
||||
let _ = writeln!(s, "Restart={}", self.restart_policy.as_systemd());
|
||||
let _ = writeln!(s, "RestartSec=10");
|
||||
if self.health.is_some() {
|
||||
// Notify=healthy makes systemd block the unit's "started"
|
||||
// state on the first green health probe. systemd's default
|
||||
// TimeoutStartSec is 90s — but `HealthInterval=30s` ×
|
||||
// `HealthRetries=3` is itself 90s, so the timeout fires the
|
||||
// moment the third probe MIGHT succeed. On .228 every backend
|
||||
// (lnd, electrumx, fedimint, btcpay-server, mempool-api,
|
||||
// bitcoin-knots) timed out at 90s and systemd terminated the
|
||||
// container while it was still warming up. Bump to 600s — long
|
||||
// enough for slow-starting backends (electrumx replays its
|
||||
// index, lnd unlocks its wallet) without being so long that a
|
||||
// truly stuck unit hangs forever.
|
||||
let _ = writeln!(s, "TimeoutStartSec=600");
|
||||
}
|
||||
let _ = writeln!(s);
|
||||
let _ = writeln!(s, "[Install]");
|
||||
let _ = writeln!(s, "WantedBy=default.target");
|
||||
@@ -290,17 +281,38 @@ fn shell_join(parts: &[String]) -> String {
|
||||
parts
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let p = p.replace(['\r', '\n'], " ");
|
||||
if p.is_empty() || p.chars().any(|c| c.is_whitespace() || "\"\\$`".contains(c)) {
|
||||
let escaped = p.replace('\\', "\\\\").replace('"', "\\\"");
|
||||
let escaped = p
|
||||
.replace('\\', "\\\\")
|
||||
.replace('"', "\\\"")
|
||||
.replace('$', "$$");
|
||||
format!("\"{escaped}\"")
|
||||
} else {
|
||||
p.clone()
|
||||
p
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
fn quote_environment(env: &str) -> String {
|
||||
let env = env.replace(['\r', '\n'], " ");
|
||||
if env.is_empty()
|
||||
|| env
|
||||
.chars()
|
||||
.any(|c| c.is_whitespace() || "\"\\$`".contains(c))
|
||||
{
|
||||
let escaped = env
|
||||
.replace('\\', "\\\\")
|
||||
.replace('"', "\\\"")
|
||||
.replace('$', "$$");
|
||||
format!("\"{escaped}\"")
|
||||
} else {
|
||||
env
|
||||
}
|
||||
}
|
||||
|
||||
impl QuadletUnit {
|
||||
/// Build a backend-flavour QuadletUnit from a parsed AppManifest.
|
||||
/// Wired through `prod_orchestrator::install_via_quadlet`, gated by
|
||||
@@ -323,7 +335,7 @@ impl QuadletUnit {
|
||||
other if !other.is_empty() && other != "isolated" => NetworkMode::Bridge(other.into()),
|
||||
_ => match app.container.network.as_deref() {
|
||||
Some(n) if !n.is_empty() && n != "host" => NetworkMode::Bridge(n.into()),
|
||||
_ => NetworkMode::Host,
|
||||
_ => NetworkMode::Default,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -366,6 +378,7 @@ impl QuadletUnit {
|
||||
environment: app.environment.clone(),
|
||||
devices: app.devices.clone(),
|
||||
add_hosts: vec![("host.archipelago".into(), "10.89.0.1".into())],
|
||||
network_aliases: vec![name.to_string()],
|
||||
entrypoint: app.container.entrypoint.clone(),
|
||||
command: app.container.custom_args.clone(),
|
||||
read_only_root: app.security.readonly_root,
|
||||
@@ -378,12 +391,11 @@ impl QuadletUnit {
|
||||
|
||||
/// Translate the manifest's HealthCheck shape into a HealthSpec the
|
||||
/// renderer understands. Returns None when the manifest's health spec
|
||||
/// is malformed or unsupported — we'd rather skip Notify=healthy than
|
||||
/// emit a broken HealthCmd that fails the unit start forever.
|
||||
/// is malformed or unsupported rather than emitting a broken HealthCmd.
|
||||
///
|
||||
/// Supported shapes:
|
||||
/// - type: tcp, endpoint: "host:port" → `nc -z host port`
|
||||
/// - type: http, endpoint: "host:port" or "http(s)://host:port", path → curl
|
||||
/// - type: tcp, endpoint: "host:port" → skipped for Quadlet units
|
||||
/// - type: http, endpoint: "host:port" or "http(s)://host:port", path → wget/curl
|
||||
/// - type: cmd, endpoint: "<shell command>" → `<shell command>` verbatim
|
||||
///
|
||||
/// For type=http we accept the endpoint with or without scheme; manifests
|
||||
@@ -393,13 +405,11 @@ impl QuadletUnit {
|
||||
/// that pasted on .228 2026-05-02 and failed every probe.
|
||||
fn translate_health_check(hc: &archipelago_container::HealthCheck) -> Option<HealthSpec> {
|
||||
let cmd = match hc.check_type.as_str() {
|
||||
"tcp" => {
|
||||
let endpoint = hc.endpoint.as_deref()?;
|
||||
let (host, port) = endpoint.rsplit_once(':')?;
|
||||
// nc is in busybox/coreutils on every base image we ship.
|
||||
// The -z flag does a "scan" that exits 0 on connect, 1 otherwise.
|
||||
format!("nc -z {host} {port}")
|
||||
}
|
||||
// A generic TCP probe inside arbitrary app images is not reliable:
|
||||
// some images lack nc, some lack bash /dev/tcp, and failures leave
|
||||
// Podman/systemd health in a false-negative state. Keep TCP readiness
|
||||
// checks in the host-side lifecycle/status layer instead.
|
||||
"tcp" => return None,
|
||||
"http" => {
|
||||
let endpoint = hc.endpoint.as_deref()?.trim();
|
||||
// Accept either bare host:port or a full URL. If endpoint
|
||||
@@ -426,9 +436,14 @@ fn translate_health_check(hc: &archipelago_container::HealthCheck) -> Option<Hea
|
||||
let path = hc.path.as_deref().unwrap_or("/");
|
||||
format!("{url}{path}")
|
||||
};
|
||||
// -fsS: fail on non-2xx, silent except on error, show errors.
|
||||
// -m 5: per-request timeout matches the default manifest timeout.
|
||||
format!("curl -fsS -m 5 {final_url}")
|
||||
// Images vary wildly: SearXNG ships wget but no curl, while some
|
||||
// Node images ship neither. Use whichever probe helper exists and
|
||||
// skip Podman health if the image has none; host-side lifecycle
|
||||
// probes still verify reachability.
|
||||
format!(
|
||||
"if command -v wget >/dev/null 2>&1; then wget -q -T 5 -O /dev/null {0}; elif command -v curl >/dev/null 2>&1; then curl -fsS -m 5 {0}; else exit 0; fi",
|
||||
final_url
|
||||
)
|
||||
}
|
||||
"cmd" => hc.endpoint.as_deref()?.to_string(),
|
||||
_ => return None,
|
||||
@@ -528,6 +543,70 @@ pub async fn enable_now(service: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Restart a generated Quadlet service after rewriting a known-bad unit.
|
||||
pub async fn restart_service(service: &str) -> Result<()> {
|
||||
let status = Command::new("systemctl")
|
||||
.args(["--user", "restart", service])
|
||||
.status()
|
||||
.await
|
||||
.with_context(|| format!("spawn systemctl --user restart {service}"))?;
|
||||
if !status.success() {
|
||||
return Err(anyhow!(
|
||||
"systemctl --user restart {service} exited {status}"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stop a generated Quadlet service without removing its unit file.
|
||||
pub async fn stop_service(service: &str) -> Result<()> {
|
||||
let status = Command::new("systemctl")
|
||||
.args(["--user", "stop", service])
|
||||
.status()
|
||||
.await
|
||||
.with_context(|| format!("spawn systemctl --user stop {service}"))?;
|
||||
if !status.success() {
|
||||
return Err(anyhow!("systemctl --user stop {service} exited {status}"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn contains_stale_health_gate(unit_body: &str) -> bool {
|
||||
unit_body.contains("Notify=healthy")
|
||||
|| unit_body.contains("TimeoutStartSec=600")
|
||||
|| unit_body.contains("HealthCmd=nc -z")
|
||||
}
|
||||
|
||||
pub fn health_cmd_changed(old_body: &str, new_body: &str) -> bool {
|
||||
directive_values(old_body, "HealthCmd=") != directive_values(new_body, "HealthCmd=")
|
||||
}
|
||||
|
||||
pub fn publish_ports_changed(old_body: &str, new_body: &str) -> bool {
|
||||
let old_ports = directive_values(old_body, "PublishPort=");
|
||||
let new_ports = directive_values(new_body, "PublishPort=");
|
||||
old_ports != new_ports
|
||||
}
|
||||
|
||||
pub fn network_aliases_changed(old_body: &str, new_body: &str) -> bool {
|
||||
let old_aliases = directive_values(old_body, "NetworkAlias=");
|
||||
let new_aliases = directive_values(new_body, "NetworkAlias=");
|
||||
old_aliases != new_aliases
|
||||
}
|
||||
|
||||
pub fn exec_changed(old_body: &str, new_body: &str) -> bool {
|
||||
let old_exec = directive_values(old_body, "Exec=");
|
||||
let new_exec = directive_values(new_body, "Exec=");
|
||||
old_exec != new_exec
|
||||
}
|
||||
|
||||
fn directive_values(unit_body: &str, prefix: &str) -> Vec<String> {
|
||||
unit_body
|
||||
.lines()
|
||||
.filter_map(|line| line.trim().strip_prefix(prefix))
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Stop + remove a quadlet unit and its on-disk file. Best-effort:
|
||||
/// errors stop only the destructive write at the failing step so a
|
||||
/// partial removal doesn't leave a quadlet file pointing at a service
|
||||
@@ -706,6 +785,30 @@ mod tests {
|
||||
);
|
||||
// Embedded quotes must escape:
|
||||
assert_eq!(shell_join(&[r#"say "hi""#.into()]), r#""say \"hi\"""#);
|
||||
assert_eq!(
|
||||
shell_join(&[
|
||||
"sh".into(),
|
||||
"-lc".into(),
|
||||
"if true; then\n exec app;\nfi".into()
|
||||
]),
|
||||
"sh -lc \"if true; then exec app; fi\""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quote_environment_quotes_values_with_spaces() {
|
||||
assert_eq!(
|
||||
quote_environment("BITCOIN_RPC_PASS=secret"),
|
||||
"BITCOIN_RPC_PASS=secret"
|
||||
);
|
||||
assert_eq!(
|
||||
quote_environment("RELAY_NAME=Archipelago Nostr Relay"),
|
||||
"\"RELAY_NAME=Archipelago Nostr Relay\""
|
||||
);
|
||||
assert_eq!(
|
||||
quote_environment("GREETING=say \"hi\""),
|
||||
"\"GREETING=say \\\"hi\\\"\""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -727,6 +830,7 @@ mod tests {
|
||||
environment: vec![
|
||||
"BITCOIN_RPC_USER=archipelago".into(),
|
||||
"BITCOIN_RPC_PASS=secret".into(),
|
||||
"RELAY_NAME=Archipelago Nostr Relay".into(),
|
||||
],
|
||||
devices: vec!["/dev/kvm".into()],
|
||||
add_hosts: vec![("host.archipelago".into(), "10.89.0.1".into())],
|
||||
@@ -743,6 +847,7 @@ mod tests {
|
||||
assert!(s.contains("PublishPort=8333:8333/tcp"));
|
||||
assert!(s.contains("Environment=BITCOIN_RPC_USER=archipelago"));
|
||||
assert!(s.contains("Environment=BITCOIN_RPC_PASS=secret"));
|
||||
assert!(s.contains("Environment=\"RELAY_NAME=Archipelago Nostr Relay\""));
|
||||
assert!(s.contains("AddDevice=/dev/kvm"));
|
||||
assert!(s.contains("AddHost=host.archipelago:10.89.0.1"));
|
||||
assert!(s.contains("ReadOnly=true"));
|
||||
@@ -829,6 +934,29 @@ app:
|
||||
assert_eq!(u.restart_policy, RestartPolicy::OnFailure);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_manifest_uses_default_network_for_isolated_ports() {
|
||||
let yaml = r#"
|
||||
app:
|
||||
id: searxng
|
||||
name: SearXNG
|
||||
version: 1.0.0
|
||||
container:
|
||||
image: searxng:latest
|
||||
ports:
|
||||
- host: 8888
|
||||
container: 8080
|
||||
protocol: tcp
|
||||
security:
|
||||
network_policy: isolated
|
||||
"#;
|
||||
let m = AppManifest::parse(yaml).expect("manifest must parse");
|
||||
let s = QuadletUnit::from_manifest(&m, "searxng").render();
|
||||
|
||||
assert!(s.contains("PublishPort=8888:8080/tcp"));
|
||||
assert!(!s.contains("Network=host"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_manifest_preserves_grafana_data_uid_and_volume_shape() {
|
||||
let yaml = r#"
|
||||
@@ -916,28 +1044,25 @@ app:
|
||||
u.name = "lnd".into();
|
||||
u.image = "x:1".into();
|
||||
u.health = Some(HealthSpec {
|
||||
cmd: "nc -z localhost 10009".into(),
|
||||
cmd: "probe-ready".into(),
|
||||
interval: "30s".into(),
|
||||
timeout: "5s".into(),
|
||||
retries: 3,
|
||||
});
|
||||
let s = u.render();
|
||||
assert!(s.contains("HealthCmd=nc -z localhost 10009"));
|
||||
assert!(s.contains("HealthCmd=probe-ready"));
|
||||
assert!(s.contains("HealthInterval=30s"));
|
||||
assert!(s.contains("HealthTimeout=5s"));
|
||||
assert!(s.contains("HealthRetries=3"));
|
||||
assert!(s.contains("Notify=healthy"));
|
||||
// Notify=healthy needs a long-enough TimeoutStartSec or systemd
|
||||
// kills the unit before the first probe can pass — observed live
|
||||
// on .228 2026-05-02 across all six backends.
|
||||
assert!(s.contains("TimeoutStartSec=600"), "got: {s}");
|
||||
assert!(!s.contains("Notify=healthy"));
|
||||
assert!(!s.contains("TimeoutStartSec=600"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_skips_health_directives_when_absent() {
|
||||
// No health spec → no Notify=healthy, no HealthCmd, no
|
||||
// TimeoutStartSec override (default 90s applies). Companions rely
|
||||
// on this so their rendered bytes stay unchanged.
|
||||
// No health spec → no Notify=healthy, no HealthCmd, no TimeoutStartSec
|
||||
// override. Companions rely on this so their rendered bytes stay
|
||||
// unchanged.
|
||||
let s = sample_unit().render();
|
||||
assert!(!s.contains("HealthCmd="));
|
||||
assert!(!s.contains("Notify=healthy"));
|
||||
@@ -956,9 +1081,7 @@ app:
|
||||
timeout: "5s".into(),
|
||||
retries: 3,
|
||||
};
|
||||
let h = translate_health_check(&tcp).expect("tcp must translate");
|
||||
assert_eq!(h.cmd, "nc -z localhost 10009");
|
||||
assert_eq!(h.retries, 3);
|
||||
assert!(translate_health_check(&tcp).is_none());
|
||||
|
||||
let http = HealthCheck {
|
||||
check_type: "http".into(),
|
||||
@@ -969,7 +1092,10 @@ app:
|
||||
retries: 5,
|
||||
};
|
||||
let h = translate_health_check(&http).expect("http must translate");
|
||||
assert_eq!(h.cmd, "curl -fsS -m 5 http://localhost:8080/health");
|
||||
assert_eq!(
|
||||
h.cmd,
|
||||
"if command -v wget >/dev/null 2>&1; then wget -q -T 5 -O /dev/null http://localhost:8080/health; elif command -v curl >/dev/null 2>&1; then curl -fsS -m 5 http://localhost:8080/health; else exit 0; fi"
|
||||
);
|
||||
|
||||
let cmdck = HealthCheck {
|
||||
check_type: "cmd".into(),
|
||||
@@ -982,8 +1108,7 @@ app:
|
||||
let h = translate_health_check(&cmdck).expect("cmd must translate");
|
||||
assert_eq!(h.cmd, "/usr/local/bin/probe.sh");
|
||||
|
||||
// Unknown type → None (renderer skips Notify=healthy entirely
|
||||
// rather than emit a broken HealthCmd that hangs the unit start).
|
||||
// Unknown type → None rather than emit a broken HealthCmd.
|
||||
let bad = HealthCheck {
|
||||
check_type: "exec".into(),
|
||||
endpoint: Some("foo".into()),
|
||||
@@ -994,7 +1119,7 @@ app:
|
||||
};
|
||||
assert!(translate_health_check(&bad).is_none());
|
||||
|
||||
// Malformed tcp endpoint → None (no port separator).
|
||||
// TCP is skipped entirely for Quadlet units.
|
||||
let badtcp = HealthCheck {
|
||||
check_type: "tcp".into(),
|
||||
endpoint: Some("hostonly".into()),
|
||||
@@ -1022,7 +1147,7 @@ app:
|
||||
retries: 3,
|
||||
};
|
||||
let h = translate_health_check(&with_scheme).expect("with-scheme must translate");
|
||||
assert_eq!(h.cmd, "curl -fsS -m 5 http://localhost:8175/");
|
||||
assert!(h.cmd.contains("http://localhost:8175/"));
|
||||
assert!(!h.cmd.contains("http://http://"), "got: {}", h.cmd);
|
||||
|
||||
let with_https = HealthCheck {
|
||||
@@ -1035,7 +1160,7 @@ app:
|
||||
};
|
||||
let h = translate_health_check(&with_https).expect("https must translate");
|
||||
// Endpoint already has /health → don't append the default "/".
|
||||
assert_eq!(h.cmd, "curl -fsS -m 5 https://example.local/health");
|
||||
assert!(h.cmd.contains("https://example.local/health"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1056,12 +1181,46 @@ app:
|
||||
"#;
|
||||
let m = AppManifest::parse(yaml).unwrap();
|
||||
let u = QuadletUnit::from_manifest(&m, "lnd");
|
||||
let h = u.health.as_ref().expect("health should be populated");
|
||||
assert_eq!(h.cmd, "nc -z localhost 10009");
|
||||
assert_eq!(h.interval, "15s");
|
||||
assert_eq!(h.timeout, "4s");
|
||||
assert_eq!(h.retries, 5);
|
||||
assert!(u.render().contains("Notify=healthy"));
|
||||
assert!(u.health.is_none());
|
||||
assert!(!u.render().contains("Notify=healthy"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publish_ports_changed_detects_port_binding_drift() {
|
||||
let old = "[Container]\nPublishPort=9735:9735/tcp\nPublishPort=8080:8080/tcp\n";
|
||||
let new = "[Container]\nPublishPort=9735:9735/tcp\nPublishPort=18080:8080/tcp\n";
|
||||
assert!(publish_ports_changed(old, new));
|
||||
assert!(!publish_ports_changed(new, new));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_aliases_changed_detects_service_discovery_drift() {
|
||||
let old = "[Container]\nNetwork=archy-net\n";
|
||||
let new = "[Container]\nNetwork=archy-net\nNetworkAlias=bitcoin-knots\n";
|
||||
assert!(network_aliases_changed(old, new));
|
||||
assert!(!network_aliases_changed(new, new));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_join_escapes_dollars_for_container_runtime_expansion() {
|
||||
let rendered = shell_join(&["sh".into(), "-lc".into(), "echo ${BITCOIN_RPC_PASS}".into()]);
|
||||
assert!(rendered.contains("$${BITCOIN_RPC_PASS}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_changed_detects_command_drift() {
|
||||
let old = "[Container]\nExec=sh -lc \"echo ${BITCOIN_RPC_PASS}\"\n";
|
||||
let new = "[Container]\nExec=sh -lc \"echo $${BITCOIN_RPC_PASS}\"\n";
|
||||
assert!(exec_changed(old, new));
|
||||
assert!(!exec_changed(new, new));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn health_cmd_changed_detects_probe_drift() {
|
||||
let old = "[Container]\nHealthCmd=curl -fsS http://localhost:8080/\n";
|
||||
let new = "[Container]\nHealthCmd=if command -v wget >/dev/null 2>&1; then wget -q -T 5 -O /dev/null http://localhost:8080/; elif command -v curl >/dev/null 2>&1; then curl -fsS -m 5 http://localhost:8080/; else exit 0; fi\n";
|
||||
assert!(health_cmd_changed(old, new));
|
||||
assert!(!health_cmd_changed(new, new));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1098,6 +1257,8 @@ app:
|
||||
assert!(body.contains("ContainerName=lnd"));
|
||||
assert!(body.contains("Image=registry/lnd:latest"));
|
||||
assert!(body.contains("Network=archy-net"));
|
||||
assert!(body.contains("NetworkAlias=lnd"));
|
||||
assert!(body.contains("PodmanArgs=--network-alias=lnd"));
|
||||
assert!(body.contains("PublishPort=10009:10009/tcp"));
|
||||
assert!(body.contains("Volume=/var/lib/archipelago/lnd:/root/.lnd:Z"));
|
||||
assert!(body.contains("Environment=LND_NETWORK=mainnet"));
|
||||
|
||||
@@ -11,8 +11,9 @@
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Output;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Instant;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::fs;
|
||||
use tracing::{info, warn};
|
||||
|
||||
@@ -189,15 +190,9 @@ pub async fn remove_pid_marker(data_dir: &Path) {
|
||||
/// Save a snapshot of currently running containers to disk.
|
||||
/// Called periodically so we know what to restart after a crash.
|
||||
pub async fn save_container_snapshot(data_dir: &Path) -> Result<()> {
|
||||
let output = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(30),
|
||||
tokio::process::Command::new("podman")
|
||||
.args(["ps", "--format", "json"])
|
||||
.output(),
|
||||
)
|
||||
.await
|
||||
.context("podman ps timed out (30s)")?
|
||||
.context("Failed to run podman ps")?;
|
||||
let mut cmd = tokio::process::Command::new("podman");
|
||||
cmd.args(["ps", "--format", "json"]);
|
||||
let output = command_with_timeout(cmd, Duration::from_secs(30), "podman ps").await?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
@@ -277,22 +272,23 @@ pub async fn recover_containers(containers: &[RunningContainerRecord]) -> Recove
|
||||
);
|
||||
tokio::time::sleep(std::time::Duration::from_secs(10)).await;
|
||||
}
|
||||
let result = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(timeout_secs),
|
||||
tokio::process::Command::new("podman")
|
||||
.args(["start", &record.name])
|
||||
.output(),
|
||||
let mut cmd = tokio::process::Command::new("podman");
|
||||
cmd.args(["start", &record.name]);
|
||||
let result = command_with_timeout(
|
||||
cmd,
|
||||
Duration::from_secs(timeout_secs),
|
||||
&format!("podman start {}", record.name),
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(Ok(output)) if output.status.success() => {
|
||||
Ok(output) if output.status.success() => {
|
||||
info!("Successfully restarted container: {}", record.name);
|
||||
report.recovered += 1;
|
||||
started = true;
|
||||
break;
|
||||
}
|
||||
Ok(Ok(output)) => {
|
||||
Ok(output) => {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
warn!(
|
||||
"Failed to restart container {} (attempt {}): {}",
|
||||
@@ -301,20 +297,13 @@ pub async fn recover_containers(containers: &[RunningContainerRecord]) -> Recove
|
||||
stderr.trim()
|
||||
);
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Failed to execute podman start for {} (attempt {}): {}",
|
||||
record.name,
|
||||
attempt + 1,
|
||||
e
|
||||
);
|
||||
}
|
||||
Err(_) => {
|
||||
warn!(
|
||||
"Timeout starting container {} ({}s, attempt {})",
|
||||
"Failed to start container {} ({}s, attempt {}): {}",
|
||||
record.name,
|
||||
timeout_secs,
|
||||
attempt + 1
|
||||
attempt + 1,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -345,26 +334,22 @@ fn is_process_running(pid: u32) -> bool {
|
||||
/// The crash recovery (PID-based) handles dirty shutdowns; this handles clean ones.
|
||||
/// Skips containers that the user intentionally stopped via the UI.
|
||||
pub async fn start_stopped_containers(data_dir: &Path) -> RecoveryReport {
|
||||
let output = match tokio::time::timeout(
|
||||
std::time::Duration::from_secs(60),
|
||||
tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"ps",
|
||||
"-a",
|
||||
"--filter",
|
||||
"status=exited",
|
||||
"--filter",
|
||||
"status=created",
|
||||
"--format",
|
||||
"{{.Names}}",
|
||||
])
|
||||
.output(),
|
||||
)
|
||||
.await
|
||||
let mut cmd = tokio::process::Command::new("podman");
|
||||
cmd.args([
|
||||
"ps",
|
||||
"-a",
|
||||
"--filter",
|
||||
"status=exited",
|
||||
"--filter",
|
||||
"status=created",
|
||||
"--format",
|
||||
"{{.Names}}",
|
||||
]);
|
||||
let output = match command_with_timeout(cmd, Duration::from_secs(60), "podman ps stopped").await
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(_) => {
|
||||
warn!("Timeout listing stopped containers (60s)");
|
||||
Ok(output) => output,
|
||||
Err(e) => {
|
||||
warn!("Failed listing stopped containers: {}", e);
|
||||
return RecoveryReport {
|
||||
total: 0,
|
||||
recovered: 0,
|
||||
@@ -373,13 +358,14 @@ pub async fn start_stopped_containers(data_dir: &Path) -> RecoveryReport {
|
||||
}
|
||||
};
|
||||
|
||||
let all_names: Vec<String> = match output {
|
||||
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout)
|
||||
let all_names: Vec<String> = if output.status.success() {
|
||||
String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.filter(|l| !l.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
if all_names.is_empty() {
|
||||
@@ -481,27 +467,35 @@ pub async fn run_boot_reconciliation() {
|
||||
return;
|
||||
}
|
||||
info!("Running boot reconciliation...");
|
||||
let result = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(300),
|
||||
tokio::process::Command::new(script).output(),
|
||||
)
|
||||
.await;
|
||||
let cmd = tokio::process::Command::new(script);
|
||||
let result = command_with_timeout(cmd, Duration::from_secs(300), script).await;
|
||||
match result {
|
||||
Ok(Ok(output)) if output.status.success() => {
|
||||
Ok(output) if output.status.success() => {
|
||||
info!("Boot reconciliation complete");
|
||||
}
|
||||
Ok(Ok(output)) => {
|
||||
Ok(output) => {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
warn!(
|
||||
"Boot reconciliation had failures: {}",
|
||||
stderr.chars().take(500).collect::<String>()
|
||||
);
|
||||
}
|
||||
Ok(Err(e)) => warn!("Boot reconciliation failed to run: {}", e),
|
||||
Err(_) => warn!("Boot reconciliation timed out (300s)"),
|
||||
Err(e) => warn!("Boot reconciliation failed: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
async fn command_with_timeout(
|
||||
mut cmd: tokio::process::Command,
|
||||
timeout: Duration,
|
||||
description: &str,
|
||||
) -> Result<Output> {
|
||||
cmd.kill_on_drop(true);
|
||||
tokio::time::timeout(timeout, cmd.output())
|
||||
.await
|
||||
.with_context(|| format!("{} timed out after {}s", description, timeout.as_secs()))?
|
||||
.with_context(|| format!("Failed to run {}", description))
|
||||
}
|
||||
|
||||
/// Spawn a background task that periodically saves the container snapshot.
|
||||
pub fn spawn_snapshot_task(data_dir: PathBuf) {
|
||||
tokio::spawn(async move {
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::data_model::{Notification, NotificationLevel, PackageState};
|
||||
use crate::state::StateManager;
|
||||
use crate::webhooks::{self, WebhookEvent};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
@@ -216,6 +216,7 @@ struct ContainerHealth {
|
||||
name: String,
|
||||
app_id: String,
|
||||
state: String,
|
||||
podman_health: Option<String>,
|
||||
healthy: bool,
|
||||
}
|
||||
|
||||
@@ -419,6 +420,9 @@ async fn check_containers() -> Vec<ContainerHealth> {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let containers: Vec<serde_json::Value> = serde_json::from_str(&stdout).unwrap_or_default();
|
||||
|
||||
let live_container_ids = live_container_ids(&containers);
|
||||
cleanup_stale_podman_healthcheck_units(&live_container_ids).await;
|
||||
|
||||
// Monitor ALL long-running containers for health — backend services (databases,
|
||||
// nbxplorer, mempool-api) and UI containers need auto-restart too.
|
||||
// Only skip ephemeral containers (build infrastructure, init one-shots).
|
||||
@@ -447,18 +451,195 @@ async fn check_containers() -> Vec<ContainerHealth> {
|
||||
.unwrap_or("unknown")
|
||||
.to_lowercase();
|
||||
|
||||
let healthy = state == "running";
|
||||
let podman_health = parse_podman_health(c, &state);
|
||||
let healthy = state == "running" && podman_health.as_deref() != Some("unhealthy");
|
||||
|
||||
Some(ContainerHealth {
|
||||
name,
|
||||
app_id,
|
||||
state,
|
||||
podman_health,
|
||||
healthy,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn live_container_ids(containers: &[serde_json::Value]) -> HashSet<String> {
|
||||
containers
|
||||
.iter()
|
||||
.filter_map(|c| {
|
||||
c.get("Id")
|
||||
.or_else(|| c.get("ID"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn cleanup_stale_podman_healthcheck_units(live_container_ids: &HashSet<String>) {
|
||||
if live_container_ids.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut units = stale_healthcheck_units_from_systemd(live_container_ids).await;
|
||||
if units.is_empty() {
|
||||
return;
|
||||
}
|
||||
units.sort();
|
||||
units.dedup();
|
||||
|
||||
let mut cleaned = 0;
|
||||
for unit in units {
|
||||
let Some(container_id) = parse_podman_healthcheck_unit(&unit) else {
|
||||
continue;
|
||||
};
|
||||
let service = format!("{}.service", unit.trim_end_matches(".timer"));
|
||||
if stop_user_unit(&unit).await {
|
||||
cleaned += 1;
|
||||
}
|
||||
let _ = stop_user_unit(&service).await;
|
||||
let _ = reset_failed_user_unit(&service).await;
|
||||
debug!(
|
||||
"Stopped stale Podman healthcheck unit {} for removed container {}",
|
||||
unit, container_id
|
||||
);
|
||||
}
|
||||
|
||||
if cleaned > 0 {
|
||||
info!("Cleaned {} stale Podman healthcheck timer(s)", cleaned);
|
||||
}
|
||||
}
|
||||
|
||||
async fn stale_healthcheck_units_from_systemd(live_container_ids: &HashSet<String>) -> Vec<String> {
|
||||
let mut units = Vec::new();
|
||||
for args in [
|
||||
[
|
||||
"--user",
|
||||
"list-timers",
|
||||
"--all",
|
||||
"--no-legend",
|
||||
"--no-pager",
|
||||
]
|
||||
.as_slice(),
|
||||
["--user", "list-units", "--all", "--no-legend", "--no-pager"].as_slice(),
|
||||
] {
|
||||
let output = match tokio::time::timeout(
|
||||
std::time::Duration::from_secs(20),
|
||||
tokio::process::Command::new("systemctl")
|
||||
.args(args.iter().copied())
|
||||
.output(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(output)) if output.status.success() => output,
|
||||
Ok(Ok(output)) => {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
debug!("systemctl {} failed: {}", args.join(" "), stderr.trim());
|
||||
continue;
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
debug!("Failed to run systemctl {}: {}", args.join(" "), e);
|
||||
continue;
|
||||
}
|
||||
Err(_) => {
|
||||
debug!("systemctl {} timed out", args.join(" "));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
units.extend(stale_healthcheck_units(&stdout, live_container_ids));
|
||||
}
|
||||
units
|
||||
}
|
||||
|
||||
fn stale_healthcheck_units(output: &str, live_container_ids: &HashSet<String>) -> Vec<String> {
|
||||
output
|
||||
.lines()
|
||||
.flat_map(|line| line.split_whitespace())
|
||||
.filter_map(|token| {
|
||||
let unit = token.trim_start_matches('●');
|
||||
let id = parse_podman_healthcheck_unit(unit)?;
|
||||
(!live_container_ids.contains(id)).then(|| unit.to_string())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_podman_healthcheck_unit(unit: &str) -> Option<&str> {
|
||||
let unit = unit
|
||||
.strip_suffix(".timer")
|
||||
.or_else(|| unit.strip_suffix(".service"))?;
|
||||
let (container_id, _suffix) = unit.split_once('-')?;
|
||||
if container_id.len() == 64 && container_id.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
Some(container_id)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
async fn stop_user_unit(unit: &str) -> bool {
|
||||
run_systemctl_user(["stop", unit]).await
|
||||
}
|
||||
|
||||
async fn reset_failed_user_unit(unit: &str) -> bool {
|
||||
run_systemctl_user(["reset-failed", unit]).await
|
||||
}
|
||||
|
||||
async fn run_systemctl_user<const N: usize>(args: [&str; N]) -> bool {
|
||||
let output = match tokio::time::timeout(
|
||||
std::time::Duration::from_secs(10),
|
||||
tokio::process::Command::new("systemctl")
|
||||
.arg("--user")
|
||||
.args(args.iter().copied())
|
||||
.output(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(output)) => output,
|
||||
Ok(Err(e)) => {
|
||||
debug!("Failed to run systemctl --user {}: {}", args.join(" "), e);
|
||||
return false;
|
||||
}
|
||||
Err(_) => {
|
||||
debug!("systemctl --user {} timed out", args.join(" "));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
if output.status.success() {
|
||||
true
|
||||
} else {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
debug!(
|
||||
"systemctl --user {} failed: {}",
|
||||
args.join(" "),
|
||||
stderr.trim()
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_podman_health(c: &serde_json::Value, state: &str) -> Option<String> {
|
||||
c.get("Status")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(parse_health_from_status)
|
||||
.or_else(|| {
|
||||
c.get("State")
|
||||
.and_then(|v| v.get("Health"))
|
||||
.and_then(|v| v.get("Status"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
})
|
||||
.or_else(|| state.contains("unhealthy").then(|| "unhealthy".to_string()))
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
/// Try to restart a container.
|
||||
async fn restart_container(name: &str) -> bool {
|
||||
info!("Auto-restarting unhealthy container: {}", name);
|
||||
@@ -590,10 +771,26 @@ pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
|
||||
"Skipping orphan container (not in package_data): {}",
|
||||
container.name
|
||||
);
|
||||
let before = data.notifications.len();
|
||||
data.notifications.retain(|n| {
|
||||
n.app_id.as_deref() != Some(&container.app_id)
|
||||
&& !n.title.contains(&container.app_id)
|
||||
});
|
||||
if data.notifications.len() != before {
|
||||
state_changed = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if container.healthy {
|
||||
let before = data.notifications.len();
|
||||
data.notifications.retain(|n| {
|
||||
n.app_id.as_deref() != Some(&container.app_id)
|
||||
&& !n.title.contains(&container.app_id)
|
||||
});
|
||||
if data.notifications.len() != before {
|
||||
state_changed = true;
|
||||
}
|
||||
if tracker.attempt_count(&container.name) > 0 {
|
||||
info!(
|
||||
"Container {} is healthy again after restart",
|
||||
@@ -628,8 +825,9 @@ pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Handle exited, stopped, AND created state containers
|
||||
if container.state == "exited"
|
||||
// Handle exited, stopped, created, and Podman-unhealthy running containers.
|
||||
if container.podman_health.as_deref() == Some("unhealthy")
|
||||
|| container.state == "exited"
|
||||
|| container.state == "stopped"
|
||||
|| container.state == "created"
|
||||
{
|
||||
@@ -642,6 +840,21 @@ pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
|
||||
}
|
||||
}
|
||||
|
||||
let unhealthy_app_ids: HashSet<&str> = unhealthy
|
||||
.iter()
|
||||
.map(|container| container.app_id.as_str())
|
||||
.collect();
|
||||
let before = data.notifications.len();
|
||||
data.notifications.retain(|n| {
|
||||
!n.id.starts_with("health-")
|
||||
|| n.app_id
|
||||
.as_deref()
|
||||
.is_some_and(|app_id| unhealthy_app_ids.contains(app_id))
|
||||
});
|
||||
if data.notifications.len() != before {
|
||||
state_changed = true;
|
||||
}
|
||||
|
||||
// Sort by startup tier: databases first, then core, then dependent, then apps, then UIs
|
||||
unhealthy.sort_by_key(|c| container_tier(&c.name));
|
||||
|
||||
@@ -874,6 +1087,7 @@ mod tests {
|
||||
name: "archy-bitcoin-knots".to_string(),
|
||||
app_id: "bitcoin-knots".to_string(),
|
||||
state: "running".to_string(),
|
||||
podman_health: Some("healthy".to_string()),
|
||||
healthy: true,
|
||||
};
|
||||
assert!(health.healthy);
|
||||
@@ -888,6 +1102,7 @@ mod tests {
|
||||
name: "archy-mempool-web".to_string(),
|
||||
app_id: "mempool-web".to_string(),
|
||||
state: "exited".to_string(),
|
||||
podman_health: None,
|
||||
healthy: false,
|
||||
};
|
||||
assert!(!health.healthy);
|
||||
@@ -977,18 +1192,21 @@ mod tests {
|
||||
name: "indeedhub-postgres".into(),
|
||||
app_id: "indeedhub-postgres".into(),
|
||||
state: "running".into(),
|
||||
podman_health: None,
|
||||
healthy: true,
|
||||
},
|
||||
ContainerHealth {
|
||||
name: "indeedhub-redis".into(),
|
||||
app_id: "indeedhub-redis".into(),
|
||||
state: "running".into(),
|
||||
podman_health: None,
|
||||
healthy: true,
|
||||
},
|
||||
ContainerHealth {
|
||||
name: "indeedhub-api".into(),
|
||||
app_id: "indeedhub-api".into(),
|
||||
state: "exited".into(),
|
||||
podman_health: None,
|
||||
healthy: false,
|
||||
},
|
||||
];
|
||||
@@ -998,6 +1216,7 @@ mod tests {
|
||||
name: "indeedhub-redis".into(),
|
||||
app_id: "indeedhub-redis".into(),
|
||||
state: "running".into(),
|
||||
podman_health: None,
|
||||
healthy: true,
|
||||
}];
|
||||
assert!(!deps_are_running("indeedhub-api", &partial));
|
||||
@@ -1009,6 +1228,7 @@ mod tests {
|
||||
name: "bitcoin-core".into(),
|
||||
app_id: "bitcoin-core".into(),
|
||||
state: "running".into(),
|
||||
podman_health: None,
|
||||
healthy: true,
|
||||
}];
|
||||
assert!(deps_are_running("lnd", &core));
|
||||
@@ -1017,6 +1237,7 @@ mod tests {
|
||||
name: "bitcoin-knots".into(),
|
||||
app_id: "bitcoin-knots".into(),
|
||||
state: "running".into(),
|
||||
podman_health: None,
|
||||
healthy: true,
|
||||
}];
|
||||
assert!(deps_are_running("fedimint", &knots));
|
||||
@@ -1025,6 +1246,7 @@ mod tests {
|
||||
name: "bitcoin-core".into(),
|
||||
app_id: "bitcoin-core".into(),
|
||||
state: "stopped".into(),
|
||||
podman_health: None,
|
||||
healthy: false,
|
||||
}];
|
||||
assert!(!deps_are_running("electrumx", &stopped));
|
||||
@@ -1036,6 +1258,7 @@ mod tests {
|
||||
name: "bitcoin-core".into(),
|
||||
app_id: "bitcoin-core".into(),
|
||||
state: "running".into(),
|
||||
podman_health: None,
|
||||
healthy: true,
|
||||
}];
|
||||
|
||||
@@ -1050,6 +1273,7 @@ mod tests {
|
||||
name: "bitcoin-core".into(),
|
||||
app_id: "bitcoin-core".into(),
|
||||
state: "stopped".into(),
|
||||
podman_health: None,
|
||||
healthy: false,
|
||||
}];
|
||||
|
||||
@@ -1121,4 +1345,58 @@ mod tests {
|
||||
tracker.record("test", 100_000_000);
|
||||
assert!(tracker.check_leak("test").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn podman_unhealthy_makes_running_container_unhealthy() {
|
||||
let c = serde_json::json!({ "Status": "Up 5 minutes (unhealthy)" });
|
||||
assert_eq!(
|
||||
parse_podman_health(&c, "running").as_deref(),
|
||||
Some("unhealthy")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_podman_healthcheck_systemd_units() {
|
||||
let id = "c1f44a6369c91d65f9e9f6134a5591aa02792cff2f1a4e0f689b5a6c03b6c77c";
|
||||
assert_eq!(
|
||||
parse_podman_healthcheck_unit(&format!("{}-15c66ddfefa8a763.timer", id)),
|
||||
Some(id)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_podman_healthcheck_unit(&format!("{}-15c66ddfefa8a763.service", id)),
|
||||
Some(id)
|
||||
);
|
||||
assert_eq!(parse_podman_healthcheck_unit("grafana.service"), None);
|
||||
assert_eq!(
|
||||
parse_podman_healthcheck_unit(
|
||||
"nothexzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz-x.timer"
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_healthcheck_units_filters_only_removed_container_ids() {
|
||||
let live = "6467e25fd87d791a63fe9dbf6e2fabc7bf26533aa2c402b1089effeacf7ebbba";
|
||||
let stale = "c1f44a6369c91d65f9e9f6134a5591aa02792cff2f1a4e0f689b5a6c03b6c77c";
|
||||
let mut live_ids = HashSet::new();
|
||||
live_ids.insert(live.to_string());
|
||||
|
||||
let output = format!(
|
||||
" {live}-6fdc497fd3ba3b62.timer loaded active waiting\n\
|
||||
● {stale}-15c66ddfefa8a763.service loaded failed failed\n\
|
||||
grafana.service loaded active running\n\
|
||||
{stale}-1898d85de0bb707f.timer loaded active waiting\n"
|
||||
);
|
||||
|
||||
let mut units = stale_healthcheck_units(&output, &live_ids);
|
||||
units.sort();
|
||||
assert_eq!(
|
||||
units,
|
||||
vec![
|
||||
format!("{stale}-15c66ddfefa8a763.service"),
|
||||
format!("{stale}-1898d85de0bb707f.timer"),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,42 +1,140 @@
|
||||
//! Mesh session lifecycle: connect, initialize, main loop.
|
||||
|
||||
use super::super::meshtastic::MeshtasticDevice;
|
||||
use super::super::serial::MeshcoreDevice;
|
||||
use super::super::types::*;
|
||||
use super::{
|
||||
frames, MeshCommand, MeshState, ADVERT_INTERVAL, MAX_CONSECUTIVE_WRITE_FAILURES, SYNC_INTERVAL,
|
||||
};
|
||||
use anyhow::Result;
|
||||
use anyhow::{Context, Result};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
/// Scan all candidate serial ports and open the first Meshcore device found.
|
||||
async fn auto_detect_and_open() -> Result<(String, MeshcoreDevice, DeviceInfo)> {
|
||||
enum MeshRadioDevice {
|
||||
Meshcore(MeshcoreDevice),
|
||||
Meshtastic(MeshtasticDevice),
|
||||
}
|
||||
|
||||
impl MeshRadioDevice {
|
||||
fn device_type(&self) -> DeviceType {
|
||||
match self {
|
||||
Self::Meshcore(_) => DeviceType::Meshcore,
|
||||
Self::Meshtastic(_) => DeviceType::Meshtastic,
|
||||
}
|
||||
}
|
||||
|
||||
fn advert_name(&self) -> Option<String> {
|
||||
match self {
|
||||
Self::Meshcore(device) => device.advert_name.clone(),
|
||||
Self::Meshtastic(device) => device.advert_name(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn set_advert_name(&mut self, name: &str) -> Result<()> {
|
||||
match self {
|
||||
Self::Meshcore(device) => device.set_advert_name(name).await,
|
||||
Self::Meshtastic(device) => device.set_advert_name(name).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_self_advert(&mut self) -> Result<()> {
|
||||
match self {
|
||||
Self::Meshcore(device) => device.send_self_advert().await,
|
||||
Self::Meshtastic(device) => device.send_self_advert().await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_channel_text(&mut self, channel: u8, payload: &[u8]) -> Result<()> {
|
||||
match self {
|
||||
Self::Meshcore(device) => device.send_channel_text(channel, payload).await,
|
||||
Self::Meshtastic(device) => device.send_channel_text(channel, payload).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_contacts(&mut self) -> Result<Vec<super::super::protocol::ParsedContact>> {
|
||||
match self {
|
||||
Self::Meshcore(device) => device.get_contacts().await,
|
||||
Self::Meshtastic(device) => device.get_contacts().await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn reset_contact_path(&mut self, pubkey: &[u8; 32]) -> Result<()> {
|
||||
match self {
|
||||
Self::Meshcore(device) => device.reset_contact_path(pubkey).await,
|
||||
Self::Meshtastic(device) => device.reset_contact_path(pubkey).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn sync_messages(&mut self) -> Result<Vec<super::super::protocol::InboundFrame>> {
|
||||
match self {
|
||||
Self::Meshcore(device) => device.sync_messages().await,
|
||||
Self::Meshtastic(device) => device.sync_messages().await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn try_recv_frame(&mut self) -> Result<Option<super::super::protocol::InboundFrame>> {
|
||||
match self {
|
||||
Self::Meshcore(device) => device.try_recv_frame().await,
|
||||
Self::Meshtastic(device) => device.try_recv_frame().await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Scan all candidate serial ports and open the first supported mesh device found.
|
||||
async fn auto_detect_and_open() -> Result<(String, MeshRadioDevice, DeviceInfo)> {
|
||||
let paths = super::super::serial::detect_serial_devices().await;
|
||||
if paths.is_empty() {
|
||||
anyhow::bail!("No serial devices found in /dev");
|
||||
}
|
||||
for path in &paths {
|
||||
debug!(path = %path, "Probing for Meshcore device");
|
||||
debug!(path = %path, "Probing for mesh radio device");
|
||||
match MeshcoreDevice::open(path).await {
|
||||
Ok(mut dev) => match dev.initialize().await {
|
||||
Ok(info) => {
|
||||
info!(path = %path, firmware = %info.firmware_version, "Found Meshcore device via auto-detect");
|
||||
return Ok((path.clone(), dev, info));
|
||||
return Ok((path.clone(), MeshRadioDevice::Meshcore(dev), info));
|
||||
}
|
||||
Err(e) => debug!(path = %path, error = %e, "Not a Meshcore device"),
|
||||
},
|
||||
Err(e) => debug!(path = %path, error = %e, "Could not open serial port"),
|
||||
}
|
||||
match MeshtasticDevice::open(path).await {
|
||||
Ok(mut dev) => match dev.initialize().await {
|
||||
Ok(info) => {
|
||||
info!(path = %path, firmware = %info.firmware_version, "Found Meshtastic device via auto-detect");
|
||||
return Ok((path.clone(), MeshRadioDevice::Meshtastic(dev), info));
|
||||
}
|
||||
Err(e) => debug!(path = %path, error = %e, "Not a Meshtastic device"),
|
||||
},
|
||||
Err(e) => debug!(path = %path, error = %e, "Could not open serial port for Meshtastic"),
|
||||
}
|
||||
}
|
||||
anyhow::bail!(
|
||||
"No Meshcore device found on {} candidate ports: {:?}",
|
||||
"No supported mesh radio found on {} candidate ports: {:?}",
|
||||
paths.len(),
|
||||
paths
|
||||
)
|
||||
}
|
||||
|
||||
async fn open_preferred_path(path: &str) -> Result<(MeshRadioDevice, DeviceInfo)> {
|
||||
match MeshcoreDevice::open(path).await {
|
||||
Ok(mut dev) => match dev.initialize().await {
|
||||
Ok(info) => return Ok((MeshRadioDevice::Meshcore(dev), info)),
|
||||
Err(e) => debug!(path = %path, error = %e, "Preferred path is not Meshcore"),
|
||||
},
|
||||
Err(e) => debug!(path = %path, error = %e, "Could not open preferred path as Meshcore"),
|
||||
}
|
||||
match MeshtasticDevice::open(path).await {
|
||||
Ok(mut dev) => match dev.initialize().await {
|
||||
Ok(info) => Ok((MeshRadioDevice::Meshtastic(dev), info)),
|
||||
Err(e) => Err(e).context("Preferred path is not Meshtastic"),
|
||||
},
|
||||
Err(e) => Err(e).context("Could not open preferred path as Meshtastic"),
|
||||
}
|
||||
}
|
||||
|
||||
/// ASCII marker for the original DM-via-channel format:
|
||||
/// `@DM:` + base64(`[dest_prefix(6)][inner…]`). No sender info on the wire,
|
||||
/// so the receiver has to guess the sender from its contact table — which
|
||||
@@ -90,7 +188,7 @@ fn our_sender_prefix(state: &Arc<MeshState>) -> [u8; 6] {
|
||||
/// with both the recipient and sender prefixes so attribution works on
|
||||
/// the receiver side.
|
||||
async fn send_dm_via_channel(
|
||||
device: &mut MeshcoreDevice,
|
||||
device: &mut MeshRadioDevice,
|
||||
state: &Arc<MeshState>,
|
||||
dest_pubkey_prefix: &[u8; 6],
|
||||
payload: &[u8],
|
||||
@@ -166,7 +264,7 @@ async fn send_dm_via_channel(
|
||||
}
|
||||
|
||||
/// Fetch the contacts list from the device and update the peer cache.
|
||||
async fn refresh_contacts(device: &mut MeshcoreDevice, state: &Arc<MeshState>) {
|
||||
async fn refresh_contacts(device: &mut MeshRadioDevice, state: &Arc<MeshState>) {
|
||||
match device.get_contacts().await {
|
||||
Ok(contacts) => {
|
||||
// Skip firmware contacts the user has explicitly wiped via
|
||||
@@ -239,7 +337,7 @@ async fn refresh_contacts(device: &mut MeshcoreDevice, state: &Arc<MeshState>) {
|
||||
/// Drain any queued messages from the device.
|
||||
/// Returns `true` if a write/communication error occurred (for failure tracking).
|
||||
async fn sync_queued_messages(
|
||||
device: &mut MeshcoreDevice,
|
||||
device: &mut MeshRadioDevice,
|
||||
state: &Arc<MeshState>,
|
||||
our_x25519_secret: &[u8; 32],
|
||||
) -> bool {
|
||||
@@ -274,20 +372,11 @@ pub(super) async fn run_mesh_session(
|
||||
) -> Result<()> {
|
||||
// Detect device — try preferred path first, fall back to auto-detect
|
||||
let (device_path, mut device, device_info) = if let Some(path) = preferred_path {
|
||||
match MeshcoreDevice::open(path).await {
|
||||
Ok(mut dev) => match dev.initialize().await {
|
||||
Ok(info) => (path.to_string(), dev, info),
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Preferred path {} handshake failed: {} — trying auto-detect",
|
||||
path, e
|
||||
);
|
||||
auto_detect_and_open().await?
|
||||
}
|
||||
},
|
||||
match open_preferred_path(path).await {
|
||||
Ok((dev, info)) => (path.to_string(), dev, info),
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Preferred path {} open failed: {} — trying auto-detect",
|
||||
"Preferred path {} probe failed: {} — trying auto-detect",
|
||||
path, e
|
||||
);
|
||||
auto_detect_and_open().await?
|
||||
@@ -301,11 +390,11 @@ pub(super) async fn run_mesh_session(
|
||||
{
|
||||
let mut status = state.status.write().await;
|
||||
status.device_connected = true;
|
||||
status.device_type = DeviceType::Meshcore;
|
||||
status.device_type = device.device_type();
|
||||
status.device_path = Some(device_path.clone());
|
||||
status.firmware_version = Some(device_info.firmware_version.clone());
|
||||
status.self_node_id = Some(device_info.node_id);
|
||||
status.self_advert_name = device.advert_name.clone();
|
||||
status.self_advert_name = device.advert_name();
|
||||
}
|
||||
|
||||
let _ = state.event_tx.send(MeshEvent::DeviceConnected(device_info));
|
||||
@@ -434,7 +523,7 @@ pub(super) async fn run_mesh_session(
|
||||
/// Process a single outbound command from MeshService.
|
||||
async fn handle_send_command(
|
||||
cmd: MeshCommand,
|
||||
device: &mut MeshcoreDevice,
|
||||
device: &mut MeshRadioDevice,
|
||||
state: &Arc<MeshState>,
|
||||
consecutive_write_failures: &mut u32,
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,621 @@
|
||||
//! Async serial driver for Meshtastic devices.
|
||||
//!
|
||||
//! Meshtastic uses protobuf payloads over a SLIP-like serial stream. This
|
||||
//! module implements only the small subset Archipelago needs: connect,
|
||||
//! discover the local node, send/receive text packets, and provide synthetic
|
||||
//! contacts to the existing mesh listener.
|
||||
|
||||
use super::protocol::{InboundFrame, ParsedContact};
|
||||
use super::types::{DeviceInfo, DeviceType};
|
||||
use anyhow::{Context, Result};
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
const BAUD_RATE: u32 = 115200;
|
||||
const READ_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const WRITE_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
const READ_BUF_SIZE: usize = 512;
|
||||
|
||||
const START1: u8 = 0x94;
|
||||
const START2: u8 = 0xc3;
|
||||
const TO_RADIO_MAX: usize = 512;
|
||||
const BROADCAST_NUM: u32 = 0xffff_ffff;
|
||||
const TEXT_MESSAGE_APP: u32 = 1;
|
||||
|
||||
const TO_RADIO_PACKET: u64 = 1;
|
||||
const TO_RADIO_WANT_CONFIG_ID: u64 = 3;
|
||||
const TO_RADIO_HEARTBEAT: u64 = 7;
|
||||
|
||||
const FROM_RADIO_PACKET: u64 = 2;
|
||||
const FROM_RADIO_MY_INFO: u64 = 3;
|
||||
const FROM_RADIO_NODE_INFO: u64 = 4;
|
||||
const FROM_RADIO_CONFIG_COMPLETE_ID: u64 = 7;
|
||||
const FROM_RADIO_REBOOTED: u64 = 8;
|
||||
|
||||
/// Async Meshtastic device handle.
|
||||
pub struct MeshtasticDevice {
|
||||
port: serial2_tokio::SerialPort,
|
||||
read_buf: Vec<u8>,
|
||||
node_num: Option<u32>,
|
||||
user_id: Option<String>,
|
||||
long_name: Option<String>,
|
||||
short_name: Option<String>,
|
||||
contacts: HashMap<u32, ParsedContact>,
|
||||
device_path: String,
|
||||
}
|
||||
|
||||
impl MeshtasticDevice {
|
||||
pub async fn open(path: &str) -> Result<Self> {
|
||||
match tokio::fs::metadata(path).await {
|
||||
Ok(meta) => {
|
||||
debug!(path = %path, permissions = ?meta.permissions(), "Device node exists")
|
||||
}
|
||||
Err(e) => anyhow::bail!("Serial device {} not accessible: {}", path, e),
|
||||
}
|
||||
|
||||
let port = serial2_tokio::SerialPort::open(path, BAUD_RATE).context(format!(
|
||||
"Failed to open serial port {} (permission denied? device busy?)",
|
||||
path
|
||||
))?;
|
||||
info!(path = %path, baud = BAUD_RATE, "Opened Meshtastic serial port");
|
||||
|
||||
Ok(Self {
|
||||
port,
|
||||
read_buf: Vec::with_capacity(READ_BUF_SIZE),
|
||||
node_num: None,
|
||||
user_id: None,
|
||||
long_name: None,
|
||||
short_name: None,
|
||||
contacts: HashMap::new(),
|
||||
device_path: path.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn initialize(&mut self) -> Result<DeviceInfo> {
|
||||
info!(path = %self.device_path, "Starting Meshtastic handshake");
|
||||
self.send_to_radio(&encode_want_config()).await?;
|
||||
|
||||
let deadline = tokio::time::Instant::now() + READ_TIMEOUT;
|
||||
let mut saw_meshtastic_frame = false;
|
||||
let mut saw_config_complete = false;
|
||||
|
||||
loop {
|
||||
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
|
||||
if remaining.is_zero() {
|
||||
break;
|
||||
}
|
||||
match tokio::time::timeout(
|
||||
remaining.min(Duration::from_millis(250)),
|
||||
self.read_from_radio(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(Some(frame))) => {
|
||||
saw_meshtastic_frame = true;
|
||||
if matches!(
|
||||
decode_top_level_variant(&frame),
|
||||
Some((FROM_RADIO_CONFIG_COMPLETE_ID, _))
|
||||
) {
|
||||
saw_config_complete = true;
|
||||
}
|
||||
self.handle_from_radio(&frame);
|
||||
if saw_config_complete && self.node_num.is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(Ok(None)) | Err(_) => {}
|
||||
Ok(Err(e)) => return Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
if !saw_meshtastic_frame {
|
||||
anyhow::bail!("No Meshtastic serial API response");
|
||||
}
|
||||
|
||||
let node_id = self
|
||||
.node_num
|
||||
.ok_or_else(|| anyhow::anyhow!("Meshtastic serial API did not provide MyInfo"))?;
|
||||
if self.user_id.is_none() && self.long_name.is_none() && self.short_name.is_none() {
|
||||
anyhow::bail!("Meshtastic serial API did not provide node identity");
|
||||
}
|
||||
let firmware_version = self
|
||||
.long_name
|
||||
.clone()
|
||||
.or_else(|| self.user_id.clone())
|
||||
.unwrap_or_else(|| "Meshtastic".to_string());
|
||||
|
||||
info!(node_id, name = %firmware_version, "Meshtastic identity");
|
||||
Ok(DeviceInfo {
|
||||
firmware_version,
|
||||
node_id,
|
||||
max_contacts: 200,
|
||||
device_type: DeviceType::Meshtastic,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn set_advert_name(&mut self, name: &str) -> Result<()> {
|
||||
self.long_name = Some(name.to_string());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn send_self_advert(&mut self) -> Result<()> {
|
||||
self.send_to_radio(&encode_heartbeat()).await
|
||||
}
|
||||
|
||||
pub async fn send_channel_text(&mut self, _channel: u8, msg: &[u8]) -> Result<()> {
|
||||
let text = String::from_utf8_lossy(msg);
|
||||
let packet = encode_mesh_packet(BROADCAST_NUM, TEXT_MESSAGE_APP, text.as_bytes());
|
||||
self.send_to_radio(&encode_to_radio_variant(TO_RADIO_PACKET, &packet))
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_contacts(&mut self) -> Result<Vec<ParsedContact>> {
|
||||
if self.contacts.is_empty() {
|
||||
self.send_to_radio(&encode_want_config()).await?;
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
|
||||
while tokio::time::Instant::now() < deadline {
|
||||
match self.read_from_radio().await? {
|
||||
Some(frame) => {
|
||||
let config_complete = matches!(
|
||||
decode_top_level_variant(&frame),
|
||||
Some((FROM_RADIO_CONFIG_COMPLETE_ID, _))
|
||||
);
|
||||
self.handle_from_radio(&frame);
|
||||
if config_complete || !self.contacts.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
None => tokio::time::sleep(Duration::from_millis(50)).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(self.contacts.values().cloned().collect())
|
||||
}
|
||||
|
||||
pub async fn reset_contact_path(&mut self, _pubkey: &[u8; 32]) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn sync_messages(&mut self) -> Result<Vec<InboundFrame>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
pub async fn try_recv_frame(&mut self) -> Result<Option<InboundFrame>> {
|
||||
let Some(frame) = self.read_from_radio().await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(self.handle_from_radio(&frame))
|
||||
}
|
||||
|
||||
pub fn advert_name(&self) -> Option<String> {
|
||||
self.long_name
|
||||
.clone()
|
||||
.or_else(|| self.short_name.clone())
|
||||
.or_else(|| self.user_id.clone())
|
||||
}
|
||||
|
||||
async fn send_to_radio(&mut self, payload: &[u8]) -> Result<()> {
|
||||
if payload.len() > TO_RADIO_MAX {
|
||||
anyhow::bail!("Meshtastic payload too large: {} bytes", payload.len());
|
||||
}
|
||||
let mut frame = Vec::with_capacity(4 + payload.len());
|
||||
frame.push(START1);
|
||||
frame.push(START2);
|
||||
frame.extend_from_slice(&(payload.len() as u16).to_be_bytes());
|
||||
frame.extend_from_slice(payload);
|
||||
tokio::time::timeout(WRITE_TIMEOUT, self.port.write_all(&frame))
|
||||
.await
|
||||
.context("Meshtastic serial write timed out")?
|
||||
.context("Meshtastic serial write failed")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn read_from_radio(&mut self) -> Result<Option<Vec<u8>>> {
|
||||
if let Some(frame) = decode_serial_frame(&mut self.read_buf) {
|
||||
return Ok(Some(frame));
|
||||
}
|
||||
|
||||
let mut tmp = [0u8; READ_BUF_SIZE];
|
||||
match tokio::time::timeout(Duration::from_millis(50), self.port.read(&mut tmp)).await {
|
||||
Ok(Ok(0)) => anyhow::bail!("Meshtastic serial port closed"),
|
||||
Ok(Ok(n)) => self.read_buf.extend_from_slice(&tmp[..n]),
|
||||
Ok(Err(e)) => return Err(e).context("Meshtastic serial read error"),
|
||||
Err(_) => return Ok(None),
|
||||
}
|
||||
|
||||
Ok(decode_serial_frame(&mut self.read_buf))
|
||||
}
|
||||
|
||||
fn handle_from_radio(&mut self, frame: &[u8]) -> Option<InboundFrame> {
|
||||
let Some((field, value)) = decode_top_level_variant(frame) else {
|
||||
return None;
|
||||
};
|
||||
match field {
|
||||
FROM_RADIO_MY_INFO => {
|
||||
if let Some((node_num, user_id)) = parse_my_info(value) {
|
||||
self.node_num = Some(node_num);
|
||||
if let Some(user_id) = user_id {
|
||||
self.user_id = Some(user_id);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
FROM_RADIO_NODE_INFO => {
|
||||
self.update_node_info(value);
|
||||
None
|
||||
}
|
||||
FROM_RADIO_PACKET => self.packet_to_inbound_frame(value),
|
||||
FROM_RADIO_CONFIG_COMPLETE_ID | FROM_RADIO_REBOOTED => None,
|
||||
other => {
|
||||
debug!(
|
||||
field = other,
|
||||
len = value.len(),
|
||||
"Unhandled Meshtastic FromRadio field"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn update_node_info(&mut self, data: &[u8]) {
|
||||
if let Some(node) = parse_node_info(data) {
|
||||
let key = synthetic_pubkey(node.num);
|
||||
let name = node
|
||||
.long_name
|
||||
.or(node.short_name)
|
||||
.or(node.id)
|
||||
.unwrap_or_else(|| format!("Meshtastic !{:08x}", node.num));
|
||||
if Some(node.num) == self.node_num {
|
||||
self.long_name = Some(name.clone());
|
||||
}
|
||||
self.contacts.insert(
|
||||
node.num,
|
||||
ParsedContact {
|
||||
public_key_hex: hex::encode(key),
|
||||
advert_name: name,
|
||||
last_advert: node.last_heard.unwrap_or_default(),
|
||||
contact_type: 1,
|
||||
path_len: 0xff,
|
||||
flags: 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn packet_to_inbound_frame(&mut self, data: &[u8]) -> Option<InboundFrame> {
|
||||
let packet = parse_mesh_packet(data)?;
|
||||
if packet.portnum != TEXT_MESSAGE_APP || packet.payload.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let from = packet.from.unwrap_or(0);
|
||||
if Some(from) == self.node_num {
|
||||
return None;
|
||||
}
|
||||
let from_key = synthetic_pubkey(from);
|
||||
self.contacts.entry(from).or_insert_with(|| ParsedContact {
|
||||
public_key_hex: hex::encode(synthetic_pubkey(from)),
|
||||
advert_name: format!("Meshtastic !{:08x}", from),
|
||||
last_advert: 0,
|
||||
contact_type: 1,
|
||||
path_len: 0xff,
|
||||
flags: 0,
|
||||
});
|
||||
|
||||
let mut payload = Vec::with_capacity(15 + packet.payload.len());
|
||||
payload.push(0); // SNR unknown
|
||||
payload.extend_from_slice(&[0, 0]); // reserved
|
||||
payload.extend_from_slice(&from_key[..6]);
|
||||
payload.push(0xff); // unknown/flood path
|
||||
payload.push(0); // text type
|
||||
payload.extend_from_slice(&0u32.to_le_bytes());
|
||||
payload.extend_from_slice(&packet.payload);
|
||||
Some(InboundFrame {
|
||||
code: super::protocol::RESP_CONTACT_MSG_V3,
|
||||
data: payload,
|
||||
bytes_consumed: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_serial_frame(buf: &mut Vec<u8>) -> Option<Vec<u8>> {
|
||||
let start = buf.windows(2).position(|w| w == [START1, START2])?;
|
||||
if start > 0 {
|
||||
buf.drain(..start);
|
||||
}
|
||||
if buf.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
let len = u16::from_be_bytes([buf[2], buf[3]]) as usize;
|
||||
if buf.len() < 4 + len {
|
||||
return None;
|
||||
}
|
||||
let payload = buf[4..4 + len].to_vec();
|
||||
buf.drain(..4 + len);
|
||||
Some(payload)
|
||||
}
|
||||
|
||||
fn encode_want_config() -> Vec<u8> {
|
||||
encode_varint_field(TO_RADIO_WANT_CONFIG_ID, 1)
|
||||
}
|
||||
|
||||
fn encode_heartbeat() -> Vec<u8> {
|
||||
encode_to_radio_variant(TO_RADIO_HEARTBEAT, &[])
|
||||
}
|
||||
|
||||
fn encode_to_radio_variant(field: u64, bytes: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
encode_len_field(field, bytes, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn encode_mesh_packet(to: u32, portnum: u32, payload: &[u8]) -> Vec<u8> {
|
||||
let mut decoded = Vec::new();
|
||||
encode_varint_field_into(1, portnum as u64, &mut decoded);
|
||||
encode_len_field(2, payload, &mut decoded);
|
||||
|
||||
let mut packet = Vec::new();
|
||||
encode_fixed32_field(2, to, &mut packet);
|
||||
encode_len_field(4, &decoded, &mut packet);
|
||||
packet
|
||||
}
|
||||
|
||||
fn decode_top_level_variant(buf: &[u8]) -> Option<(u64, &[u8])> {
|
||||
let mut idx = 0;
|
||||
while idx < buf.len() {
|
||||
let (key, n) = read_varint(&buf[idx..])?;
|
||||
idx += n;
|
||||
let field = key >> 3;
|
||||
match key & 0x07 {
|
||||
0 => {
|
||||
let (_, n) = read_varint(&buf[idx..])?;
|
||||
idx += n;
|
||||
if matches!(field, FROM_RADIO_CONFIG_COMPLETE_ID | FROM_RADIO_REBOOTED) {
|
||||
return Some((field, &[]));
|
||||
}
|
||||
}
|
||||
2 => {
|
||||
let (len, n) = read_varint(&buf[idx..])?;
|
||||
idx += n;
|
||||
let end = idx.checked_add(len as usize)?;
|
||||
if end > buf.len() {
|
||||
return None;
|
||||
}
|
||||
if matches!(
|
||||
field,
|
||||
FROM_RADIO_PACKET | FROM_RADIO_MY_INFO | FROM_RADIO_NODE_INFO
|
||||
) {
|
||||
return Some((field, &buf[idx..end]));
|
||||
}
|
||||
idx = end;
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn parse_my_info(data: &[u8]) -> Option<(u32, Option<String>)> {
|
||||
let mut idx = 0;
|
||||
let mut node_num = None;
|
||||
let mut user_id = None;
|
||||
while idx < data.len() {
|
||||
let (field, value, next) = next_field(data, idx)?;
|
||||
idx = next;
|
||||
match (field, value) {
|
||||
(1, FieldValue::Varint(v)) => node_num = Some(v as u32),
|
||||
(1, FieldValue::Fixed32(v)) => node_num = Some(v),
|
||||
(3, FieldValue::Bytes(b)) => user_id = parse_user(b).and_then(|u| u.id),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
node_num.map(|n| (n, user_id))
|
||||
}
|
||||
|
||||
struct ParsedNode {
|
||||
num: u32,
|
||||
id: Option<String>,
|
||||
long_name: Option<String>,
|
||||
short_name: Option<String>,
|
||||
last_heard: Option<u32>,
|
||||
}
|
||||
|
||||
fn parse_node_info(data: &[u8]) -> Option<ParsedNode> {
|
||||
let mut idx = 0;
|
||||
let mut node = ParsedNode {
|
||||
num: 0,
|
||||
id: None,
|
||||
long_name: None,
|
||||
short_name: None,
|
||||
last_heard: None,
|
||||
};
|
||||
while idx < data.len() {
|
||||
let (field, value, next) = next_field(data, idx)?;
|
||||
idx = next;
|
||||
match (field, value) {
|
||||
(1, FieldValue::Varint(v)) => node.num = v as u32,
|
||||
(1, FieldValue::Fixed32(v)) => node.num = v,
|
||||
(2, FieldValue::Bytes(b)) => {
|
||||
if let Some(user) = parse_user(b) {
|
||||
node.id = user.id;
|
||||
node.long_name = user.long_name;
|
||||
node.short_name = user.short_name;
|
||||
}
|
||||
}
|
||||
(5, FieldValue::Fixed32(v)) => node.last_heard = Some(v),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if node.num == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(node)
|
||||
}
|
||||
}
|
||||
|
||||
struct ParsedUser {
|
||||
id: Option<String>,
|
||||
long_name: Option<String>,
|
||||
short_name: Option<String>,
|
||||
}
|
||||
|
||||
fn parse_user(data: &[u8]) -> Option<ParsedUser> {
|
||||
let mut idx = 0;
|
||||
let mut user = ParsedUser {
|
||||
id: None,
|
||||
long_name: None,
|
||||
short_name: None,
|
||||
};
|
||||
while idx < data.len() {
|
||||
let (field, value, next) = next_field(data, idx)?;
|
||||
idx = next;
|
||||
match (field, value) {
|
||||
(1, FieldValue::Bytes(b)) => user.id = string_field(b),
|
||||
(2, FieldValue::Bytes(b)) => user.long_name = string_field(b),
|
||||
(3, FieldValue::Bytes(b)) => user.short_name = string_field(b),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Some(user)
|
||||
}
|
||||
|
||||
struct ParsedPacket {
|
||||
from: Option<u32>,
|
||||
portnum: u32,
|
||||
payload: Vec<u8>,
|
||||
}
|
||||
|
||||
fn parse_mesh_packet(data: &[u8]) -> Option<ParsedPacket> {
|
||||
let mut idx = 0;
|
||||
let mut from = None;
|
||||
let mut decoded = None;
|
||||
while idx < data.len() {
|
||||
let (field, value, next) = next_field(data, idx)?;
|
||||
idx = next;
|
||||
match (field, value) {
|
||||
(1, FieldValue::Fixed32(v)) => from = Some(v),
|
||||
(4, FieldValue::Bytes(b)) => decoded = Some(b),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let decoded = decoded?;
|
||||
let mut didx = 0;
|
||||
let mut portnum = 0;
|
||||
let mut payload = Vec::new();
|
||||
while didx < decoded.len() {
|
||||
let (field, value, next) = next_field(decoded, didx)?;
|
||||
didx = next;
|
||||
match (field, value) {
|
||||
(1, FieldValue::Varint(v)) => portnum = v as u32,
|
||||
(2, FieldValue::Bytes(b)) => payload = b.to_vec(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Some(ParsedPacket {
|
||||
from,
|
||||
portnum,
|
||||
payload,
|
||||
})
|
||||
}
|
||||
|
||||
enum FieldValue<'a> {
|
||||
Varint(u64),
|
||||
Fixed32(u32),
|
||||
Bytes(&'a [u8]),
|
||||
}
|
||||
|
||||
fn next_field(buf: &[u8], idx: usize) -> Option<(u64, FieldValue<'_>, usize)> {
|
||||
let (key, n) = read_varint(&buf[idx..])?;
|
||||
let field = key >> 3;
|
||||
let mut pos = idx + n;
|
||||
match key & 0x07 {
|
||||
0 => {
|
||||
let (v, n) = read_varint(&buf[pos..])?;
|
||||
pos += n;
|
||||
Some((field, FieldValue::Varint(v), pos))
|
||||
}
|
||||
2 => {
|
||||
let (len, n) = read_varint(&buf[pos..])?;
|
||||
pos += n;
|
||||
let end = pos.checked_add(len as usize)?;
|
||||
if end > buf.len() {
|
||||
return None;
|
||||
}
|
||||
Some((field, FieldValue::Bytes(&buf[pos..end]), end))
|
||||
}
|
||||
5 => {
|
||||
let end = pos.checked_add(4)?;
|
||||
if end > buf.len() {
|
||||
None
|
||||
} else {
|
||||
let value =
|
||||
u32::from_le_bytes([buf[pos], buf[pos + 1], buf[pos + 2], buf[pos + 3]]);
|
||||
Some((field, FieldValue::Fixed32(value), end))
|
||||
}
|
||||
}
|
||||
1 => {
|
||||
let end = pos.checked_add(8)?;
|
||||
if end > buf.len() {
|
||||
None
|
||||
} else {
|
||||
Some((field, FieldValue::Bytes(&buf[pos..end]), end))
|
||||
}
|
||||
}
|
||||
wire => {
|
||||
warn!(wire, "Unsupported Meshtastic protobuf wire type");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_varint(buf: &[u8]) -> Option<(u64, usize)> {
|
||||
let mut out = 0u64;
|
||||
for (i, b) in buf.iter().copied().enumerate().take(10) {
|
||||
out |= ((b & 0x7f) as u64) << (7 * i);
|
||||
if b & 0x80 == 0 {
|
||||
return Some((out, i + 1));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn encode_varint_field(field: u64, value: u64) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
encode_varint_field_into(field, value, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn encode_varint_field_into(field: u64, value: u64, out: &mut Vec<u8>) {
|
||||
write_varint((field << 3) | 0, out);
|
||||
write_varint(value, out);
|
||||
}
|
||||
|
||||
fn encode_len_field(field: u64, bytes: &[u8], out: &mut Vec<u8>) {
|
||||
write_varint((field << 3) | 2, out);
|
||||
write_varint(bytes.len() as u64, out);
|
||||
out.extend_from_slice(bytes);
|
||||
}
|
||||
|
||||
fn encode_fixed32_field(field: u64, value: u32, out: &mut Vec<u8>) {
|
||||
write_varint((field << 3) | 5, out);
|
||||
out.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
|
||||
fn write_varint(mut value: u64, out: &mut Vec<u8>) {
|
||||
while value >= 0x80 {
|
||||
out.push((value as u8 & 0x7f) | 0x80);
|
||||
value >>= 7;
|
||||
}
|
||||
out.push(value as u8);
|
||||
}
|
||||
|
||||
fn string_field(bytes: &[u8]) -> Option<String> {
|
||||
std::str::from_utf8(bytes).ok().map(|s| s.to_string())
|
||||
}
|
||||
|
||||
fn synthetic_pubkey(node_num: u32) -> [u8; 32] {
|
||||
let mut out = [0u8; 32];
|
||||
out[..4].copy_from_slice(&node_num.to_le_bytes());
|
||||
out[4..15].copy_from_slice(b"meshtastic:");
|
||||
out
|
||||
}
|
||||
@@ -1,13 +1,15 @@
|
||||
//! Mesh networking: Meshcore LoRa radio integration for offline peer discovery
|
||||
//! Mesh networking: LoRa radio integration for offline peer discovery
|
||||
//! and encrypted messaging between Archipelago nodes.
|
||||
//!
|
||||
//! Supports Meshcore firmware on Heltec V3, T-Beam, RAK WisBlock, Station G2,
|
||||
//! and other ESP32/nRF52-based LoRa boards via USB serial (Companion USB mode).
|
||||
//! Supports Meshcore firmware via Companion USB and Meshtastic firmware via
|
||||
//! the Meshtastic serial API on Heltec, T-Beam, RAK WisBlock, Station G2,
|
||||
//! and other ESP32/nRF52-based LoRa boards.
|
||||
|
||||
pub mod alerts;
|
||||
pub mod bitcoin_relay;
|
||||
pub mod crypto;
|
||||
pub mod listener;
|
||||
pub mod meshtastic;
|
||||
pub mod message_types;
|
||||
pub mod outbox;
|
||||
pub mod protocol;
|
||||
|
||||
@@ -323,6 +323,7 @@ pub fn parse_self_info(data: &[u8]) -> Result<(u32, String)> {
|
||||
}
|
||||
|
||||
/// Parsed contact from RESP_CONTACT (0x03).
|
||||
#[derive(Clone)]
|
||||
pub struct ParsedContact {
|
||||
pub public_key_hex: String,
|
||||
pub advert_name: String,
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
use super::protocol::{self, InboundFrame};
|
||||
use super::types::DeviceInfo;
|
||||
use anyhow::{Context, Result};
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
@@ -400,12 +401,43 @@ const SERIAL_CANDIDATES: &[&str] = &[
|
||||
"/dev/ttyACM2",
|
||||
];
|
||||
|
||||
const SKIP_SERIAL_MODEL_SUBSTRINGS: &[&str] = &["Sierra_Wireless", "Z-Wave", "Zooz"];
|
||||
|
||||
fn likely_non_mesh_serial_device(path: &str) -> bool {
|
||||
let Some(name) = Path::new(path).file_name().and_then(|s| s.to_str()) else {
|
||||
return false;
|
||||
};
|
||||
let by_id = Path::new("/dev/serial/by-id");
|
||||
let Ok(entries) = std::fs::read_dir(by_id) else {
|
||||
return false;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let file_name = entry.file_name().to_string_lossy().to_string();
|
||||
if !SKIP_SERIAL_MODEL_SUBSTRINGS
|
||||
.iter()
|
||||
.any(|needle| file_name.contains(needle))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if let Ok(target) = std::fs::read_link(entry.path()) {
|
||||
if target.file_name().and_then(|s| s.to_str()) == Some(name) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Scan for serial devices that could be Meshcore radios.
|
||||
/// Returns paths to existing serial device files.
|
||||
pub async fn detect_serial_devices() -> Vec<String> {
|
||||
let mut devices = Vec::new();
|
||||
for path in SERIAL_CANDIDATES {
|
||||
if tokio::fs::metadata(path).await.is_ok() {
|
||||
if likely_non_mesh_serial_device(path) {
|
||||
debug!(path = %path, "Skipping known non-mesh serial device");
|
||||
continue;
|
||||
}
|
||||
devices.push(path.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ const RESERVED_PORTS: &[u16] = &[
|
||||
3001, 3002, // Gitea, Uptime Kuma
|
||||
8888, // SearXNG
|
||||
8096, 2342, 2283, // Jellyfin, Photoprism, Immich
|
||||
8443, 8084, // NPM
|
||||
8443, // FIPS TCP fallback
|
||||
];
|
||||
|
||||
/// Start of range for allocating web app ports when preferred is taken.
|
||||
@@ -93,8 +93,12 @@ impl PortAllocator {
|
||||
.any(|m| m.host_port == port)
|
||||
}
|
||||
|
||||
fn can_bind(port: u16) -> bool {
|
||||
std::net::TcpListener::bind(("0.0.0.0", port)).is_ok()
|
||||
}
|
||||
|
||||
fn is_available(&self, port: u16) -> bool {
|
||||
!self.is_reserved(port) && !self.is_allocated(port)
|
||||
!self.is_reserved(port) && !self.is_allocated(port) && Self::can_bind(port)
|
||||
}
|
||||
|
||||
/// Allocate a host port for an app. Uses preferred_port if available, else finds next free.
|
||||
|
||||
@@ -80,7 +80,10 @@ impl EndpointRateLimiter {
|
||||
limits.insert("backup.upload-s3".to_string(), (3, 600));
|
||||
limits.insert("backup.download-s3".to_string(), (3, 600));
|
||||
// System operations
|
||||
limits.insert("update.apply".to_string(), (2, 600));
|
||||
// Update apply is an authenticated local admin action. Keep a guard
|
||||
// against accidental button storms without locking operators out for
|
||||
// ten minutes during OTA troubleshooting.
|
||||
limits.insert("update.apply".to_string(), (10, 60));
|
||||
limits.insert("system.reboot".to_string(), (2, 300));
|
||||
limits.insert("system.shutdown".to_string(), (2, 300));
|
||||
// Password and TOTP changes
|
||||
|
||||
@@ -838,7 +838,21 @@ const CONTAINER_ABSENCE_THRESHOLD: u32 = 3;
|
||||
/// 600s. 2× that gives the spawned task ample margin before we assume it
|
||||
/// died (panic, OOM, process restart mid-stop) and fall back to the
|
||||
/// scanner's authoritative view. Applies to all transitional variants.
|
||||
const TRANSITIONAL_STUCK_TIMEOUT: Duration = Duration::from_secs(1200);
|
||||
const TRANSITIONAL_STUCK_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
|
||||
/// Multi-container installs can legitimately spend several minutes before the
|
||||
/// primary user-facing container exists. BTCPay, for example, pulls/starts
|
||||
/// Postgres and NBXplorer before `btcpay-server`; do not erase its installing
|
||||
/// card just because the primary container is absent during that setup window.
|
||||
const INSTALLING_STUCK_TIMEOUT: Duration = Duration::from_secs(20 * 60);
|
||||
|
||||
fn transitional_stuck_timeout(state: &crate::data_model::PackageState) -> Duration {
|
||||
if *state == crate::data_model::PackageState::Installing {
|
||||
INSTALLING_STUCK_TIMEOUT
|
||||
} else {
|
||||
TRANSITIONAL_STUCK_TIMEOUT
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if `state` is one of the transitional variants that a
|
||||
/// `spawn_transitional`-style background task owns. While such a state is
|
||||
@@ -961,13 +975,14 @@ async fn scan_and_update_packages(
|
||||
let overwrite = match existing {
|
||||
Some(existing_entry) if is_transitional(&existing_entry.state) => {
|
||||
let entered = *transitional_since.entry(id.clone()).or_insert(now);
|
||||
let stuck = now.duration_since(entered) > TRANSITIONAL_STUCK_TIMEOUT;
|
||||
let timeout = transitional_stuck_timeout(&existing_entry.state);
|
||||
let stuck = now.duration_since(entered) > timeout;
|
||||
if stuck {
|
||||
warn!(
|
||||
"Container {} stuck in {:?} for >{}s; overriding with scan state {:?}",
|
||||
id,
|
||||
existing_entry.state,
|
||||
TRANSITIONAL_STUCK_TIMEOUT.as_secs(),
|
||||
timeout.as_secs(),
|
||||
pkg.state
|
||||
);
|
||||
transitional_since.remove(id);
|
||||
@@ -1015,12 +1030,13 @@ async fn scan_and_update_packages(
|
||||
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 {
|
||||
let timeout = transitional_stuck_timeout(&entry.state);
|
||||
if now.duration_since(entered) > timeout {
|
||||
warn!(
|
||||
"Container {} stuck in {:?} and absent for >{}s; removing stale transitional state",
|
||||
id,
|
||||
entry.state,
|
||||
TRANSITIONAL_STUCK_TIMEOUT.as_secs()
|
||||
timeout.as_secs()
|
||||
);
|
||||
merged.remove(&id);
|
||||
transitional_since.remove(&id);
|
||||
@@ -1029,6 +1045,16 @@ async fn scan_and_update_packages(
|
||||
absence_tracker.remove(&id);
|
||||
continue;
|
||||
}
|
||||
// Quadlet-generated units run containers with `--rm`, so a
|
||||
// clean user stop removes the Podman record. Keep the package
|
||||
// visible as Stopped while the user-stopped marker exists so
|
||||
// package.start can recreate it via systemd/Quadlet.
|
||||
if entry.state == crate::data_model::PackageState::Stopped
|
||||
&& user_stopped.contains(&id)
|
||||
{
|
||||
absence_tracker.remove(&id);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let count = absence_tracker.entry(id.clone()).or_insert(0);
|
||||
*count += 1;
|
||||
@@ -1237,4 +1263,13 @@ mod merge_tests {
|
||||
assert!(!is_transitional(&s), "{:?} should NOT be transitional", s);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installing_uses_longer_stale_timeout_than_other_transitions() {
|
||||
assert!(transitional_stuck_timeout(&PackageState::Installing) > TRANSITIONAL_STUCK_TIMEOUT);
|
||||
assert_eq!(
|
||||
transitional_stuck_timeout(&PackageState::Stopping),
|
||||
TRANSITIONAL_STUCK_TIMEOUT
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -713,11 +713,14 @@ pub async fn dismiss_update(data_dir: &Path) -> Result<()> {
|
||||
/// verified over the complete file at the end of each component, so a
|
||||
/// partially-corrupt resume still fails cleanly.
|
||||
pub async fn download_update(data_dir: &Path) -> Result<DownloadProgress> {
|
||||
let state = load_state(data_dir).await?;
|
||||
let mut state = load_state(data_dir).await?;
|
||||
if state.available_update.is_none() {
|
||||
state = check_for_updates(data_dir).await?;
|
||||
}
|
||||
let manifest = state
|
||||
.available_update
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("No update available to download"))?;
|
||||
.ok_or_else(|| anyhow::anyhow!("No update is available to download"))?;
|
||||
|
||||
let staging_dir = data_dir.join("update-staging");
|
||||
fs::create_dir_all(&staging_dir)
|
||||
|
||||
@@ -46,6 +46,7 @@ pub struct ContainerStatus {
|
||||
pub enum ContainerState {
|
||||
Created,
|
||||
Running,
|
||||
Stopping,
|
||||
Stopped,
|
||||
Exited,
|
||||
Paused,
|
||||
@@ -57,6 +58,7 @@ impl From<&str> for ContainerState {
|
||||
match s.to_lowercase().as_str() {
|
||||
"created" => ContainerState::Created,
|
||||
"running" => ContainerState::Running,
|
||||
"stopping" => ContainerState::Stopping,
|
||||
"stopped" => ContainerState::Stopped,
|
||||
"exited" => ContainerState::Exited,
|
||||
"paused" => ContainerState::Paused,
|
||||
@@ -120,17 +122,18 @@ impl PodmanClient {
|
||||
"penpot" => "http://localhost:9001",
|
||||
"nextcloud" => "http://localhost:8085",
|
||||
"vaultwarden" => "http://localhost:8082",
|
||||
"gitea" => "http://localhost:3001",
|
||||
"jellyfin" => "http://localhost:8096",
|
||||
"photoprism" => "http://localhost:2342",
|
||||
"immich_server" | "immich" => "http://localhost:2283",
|
||||
"filebrowser" => "http://localhost:8083",
|
||||
"nginx-proxy-manager" => "http://localhost:81",
|
||||
"nginx-proxy-manager" => "http://localhost:8081",
|
||||
"portainer" => "http://localhost:9000",
|
||||
"uptime-kuma" => "http://localhost:3002",
|
||||
"fedimint" | "fedimintd" => "http://localhost:8175",
|
||||
"fedimint-gateway" => "http://localhost:8176",
|
||||
"nostr-rs-relay" => "http://localhost:18081",
|
||||
"indeedhub" => "http://localhost:7777",
|
||||
"indeedhub" => "http://localhost:7778",
|
||||
"dwn" => "http://localhost:3100",
|
||||
"endurain" => "http://localhost:8080",
|
||||
"electrs" | "archy-electrs-ui" => "http://localhost:50002",
|
||||
|
||||
@@ -3,8 +3,12 @@ use crate::podman_client::{ContainerState, ContainerStatus, PodmanClient};
|
||||
use anyhow::{Context, Result};
|
||||
use async_trait::async_trait;
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
use tokio::process::Command as TokioCommand;
|
||||
|
||||
const PODMAN_CLI_DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const PODMAN_CLI_BUILD_TIMEOUT: Duration = Duration::from_secs(900);
|
||||
|
||||
#[async_trait]
|
||||
pub trait ContainerRuntime: Send + Sync {
|
||||
async fn pull_image(&self, image: &str, signature: Option<&str>) -> Result<()>;
|
||||
@@ -52,13 +56,31 @@ impl PodmanRuntime {
|
||||
/// Run `podman <args>`, returning an error with captured stderr on non-zero
|
||||
/// exit. Used for operations (build, image inspect) that are awkward over the
|
||||
/// HTTP API. The daemon runs as the target user already, so no sudo hop.
|
||||
async fn podman_cli(&self, args: &[&str]) -> Result<std::process::Output> {
|
||||
async fn podman_cli_timeout(
|
||||
&self,
|
||||
args: &[&str],
|
||||
timeout: Duration,
|
||||
) -> Result<std::process::Output> {
|
||||
let mut cmd = TokioCommand::new("podman");
|
||||
cmd.args(args);
|
||||
cmd.output()
|
||||
cmd.kill_on_drop(true);
|
||||
tokio::time::timeout(timeout, cmd.output())
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"podman {} timed out after {}s",
|
||||
args.join(" "),
|
||||
timeout.as_secs()
|
||||
)
|
||||
})?
|
||||
.with_context(|| format!("failed to execute podman {}", args.join(" ")))
|
||||
}
|
||||
|
||||
/// Run `podman <args>` with a short timeout for control-plane operations.
|
||||
async fn podman_cli(&self, args: &[&str]) -> Result<std::process::Output> {
|
||||
self.podman_cli_timeout(args, PODMAN_CLI_DEFAULT_TIMEOUT)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -84,19 +106,72 @@ impl ContainerRuntime for PodmanRuntime {
|
||||
}
|
||||
|
||||
async fn start_container(&self, name: &str) -> Result<()> {
|
||||
self.client.start_container(name).await
|
||||
match self.client.start_container(name).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(api_err) => {
|
||||
let output = self.podman_cli(&["start", name]).await?;
|
||||
if output.status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
Err(api_err.context(format!("podman start fallback failed: {}", stderr.trim())))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn stop_container(&self, name: &str) -> Result<()> {
|
||||
self.client.stop_container(name).await
|
||||
match self.client.stop_container(name).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(api_err) => {
|
||||
let output = self.podman_cli(&["stop", "-t", "30", name]).await?;
|
||||
if output.status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
if is_missing_container_error(&stderr) {
|
||||
return Ok(());
|
||||
}
|
||||
Err(api_err.context(format!("podman stop fallback failed: {}", stderr.trim())))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn remove_container(&self, name: &str) -> Result<()> {
|
||||
self.client.remove_container(name).await
|
||||
match self.client.remove_container(name).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(api_err) => {
|
||||
let output = self.podman_cli(&["rm", "-f", name]).await?;
|
||||
if output.status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
if is_missing_container_error(&stderr) {
|
||||
return Ok(());
|
||||
}
|
||||
Err(api_err.context(format!("podman rm fallback failed: {}", stderr.trim())))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_container_status(&self, name: &str) -> Result<ContainerStatus> {
|
||||
self.client.get_container_status(name).await
|
||||
match self.client.get_container_status(name).await {
|
||||
Ok(status) => Ok(status),
|
||||
Err(api_err) => {
|
||||
let output = self
|
||||
.podman_cli(&["container", "inspect", "--format", "json", name])
|
||||
.await?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(api_err
|
||||
.context(format!("podman inspect fallback failed: {}", stderr.trim())));
|
||||
}
|
||||
parse_podman_inspect_json(&output.stdout, name)
|
||||
.with_context(|| format!("podman API inspect failed: {api_err}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_container_logs(&self, name: &str, lines: u32) -> Result<Vec<String>> {
|
||||
@@ -142,7 +217,9 @@ impl ContainerRuntime for PodmanRuntime {
|
||||
async fn build_image(&self, config: &BuildConfig) -> Result<()> {
|
||||
let args = build_args_for_podman(config);
|
||||
let borrowed: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
|
||||
let output = self.podman_cli(&borrowed).await?;
|
||||
let output = self
|
||||
.podman_cli_timeout(&borrowed, PODMAN_CLI_BUILD_TIMEOUT)
|
||||
.await?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
@@ -211,6 +288,103 @@ fn parse_podman_ps_json(stdout: &[u8]) -> Result<Vec<ContainerStatus>> {
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn parse_podman_inspect_json(stdout: &[u8], requested_name: &str) -> Result<ContainerStatus> {
|
||||
let text = String::from_utf8_lossy(stdout);
|
||||
let containers: Vec<serde_json::Value> = serde_json::from_str(&text)?;
|
||||
let c = containers
|
||||
.first()
|
||||
.ok_or_else(|| anyhow::anyhow!("podman inspect returned no containers"))?;
|
||||
if c.get("State").is_none() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"podman inspect returned non-container object for {requested_name}"
|
||||
));
|
||||
}
|
||||
let name = c
|
||||
.get("Name")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.trim_start_matches('/'))
|
||||
.unwrap_or(requested_name)
|
||||
.to_string();
|
||||
let state = c
|
||||
.get("State")
|
||||
.and_then(|v| v.get("Status"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown");
|
||||
Ok(ContainerStatus {
|
||||
id: c
|
||||
.get("Id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
name: name.clone(),
|
||||
state: ContainerState::from(state),
|
||||
health: c
|
||||
.get("State")
|
||||
.and_then(|v| v.get("Health"))
|
||||
.and_then(|v| v.get("Status"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string()),
|
||||
exit_code: c
|
||||
.get("State")
|
||||
.and_then(|v| v.get("ExitCode"))
|
||||
.and_then(|v| v.as_i64())
|
||||
.map(|c| c as i32),
|
||||
started_at: c
|
||||
.get("State")
|
||||
.and_then(|v| v.get("StartedAt"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string()),
|
||||
image: c
|
||||
.get("ImageName")
|
||||
.and_then(|v| v.as_str())
|
||||
.or_else(|| {
|
||||
c.get("Config")
|
||||
.and_then(|v| v.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_inspect_ports(c),
|
||||
lan_address: PodmanClient::lan_address_for(&name),
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_inspect_ports(c: &serde_json::Value) -> Vec<String> {
|
||||
let Some(bindings) = c
|
||||
.get("HostConfig")
|
||||
.and_then(|v| v.get("PortBindings"))
|
||||
.and_then(|v| v.as_object())
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let mut ports = Vec::new();
|
||||
for (container_port, host_bindings) in bindings {
|
||||
let Some(host_bindings) = host_bindings.as_array() else {
|
||||
continue;
|
||||
};
|
||||
for binding in host_bindings {
|
||||
let host_ip = binding
|
||||
.get("HostIp")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("0.0.0.0");
|
||||
let host_port = binding
|
||||
.get("HostPort")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
if !host_port.is_empty() {
|
||||
ports.push(format!("{host_ip}:{host_port}->{container_port}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
ports
|
||||
}
|
||||
|
||||
fn parse_podman_ps_ports(ports: Option<&serde_json::Value>) -> Vec<String> {
|
||||
ports
|
||||
.and_then(|v| v.as_array())
|
||||
@@ -237,6 +411,14 @@ fn parse_health_from_status(status: &str) -> Option<String> {
|
||||
(start < end).then(|| status[start + 1..end].to_string())
|
||||
}
|
||||
|
||||
fn is_missing_container_error(stderr: &str) -> bool {
|
||||
let stderr = stderr.to_ascii_lowercase();
|
||||
stderr.contains("no container with name or id")
|
||||
|| stderr.contains("no such container")
|
||||
|| stderr.contains("does not exist")
|
||||
|| stderr.contains("not found")
|
||||
}
|
||||
|
||||
/// Build the argv for `podman build` from a BuildConfig.
|
||||
///
|
||||
/// Extracted so it can be unit-tested without actually invoking podman.
|
||||
|
||||
@@ -1,17 +1,56 @@
|
||||
# Container Lifecycle Handoff
|
||||
|
||||
Last updated: 2026-05-06
|
||||
Last updated: 2026-05-11
|
||||
|
||||
## 2026-05-13 `.198` Stopping-State Repair Checkpoint
|
||||
|
||||
- User directive confirmed: testing target is `.198` until all containers work and the container layer is bulletproof/perfected.
|
||||
- `.198` service state after this pass:
|
||||
- `archipelago.service`: active.
|
||||
- `archipelago-doctor.timer`: inactive.
|
||||
- `archipelago-reconcile.timer`: inactive.
|
||||
- `/usr/local/bin/archipelago` sha256: `5d3777d928ae6ee7627e9401faf932442806020ab7ad7a439eb7384d8eb7b8e6`.
|
||||
- Live blocker found and repaired:
|
||||
- `nostr-rs-relay` was stuck in raw Podman state `Stopping (healthy)`; focused lifecycle audit failed with `bad state: nostr-rs-relay is stopping`.
|
||||
- Removed only the wedged container record with `podman rm -f nostr-rs-relay`; bind-mounted relay data under `/var/lib/archipelago/nostr-relay` was preserved.
|
||||
- Archipelago/runtime recreated the relay and it returned `Up ... (healthy)`.
|
||||
- Durable local fix added and deployed:
|
||||
- `core/archipelago/src/container/prod_orchestrator.rs` now treats `ContainerState::Stopping` as a wedged container record during reconcile and force-recreates it from the manifest instead of trying a normal start.
|
||||
- Added unit coverage intent: `reconcile_force_recreates_stopping_container`.
|
||||
- `cargo check -p archipelago --bin archipelago` passed locally.
|
||||
- `cargo build -p archipelago --bin archipelago --release` passed locally and was deployed to `.198`.
|
||||
- Rust test binary build for the targeted unit test timed out during compilation in this environment before emitting compiler errors; use `cargo check` plus live `.198` audit as the validated gate for this pass.
|
||||
- Post-deploy validation on `.198`:
|
||||
- Focused audit passed: `ARCHY_HOST=192.168.1.198 ARCHY_PASSWORD=password123 ARCHY_APPS=mempool,searxng,nginx-proxy-manager,nostr-rs-relay,grafana,btcpay-server ARCHY_STABILITY_SECONDS=5 ARCHY_TIMEOUT=180 tests/lifecycle/remote-lifecycle.sh`.
|
||||
- Broad audit passed: `ARCHY_HOST=192.168.1.198 ARCHY_PASSWORD=password123 ARCHY_STABILITY_SECONDS=5 ARCHY_TIMEOUT=240 tests/lifecycle/remote-lifecycle.sh`.
|
||||
- Raw Podman sweep found no `unhealthy`, `Stopping`, `Removing`, `Exited`, or `Created` containers after post-restart startup settled.
|
||||
- Direct HTTP probes returned healthy responses (`200` or expected `302`) for dashboard, bitcoin-ui, lnd-ui, btcpay, indeedhub, botfights, gitea, filebrowser, vaultwarden, searxng, fedimint, jellyfin, immich, homeassistant, grafana, tailscale, uptime-kuma, nextcloud, nginx-proxy-manager, and nostr-rs-relay.
|
||||
- Current `.198` broad audit state:
|
||||
- Running: `bitcoin-knots`, `lnd`, `btcpay-server`, `indeedhub`, `botfights`, `gitea`, `filebrowser`, `vaultwarden`, `searxng`, `fedimint`, `jellyfin`, `immich`, `homeassistant`, `grafana`, `tailscale`, `uptime-kuma`, `nextcloud`.
|
||||
- Absent/expected in this audit: `bitcoin-core`, `mempool`, `electrumx`, `photoprism`.
|
||||
- Important observation:
|
||||
- Immediately after backend restart, `bitcoin-knots` briefly appeared `Exited (137)` during startup/recovery, then self-recovered and was running by inspection. Final broad audit and raw sweep were clean.
|
||||
- Next recommended gate:
|
||||
- Run destructive/full lifecycle on `.198` only when ready to intentionally cycle app containers; non-destructive broad audit and raw health are green after the stopping-state fix.
|
||||
|
||||
## 2026-05-13 Resume Correction
|
||||
|
||||
- User directive: "we're testing on .198 right, until all containers are working and we achieve our goal of bulletproof containers".
|
||||
- Active target remains `.198`; do not drift back to older `.116`/`.228` release threads except for cross-node context.
|
||||
- Continue lifecycle hardening until every intended `.198` container/app is working, recoverable, and aligned with the bulletproof-container goal.
|
||||
|
||||
## Resume Prompt
|
||||
|
||||
```text
|
||||
Resume Archipelago lifecycle testing from /home/archipelago/Projects/archy. Read docs/CONTAINER_LIFECYCLE_HANDOFF.md first. Preserve data unless explicitly told otherwise. Do not revert unrelated dirty worktree changes. Keep untracked docs/CONTAINER_LIFECYCLE_HANDOFF.md and docs/CHAT_TRANSCRIPT_2026-05-02.md.
|
||||
Resume Archipelago lifecycle hardening from /home/archipelago/Projects/archy. Read docs/CONTAINER_LIFECYCLE_HANDOFF.md first. Active mission is node `192.168.1.198`, not the older `.116/.228/.67` release thread. SSH key is `/home/archipelago/.ssh/id_ed25519`; lifecycle password is `password123`. Preserve data unless explicitly told otherwise. Keep `archipelago-doctor.timer` and `archipelago-reconcile.timer` inactive during deterministic testing. Do not revert unrelated dirty worktree changes because another agent/user may be working too.
|
||||
|
||||
Current focus: multi-node non-destructive hardening across .228, .116, and .67. .228 was live-repaired and verified for dashboard, Bitcoin UI, LND UI, Immich, and authenticated Bitcoin RPC. .116 was live-repaired for stale Bitcoin Knots command drift, Grafana rootless ownership, nginx /bitcoin-status proxying, and stale LND UI companion image/unit drift; focused non-destructive lifecycle audit now passes for bitcoin-knots,lnd,btcpay-server,mempool,grafana. v1.7.54-alpha release artifacts were regenerated from current source and verified to carry runtime payload fixes. .67 remains unreachable from this workspace despite confirmed credentials archipelago/archipelago.
|
||||
Mission: make every Archipelago app/container on `.198` lifecycle-safe and power-loss/reboot resilient. Containers should not randomly go down; app state must recover through daemon restarts, reboots, stale Podman/Quadlet state, missing host listeners, stuck installs, stopped/exited state drift, and stale stack/container records. Release is blocked until strict lifecycle plus app-specific reachability/launch probes agree with raw Podman health and actual app behavior.
|
||||
|
||||
Durable fixes implemented locally: Bitcoin container entrypoint/cmd drift recreation, Grafana data_uid/rootless ownership repair, Immich Postgres 2g memory, IndeedHub boot/start Nostr provider reapply, Apps loading/launch readiness UI fixes, nginx /bitcoin-status backend proxy repair, and LND UI 18083 companion/spec drift repair.
|
||||
Latest live `.198` status from 2026-05-11: `archipelago.service` active; `archipelago-doctor.timer` inactive; `archipelago-reconcile.timer` inactive; deployed `/usr/local/bin/archipelago` sha256 `ed4df8e4c3c0a12a481ea41f8246da4b5f9e9ad931d0f3f58084b0057c330af0`. Broad audit passed with `ARCHY_HOST=192.168.1.198 ARCHY_PASSWORD=password123 ARCHY_STABILITY_SECONDS=5 ARCHY_TIMEOUT=180 tests/lifecycle/remote-lifecycle.sh`, but this is not enough for release because raw Podman still reports health/state mismatches.
|
||||
|
||||
Latest deployed backend checksum on `.116` after live deploy: `c6c7830f14dc80b0e22d803997ad3df31c9ab3d4b08829b3bddc1b03ce77bd0a`. Latest live verification: nginx `/bitcoin-status` returns JSON, LND UI `http://127.0.0.1:18083/` returns HTTP 200 from `localhost/lnd-ui:local`, runtime payload scripts and promoted `/opt/archipelago/scripts` both carry `18083`, and focused non-destructive lifecycle audit passed for `bitcoin-knots,lnd,btcpay-server,mempool,grafana`. Next action: publish/tag v1.7.54-alpha if approved, then continue to `.228` deploy or `.67` reachability.
|
||||
Current suspected release blocker: reconcile the broad-audit pass with raw Podman health. On `.198`, `mempool-api` is `Up ... (unhealthy)`, `searxng` is `Up ... (unhealthy)`, `botfights` is `Up ... (unhealthy)`, and `nostr-rs-relay` is `Stopping (unhealthy)`. RPC/package state reports the installed audit set as running, so next work is to diagnose these health/state mismatches, decide whether each is a false-negative healthcheck or real app failure, fix the manifest/runtime/reconcile behavior, then rerun focused full lifecycle and browser/direct launch probes for affected apps.
|
||||
|
||||
Known `.198` package state from latest broad audit: running `lnd`, `mempool`, `indeedhub`, `botfights`, `gitea`, `filebrowser`, `vaultwarden`, `searxng`, `fedimint`, `jellyfin`, `immich`, `homeassistant`, `tailscale`, `uptime-kuma`, `nextcloud`; absent `bitcoin-knots`, `bitcoin-core`, `btcpay-server`, `electrumx`, `grafana`, `photoprism`. Some absences are expected/blockers from earlier qualification, but `btcpay-server` and `grafana` had previously passed focused checks, so verify whether their absence is intentional before release.
|
||||
|
||||
Regenerated release artifacts:
|
||||
- `releases/v1.7.54-alpha/archipelago`: `77e3a236a6196a5ab9ec2411b150490e78ffc95ea6ab8eb34ab29b3df53cd632`
|
||||
@@ -20,6 +59,97 @@ Regenerated release artifacts:
|
||||
- Unbundled ISO: `image-recipe/results/archipelago-installer-1.7.54-alpha-unbundled-x86_64.iso`, sha256 `9828b244e6ffdd5f1b1d5184c1b22bef7474b32078b1ceb4ec3584d9bdb6775b`, size `2.3G`.
|
||||
```
|
||||
|
||||
## 2026-05-11 `.198` Active Mission Checkpoint
|
||||
|
||||
## 2026-05-11 Resume Session Update
|
||||
|
||||
- Latest user directive: "please resume our work".
|
||||
- Reconfirmed active mission is `.198` lifecycle hardening, not the older `.116/.228/.67` thread.
|
||||
- Live `.198` state at resume:
|
||||
- `archipelago.service`: active.
|
||||
- `archipelago-doctor.timer`: inactive.
|
||||
- `archipelago-reconcile.timer`: inactive.
|
||||
- `/usr/local/bin/archipelago` sha256: `494cd64f77cbecb95c08552237cb8fd3c11c2b2b76d5d39854e6cf92b5900b68`.
|
||||
- Raw Podman still showed release blockers:
|
||||
- `mempool-api`: `Up ... (unhealthy)`.
|
||||
- `nginx-proxy-manager`: `Up ... (unhealthy)`.
|
||||
- `nostr-rs-relay`: `Stopping (healthy)`.
|
||||
- `searxng` was healthy by the time of recheck and served `http://127.0.0.1:8888/` with HTTP 200.
|
||||
- Diagnosed `mempool-api` as a real app failure, not a false-negative healthcheck: logs repeatedly show `getaddrinfo ENOTFOUND electrumx`, and `.198` has no `electrumx` container present. `mempool-api` is configured with `ELECTRUM_HOST=electrumx`, so the broad audit was masking a broken stack member.
|
||||
- Found and fixed a local backend masking bug: `ProdContainerOrchestrator::health` returned `healthy` for every running container and ignored Podman's actual health status. It now returns Podman's health value for running containers, maps `Stopping` to unhealthy, and `ContainerState` now parses Podman's `stopping` state explicitly.
|
||||
- Local verification:
|
||||
- `cargo fmt` passed.
|
||||
- `cargo test -p archipelago-container parse_podman_ps_json_handles_cli_output` passed.
|
||||
- `cargo check -p archipelago --bin archipelago` passed.
|
||||
- `cargo test -p archipelago health_maps_states_to_strings` did not finish within 3 minutes during crate compilation; no compiler error was emitted before timeout.
|
||||
- Focused live audit command attempted: `ARCHY_HOST=192.168.1.198 ARCHY_PASSWORD=password123 ARCHY_APPS=mempool,searxng,nginx-proxy-manager ARCHY_STABILITY_SECONDS=5 ARCHY_TIMEOUT=60 tests/lifecycle/remote-lifecycle.sh`. It timed out because the deployed `.198` backend still has the old health behavior and Podman operations on the node are intermittently hanging.
|
||||
- Next continuation point:
|
||||
- Decide whether to deploy a freshly built backend to `.198`. Do not deploy the current dirty worktree blindly unless the existing unrelated changes are intended for this release, because the workspace contains many modified files from prior work.
|
||||
- After deploy, rerun focused audit for `mempool,searxng,nginx-proxy-manager` and verify `container-health` reports `mempool` or stack health as unhealthy while `mempool-api` cannot resolve `electrumx`.
|
||||
- Fix the mempool stack qualification: on a pruned/under-disk node, `mempool` must not install/start into a half-running state that leaves `mempool-api` unhealthy because `electrumx` is absent.
|
||||
|
||||
## 2026-05-12 Lifecycle Hardening Completion Checkpoint
|
||||
|
||||
- User directive: continue until the work is done.
|
||||
- Deployed fixed backend to `.198`; final `/usr/local/bin/archipelago` sha256: `616e50ba8a83654e4a7656f931e5c9d1340a92cfa0ba22906edc0d374560df02`.
|
||||
- `archipelago.service` active; `archipelago-doctor.timer` inactive; `archipelago-reconcile.timer` inactive.
|
||||
- Local durable fixes made:
|
||||
- `ProdContainerOrchestrator::health` now respects Podman's health status instead of mapping all running containers to healthy.
|
||||
- Podman `stopping` state is parsed explicitly and maps to unhealthy/stopping instead of unknown/running.
|
||||
- `container-health` aggregates stack health for multi-container apps, so stack apps cannot hide unhealthy members like `mempool-api`.
|
||||
- Health fallback now uses bounded exact-container Podman checks to avoid broad `podman ps` hangs poisoning unrelated app health.
|
||||
- `mempool` install now runs dependency and archival-Bitcoin checks before dispatching to the stack installer, preventing half-running mempool stacks on pruned/under-disk nodes.
|
||||
- Nginx Proxy Manager healthcheck now probes `http://localhost:81/`; `/api/` returns 502 on the deployed image while the UI is healthy.
|
||||
- Runtime start repair now covers Vaultwarden and Nextcloud missing host listeners.
|
||||
- Nextcloud runtime repair fixes bind-mounted data ownership before start/restart.
|
||||
- Stale transitional state timeout lowered from 20 minutes to 2 minutes so dead lifecycle tasks clear promptly.
|
||||
- Live `.198` repairs performed with data preserved:
|
||||
- Removed broken `mempool` stack via `package.uninstall preserve_data=true`; `mempool` is now absent and full lifecycle correctly reports archival-blocked install.
|
||||
- Recreated Nginx Proxy Manager container after stale Podman `Removing` state; data under `/var/lib/archipelago/nginx-proxy-manager` preserved.
|
||||
- Recreated Vaultwarden container after stale conmon/host-listener failure; `/var/lib/archipelago/vaultwarden` preserved.
|
||||
- Recreated Home Assistant and Nextcloud container records after stale conmon/host-listener failures; data directories preserved.
|
||||
- Repaired Nextcloud ownership (`/var/lib/archipelago/nextcloud`) so Apache/PHP can write `config.php` and `data/nextcloud.log`.
|
||||
- Verification passed:
|
||||
- `cargo fmt`.
|
||||
- `cargo check -p archipelago --bin archipelago`.
|
||||
- `cargo build -p archipelago --bin archipelago --release`.
|
||||
- `cargo test -p archipelago-container parse_podman_ps_json_handles_cli_output` passed earlier in this session.
|
||||
- `cargo test -p archipelago health_maps_states_to_strings` still fails during local test binary linking with rust-lld undefined hidden symbols; `cargo check` and release build pass.
|
||||
- Focused audit passed: `ARCHY_HOST=192.168.1.198 ARCHY_PASSWORD=password123 ARCHY_APPS=mempool,searxng,nginx-proxy-manager ARCHY_STABILITY_SECONDS=5 ARCHY_TIMEOUT=120 tests/lifecycle/remote-lifecycle.sh`.
|
||||
- Broad audit passed: `ARCHY_HOST=192.168.1.198 ARCHY_PASSWORD=password123 ARCHY_STABILITY_SECONDS=5 ARCHY_TIMEOUT=180 tests/lifecycle/remote-lifecycle.sh`.
|
||||
- Final raw Podman sweep found no `unhealthy`, `Stopping`, or `Removing` containers.
|
||||
- Final direct probes returned HTTP 200 for LND UI, IndeedHub, Botfights, Gitea, File Browser, Vaultwarden, SearXNG, Fedimint, Jellyfin, Immich, Home Assistant, Tailscale, Uptime Kuma, Nextcloud, Nginx Proxy Manager, and Nostr Relay.
|
||||
- Final broad audit state:
|
||||
- Running: `lnd`, `indeedhub`, `botfights`, `gitea`, `filebrowser`, `vaultwarden`, `searxng`, `fedimint`, `jellyfin`, `immich`, `homeassistant`, `tailscale`, `uptime-kuma`, `nextcloud`.
|
||||
- Absent/expected for this node or archival-gated: `bitcoin-knots`, `bitcoin-core`, `btcpay-server`, `mempool`, `electrumx`, `grafana`, `photoprism`.
|
||||
- Remaining release consideration: `.198` is green for the non-destructive broad audit and raw Podman health. Destructive/full lifecycle should still be run only when you are ready to intentionally cycle app containers.
|
||||
|
||||
- User corrected the active mission after disconnect: continue `.198` container lifecycle hardening, not the older `.116/.228/.67` thread.
|
||||
- Mission: build "perfect containers" that do not go down unexpectedly and recover through daemon restarts, server reboots, power loss, stale Podman/Quadlet state, missing rootless host listeners, stuck installs, stopped/exited state drift, and stale stack/container records.
|
||||
- Preserve app data unless explicitly told otherwise.
|
||||
- Keep deterministic-test timers paused: `archipelago-doctor.timer` and `archipelago-reconcile.timer` should remain inactive.
|
||||
- Latest verified `.198` service state:
|
||||
- `archipelago.service`: active.
|
||||
- `archipelago-doctor.timer`: inactive.
|
||||
- `archipelago-reconcile.timer`: inactive.
|
||||
- `/usr/local/bin/archipelago` sha256: `ed4df8e4c3c0a12a481ea41f8246da4b5f9e9ad931d0f3f58084b0057c330af0`.
|
||||
- Latest broad audit command passed:
|
||||
|
||||
```bash
|
||||
ARCHY_HOST=192.168.1.198 ARCHY_PASSWORD=password123 ARCHY_STABILITY_SECONDS=5 ARCHY_TIMEOUT=180 tests/lifecycle/remote-lifecycle.sh
|
||||
```
|
||||
|
||||
- Latest broad audit states:
|
||||
- Running: `lnd`, `mempool`, `indeedhub`, `botfights`, `gitea`, `filebrowser`, `vaultwarden`, `searxng`, `fedimint`, `jellyfin`, `immich`, `homeassistant`, `tailscale`, `uptime-kuma`, `nextcloud`.
|
||||
- Absent: `bitcoin-knots`, `bitcoin-core`, `btcpay-server`, `electrumx`, `grafana`, `photoprism`.
|
||||
- Do not treat the broad audit pass as release-ready yet. Raw Podman still showed these concerning health/state mismatches:
|
||||
- `mempool-api`: `Up ... (unhealthy)`.
|
||||
- `searxng`: `Up ... (unhealthy)`.
|
||||
- `botfights`: `Up ... (unhealthy)`.
|
||||
- `nostr-rs-relay`: `Stopping (unhealthy)`.
|
||||
- Current suspected release blocker: Archipelago package state and broad audit say apps are running, but raw Podman health/state still reports unhealthy/stopping containers. Next agent should diagnose whether each mismatch is a false-negative healthcheck, stale Podman state, or a real app failure; then fix manifest/runtime/reconcile behavior and rerun focused full lifecycle plus browser/direct launch probes for affected apps.
|
||||
- Also verify whether `btcpay-server` and `grafana` being absent is intentional, because both had previously passed focused lifecycle checks on `.198`.
|
||||
|
||||
## 2026-05-06 Resume Checkpoint
|
||||
|
||||
- Goal: make container lifecycle and health recovery durable for every install and existing Archipelago server, while preserving app data.
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# Release Notes Backlog
|
||||
|
||||
## Next Release Required Work
|
||||
|
||||
- Backfill missing or thin historical release notes before cutting the next release.
|
||||
- Audit every `CHANGELOG.md` section from `v1.7.44-alpha` through the current release.
|
||||
- Replace raw commit-hash entries with user/operator-facing bullets that explain behavior changes, operational impact, validation, and known limitations.
|
||||
- Ensure `releases/manifest.json` changelog entries come from curated `CHANGELOG.md` notes only.
|
||||
|
||||
## Release Note Policy
|
||||
|
||||
- Every release must have at least three curated bullets.
|
||||
- Raw `git log --oneline` output is not acceptable release documentation.
|
||||
- Notes should answer what changed, why it matters, what operators should expect, and any known limitations.
|
||||
- `scripts/check-release-manifest.sh` is the enforcement gate before publishing artifacts.
|
||||
@@ -648,3 +648,6 @@ Go/no-go verdict:
|
||||
|
||||
> please do not miss AIUI in the release build or remove it from the nodes whatever you do
|
||||
- Critical release constraint: AIUI must remain bundled in release artifacts and must never be removed from existing nodes during update/deploy.
|
||||
|
||||
> please check the resume files for our latest plan and resume the work.
|
||||
- Current directive: read the resume/plan files, resume the latest active work, and continue from the recorded release/ISO lane while preserving the AIUI release constraint above.
|
||||
|
||||
@@ -1169,7 +1169,7 @@
|
||||
<div class="card-header"><span class="card-name">btcpay-server</span><span class="card-badge badge-red">archy-net</span></div>
|
||||
<div class="card-desc">Self-hosted Bitcoin payment processor. Accept Bitcoin payments with invoices, checkout pages, and POS.</div>
|
||||
<div class="card-layman">Your own payment terminal for Bitcoin. Create invoices, get paid, no middleman taking a cut.</div>
|
||||
<div class="card-image">btcpayserver:1.13.7</div>
|
||||
<div class="card-image">btcpayserver:2.3.9</div>
|
||||
<div class="card-ports">Ports: <span>23000</span></div>
|
||||
<div class="card-details">
|
||||
<div class="detail-row"><span class="detail-label">Memory</span><span class="detail-value">1 GB</span></div>
|
||||
|
||||
@@ -316,9 +316,15 @@ RUN apt-get update && apt-get -y full-upgrade && apt-get install -y --no-install
|
||||
dbus \
|
||||
sudo \
|
||||
network-manager \
|
||||
wpasupplicant \
|
||||
wireless-regdb \
|
||||
iw \
|
||||
rfkill \
|
||||
polkitd \
|
||||
openssh-server \
|
||||
nginx \
|
||||
podman \
|
||||
catatonit \
|
||||
uidmap \
|
||||
slirp4netns \
|
||||
passt \
|
||||
@@ -455,8 +461,21 @@ RUN mkdir -p /etc/systemd/system/user@.service.d && \
|
||||
# Allow unprivileged ping inside rootless containers
|
||||
RUN printf 'net.ipv4.ping_group_range=0 2147483647\n' > /etc/sysctl.d/90-podman-ping.conf
|
||||
|
||||
# Archipelago's web UI manages Wi-Fi through the backend service, not a local
|
||||
# desktop seat. Allow the dedicated system user to control NetworkManager.
|
||||
RUN mkdir -p /etc/polkit-1/rules.d && \
|
||||
printf '%s\n' \
|
||||
'polkit.addRule(function(action, subject) {' \
|
||||
' if (subject.user == "archipelago" && action.id.indexOf("org.freedesktop.NetworkManager.") == 0) {' \
|
||||
' return polkit.Result.YES;' \
|
||||
' }' \
|
||||
'});' \
|
||||
> /etc/polkit-1/rules.d/49-archipelago-networkmanager.rules && \
|
||||
chmod 644 /etc/polkit-1/rules.d/49-archipelago-networkmanager.rules
|
||||
|
||||
# Enable services
|
||||
RUN systemctl enable NetworkManager || true && \
|
||||
systemctl enable polkit || systemctl enable polkit.service || true && \
|
||||
systemctl enable ssh || true && \
|
||||
systemctl enable nginx || true && \
|
||||
systemctl enable archipelago || true && \
|
||||
@@ -696,6 +715,7 @@ kmod,procps,iproute2,ca-certificates,gdisk,\
|
||||
cryptsetup,cryptsetup-initramfs,parted,dosfstools,e2fsprogs,\
|
||||
linux-image-${DEB_ARCH},grub-efi-${DEB_ARCH},grub-pc-bin,\
|
||||
ifupdown,isc-dhcp-client,\
|
||||
wpasupplicant,wireless-regdb,iw,rfkill,\
|
||||
pciutils,usbutils,less,nano \
|
||||
trixie /installer http://deb.debian.org/debian
|
||||
|
||||
@@ -1100,12 +1120,15 @@ if [ "$BACKEND_CAPTURED" = "0" ]; then
|
||||
FROM rust:1.93-trixie as builder
|
||||
WORKDIR /build
|
||||
COPY core ./core
|
||||
COPY scripts ./scripts
|
||||
COPY image-recipe/configs ./image-recipe/configs
|
||||
RUN cd core && cargo build --release --bin archipelago
|
||||
BACKENDFILE
|
||||
|
||||
if $CONTAINER_CMD build --platform $CONTAINER_PLATFORM -t archipelago-backend -f "$BACKEND_DOCKERFILE" "$SCRIPT_DIR/.." 2>&1 | tail -20; then
|
||||
BACKEND_IMAGE="localhost/archipelago-backend:iso"
|
||||
if $CONTAINER_CMD build --platform $CONTAINER_PLATFORM -t "$BACKEND_IMAGE" -f "$BACKEND_DOCKERFILE" "$SCRIPT_DIR/.."; then
|
||||
echo " Extracting backend binary..."
|
||||
BACKEND_CONTAINER=$($CONTAINER_CMD create --platform $CONTAINER_PLATFORM archipelago-backend)
|
||||
BACKEND_CONTAINER=$($CONTAINER_CMD create --platform $CONTAINER_PLATFORM "$BACKEND_IMAGE")
|
||||
$CONTAINER_CMD cp "$BACKEND_CONTAINER:/build/core/target/release/archipelago" "$ARCH_DIR/bin/" && \
|
||||
echo " ✅ Backend binary built ($(du -h "$ARCH_DIR/bin/archipelago" | cut -f1))"
|
||||
$CONTAINER_CMD rm "$BACKEND_CONTAINER"
|
||||
|
||||
@@ -166,7 +166,14 @@ chroot /mnt/archipelago apt-get install -y linux-image-amd64 grub-efi-amd64 grub
|
||||
echo "📦 Installing essential packages..."
|
||||
chroot /mnt/archipelago apt-get install -y \
|
||||
sudo \
|
||||
networkmanager \
|
||||
network-manager \
|
||||
wpasupplicant \
|
||||
wireless-regdb \
|
||||
iw \
|
||||
rfkill \
|
||||
pciutils \
|
||||
usbutils \
|
||||
polkitd \
|
||||
openssh-server \
|
||||
curl \
|
||||
wget \
|
||||
@@ -176,7 +183,7 @@ chroot /mnt/archipelago apt-get install -y \
|
||||
chrony
|
||||
|
||||
echo "📦 Installing container tools..."
|
||||
chroot /mnt/archipelago apt-get install -y podman || echo "⚠️ Podman not available in base repos, will use containers.io later"
|
||||
chroot /mnt/archipelago apt-get install -y podman catatonit || echo "⚠️ Podman/catatonit not available in base repos, will use containers.io later"
|
||||
|
||||
echo "🔧 Installing GRUB bootloader..."
|
||||
# Need to run grub-install inside chroot with proper environment
|
||||
@@ -198,9 +205,20 @@ echo "archipelago:archipelago" | chroot /mnt/archipelago chpasswd
|
||||
|
||||
echo "⚙️ Enabling services..."
|
||||
chroot /mnt/archipelago systemctl enable NetworkManager || true
|
||||
chroot /mnt/archipelago systemctl enable polkit || chroot /mnt/archipelago systemctl enable polkit.service || true
|
||||
chroot /mnt/archipelago systemctl enable ssh || chroot /mnt/archipelago systemctl enable sshd || true
|
||||
chroot /mnt/archipelago systemctl enable chrony || true
|
||||
|
||||
mkdir -p /mnt/archipelago/etc/polkit-1/rules.d
|
||||
cat > /mnt/archipelago/etc/polkit-1/rules.d/49-archipelago-networkmanager.rules <<'EOF'
|
||||
polkit.addRule(function(action, subject) {
|
||||
if (subject.user == "archipelago" && action.id.indexOf("org.freedesktop.NetworkManager.") == 0) {
|
||||
return polkit.Result.YES;
|
||||
}
|
||||
});
|
||||
EOF
|
||||
chmod 644 /mnt/archipelago/etc/polkit-1/rules.d/49-archipelago-networkmanager.rules
|
||||
|
||||
# Remove policy-rc.d so services can start on first boot
|
||||
rm -f /mnt/archipelago/usr/sbin/policy-rc.d
|
||||
|
||||
|
||||
@@ -110,7 +110,7 @@ ExecStart=/usr/bin/podman run --name lnd \\
|
||||
-v ${LND_DATA}:/data/.lnd:Z \\
|
||||
-p 9735:9735 \\
|
||||
-p 10009:10009 \\
|
||||
-p 8080:8080 \\
|
||||
-p 18080:8080 \\
|
||||
docker.io/lightninglabs/lnd:v0.18.0-beta \\
|
||||
--configfile=/data/.lnd/lnd.conf
|
||||
ExecStop=/usr/bin/podman stop lnd
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
# Stable symlink for USB serial adapters used as mesh radios.
|
||||
# Creates /dev/mesh-radio pointing to the underlying ttyUSB device.
|
||||
# Supports: CP2102 (Heltec V3), CH340 (T-Beam), FTDI (RAK WisBlock).
|
||||
# Supports MeshCore and Meshtastic radios using CP2102 (Heltec V3),
|
||||
# CH340 (T-Beam), FTDI (RAK WisBlock), and known USB CDC ACM radios.
|
||||
SUBSYSTEM=="tty", ATTRS{idVendor}=="10c4", ATTRS{idProduct}=="ea60", SYMLINK+="mesh-radio", MODE="0660", GROUP="dialout"
|
||||
SUBSYSTEM=="tty", ATTRS{idVendor}=="1a86", ATTRS{idProduct}=="7523", SYMLINK+="mesh-radio", MODE="0660", GROUP="dialout"
|
||||
SUBSYSTEM=="tty", ATTRS{idVendor}=="0403", ATTRS{idProduct}=="6001", SYMLINK+="mesh-radio", MODE="0660", GROUP="dialout"
|
||||
SUBSYSTEM=="tty", ATTRS{idVendor}=="239a", KERNEL=="ttyACM[0-9]*", SYMLINK+="mesh-radio", MODE="0660", GROUP="dialout"
|
||||
SUBSYSTEM=="tty", ATTRS{idVendor}=="2e8a", KERNEL=="ttyACM[0-9]*", SYMLINK+="mesh-radio", MODE="0660", GROUP="dialout"
|
||||
|
||||
@@ -7,10 +7,11 @@ Wants=network-online.target
|
||||
Type=notify
|
||||
User=archipelago
|
||||
Environment="ARCHIPELAGO_BIND=127.0.0.1:5678"
|
||||
Environment="ARCHIPELAGO_USE_QUADLET_BACKENDS=true"
|
||||
# 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 /run/containers /var/lib/containers && chown archipelago:archipelago /run/user/1000 && chmod 700 /run/user/1000'
|
||||
ExecStartPre=+/bin/bash -c 'mkdir -p /run/user/1000 /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
|
||||
@@ -26,13 +27,13 @@ ProtectSystem=strict
|
||||
ProtectHome=no
|
||||
# PrivateTmp disabled: rootless podman runtime lives in /tmp/podman-run-UID/
|
||||
# and must be shared between the service and SSH-created containers
|
||||
ReadWritePaths=/var/lib/archipelago /etc/containers /var/lib/containers /run/containers /run/user /tmp /home/archipelago/.local/share/containers /home/archipelago/.config/containers /etc
|
||||
ReadWritePaths=/var/lib/archipelago /etc/containers /var/lib/containers /run/user /tmp /home/archipelago/.local/share/containers /home/archipelago/.config/containers /etc
|
||||
|
||||
# Privilege restriction — NoNewPrivileges=no required for sudo archipelago-wg
|
||||
# (WireGuard peer management). Scoped via sudoers to only archipelago-wg.
|
||||
NoNewPrivileges=no
|
||||
PrivateDevices=no
|
||||
SupplementaryGroups=dialout debian-tor
|
||||
SupplementaryGroups=dialout debian-tor fips
|
||||
|
||||
# Syscall and network restrictions — safe on Debian 13 (systemd 256+)
|
||||
# which respects NoNewPrivileges=no as an explicit override for seccomp filters
|
||||
|
||||
@@ -8,7 +8,7 @@ resolver 1.1.1.1 8.8.8.8 valid=300s ipv6=off;
|
||||
resolver_timeout 5s;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen 80 default_server;
|
||||
server_name _;
|
||||
|
||||
root /opt/archipelago/web-ui;
|
||||
@@ -23,6 +23,13 @@ server {
|
||||
add_header X-DNS-Prefetch-Control "off" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://*.basemaps.cartocdn.com https://tile.openstreetmap.org; font-src 'self' data:; connect-src 'self' ws: wss: http://$host:* https:; frame-src 'self' http://$host:* https:; frame-ancestors 'self'; base-uri 'self'; form-action 'self';" always;
|
||||
|
||||
# Serve Nginx Proxy Manager HTTP-01 challenge files before the SPA fallback.
|
||||
location ^~ /.well-known/acme-challenge/ {
|
||||
default_type text/plain;
|
||||
root /var/lib/archipelago/nginx-proxy-manager/data/letsencrypt-acme-challenge;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# AIUI SPA (Chat mode iframe) — SPA fallback for client-side routing
|
||||
location /aiui/ {
|
||||
try_files $uri $uri/ /aiui/index.html;
|
||||
@@ -105,9 +112,25 @@ server {
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# Versioned Vite assets must never fall through to index.html. During OTA
|
||||
# a browser can keep an old HTML shell that references now-removed hashed
|
||||
# chunks; returning HTML for /assets/*.js triggers strict MIME failures.
|
||||
# A real 404 plus immutable/no-cache split lets the app/browser recover on
|
||||
# refresh without caching the wrong content type.
|
||||
location /assets/ {
|
||||
try_files $uri =404;
|
||||
add_header Cache-Control "public, max-age=31536000, immutable" always;
|
||||
}
|
||||
|
||||
location ~* ^/(registerSW\.js|sw\.js|workbox-[^/]+\.js)$ {
|
||||
try_files $uri =404;
|
||||
add_header Cache-Control "no-cache, must-revalidate" always;
|
||||
}
|
||||
|
||||
# Serve static files (Vue.js SPA)
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
add_header Cache-Control "no-cache, must-revalidate";
|
||||
}
|
||||
|
||||
# Peer-to-peer node messaging (receives from other nodes over Tor)
|
||||
@@ -741,7 +764,7 @@ server {
|
||||
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
|
||||
}
|
||||
location /app/nginx-proxy-manager/ {
|
||||
proxy_pass http://127.0.0.1:81/;
|
||||
proxy_pass http://127.0.0.1:8081/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
@@ -873,9 +896,26 @@ server {
|
||||
}
|
||||
}
|
||||
|
||||
# Compatibility proxy for cached PWA bundles that still launch Nginx Proxy
|
||||
# Manager on :81. Rootless Podman cannot bind host ports below 1024, so the
|
||||
# container admin UI runs on :8081 and host nginx owns the old :81 entrypoint.
|
||||
server {
|
||||
listen 81;
|
||||
server_name _;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8081/;
|
||||
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;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
|
||||
# HTTPS - required for PWA install (Add to Home Screen) from dev servers
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen 443 ssl default_server;
|
||||
server_name _;
|
||||
|
||||
ssl_certificate /etc/archipelago/ssl/archipelago.crt;
|
||||
|
||||
@@ -358,7 +358,7 @@ location /app/indeedhub/ {
|
||||
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
|
||||
}
|
||||
location /app/nginx-proxy-manager/ {
|
||||
proxy_pass http://127.0.0.1:81/;
|
||||
proxy_pass http://127.0.0.1:8081/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"version": "1.7.54-alpha",
|
||||
"version": "1.7.68-alpha",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "neode-ui",
|
||||
"version": "1.7.54-alpha",
|
||||
"version": "1.7.68-alpha",
|
||||
"dependencies": {
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@vue-leaflet/vue-leaflet": "^0.10.1",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"private": true,
|
||||
"version": "1.7.54-alpha",
|
||||
"version": "1.7.68-alpha",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "./start-dev.sh",
|
||||
|
||||
@@ -52,13 +52,13 @@
|
||||
{
|
||||
"id": "btcpay-server",
|
||||
"title": "BTCPay Server",
|
||||
"version": "1.13.7",
|
||||
"version": "2.3.9",
|
||||
"description": "Self-hosted Bitcoin payment processor.",
|
||||
"icon": "/assets/img/app-icons/btcpay-server.png",
|
||||
"author": "BTCPay Server Foundation",
|
||||
"category": "commerce",
|
||||
"tier": "core",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/btcpayserver:1.13.7",
|
||||
"dockerImage": "docker.io/btcpayserver/btcpayserver:2.3.9",
|
||||
"repoUrl": "https://github.com/btcpayserver/btcpayserver",
|
||||
"requires": [
|
||||
"bitcoin-knots"
|
||||
@@ -115,7 +115,12 @@
|
||||
"author": "BotFights",
|
||||
"category": "community",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/botfights:1.1.0",
|
||||
"repoUrl": "https://botfights.net"
|
||||
"repoUrl": "https://botfights.net",
|
||||
"containerConfig": {
|
||||
"ports": ["9100:9100"],
|
||||
"volumes": ["/var/lib/archipelago/botfights:/app/server/data"],
|
||||
"env": ["NODE_ENV=production", "PORT=9100", "FIGHT_LOOP_ENABLED=true", "ARCHY_EMBEDDED=1"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "gitea",
|
||||
@@ -126,7 +131,12 @@
|
||||
"author": "Gitea",
|
||||
"category": "development",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/gitea:1.23",
|
||||
"repoUrl": "https://gitea.com"
|
||||
"repoUrl": "https://gitea.com",
|
||||
"containerConfig": {
|
||||
"ports": ["3001:3000", "2222:22"],
|
||||
"volumes": ["/var/lib/archipelago/gitea/data:/data", "/var/lib/archipelago/gitea/config:/etc/gitea"],
|
||||
"env": ["GITEA__database__DB_TYPE=sqlite3", "GITEA__server__SSH_PORT=2222", "GITEA__server__SSH_LISTEN_PORT=22", "GITEA__server__LFS_START_SERVER=true", "GITEA__packages__ENABLED=true", "GITEA__repository__ENABLE_PUSH_CREATE_USER=true", "GITEA__repository__ENABLE_PUSH_CREATE_ORG=true", "GITEA__security__X_FRAME_OPTIONS="]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "filebrowser",
|
||||
@@ -138,7 +148,12 @@
|
||||
"category": "data",
|
||||
"tier": "core",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/filebrowser:v2.27.0",
|
||||
"repoUrl": "https://github.com/filebrowser/filebrowser"
|
||||
"repoUrl": "https://github.com/filebrowser/filebrowser",
|
||||
"containerConfig": {
|
||||
"ports": ["8083:80"],
|
||||
"volumes": ["/var/lib/archipelago/filebrowser:/srv", "/var/lib/archipelago/filebrowser-data:/data"],
|
||||
"args": ["--database=/data/database.db", "--root=/srv", "--address=0.0.0.0", "--port=80"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "vaultwarden",
|
||||
@@ -150,7 +165,11 @@
|
||||
"category": "data",
|
||||
"tier": "recommended",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/vaultwarden:1.30.0-alpine",
|
||||
"repoUrl": "https://github.com/dani-garcia/vaultwarden"
|
||||
"repoUrl": "https://github.com/dani-garcia/vaultwarden",
|
||||
"containerConfig": {
|
||||
"ports": ["8082:80"],
|
||||
"volumes": ["/var/lib/archipelago/vaultwarden:/data"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "searxng",
|
||||
@@ -162,7 +181,11 @@
|
||||
"category": "data",
|
||||
"tier": "recommended",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/searxng:latest",
|
||||
"repoUrl": "https://github.com/searxng/searxng"
|
||||
"repoUrl": "https://github.com/searxng/searxng",
|
||||
"containerConfig": {
|
||||
"ports": ["8888:8080"],
|
||||
"volumes": ["/var/lib/archipelago/searxng:/etc/searxng"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "fedimint",
|
||||
@@ -184,7 +207,11 @@
|
||||
"author": "Jellyfin",
|
||||
"category": "data",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/jellyfin:10.8.13",
|
||||
"repoUrl": "https://github.com/jellyfin/jellyfin"
|
||||
"repoUrl": "https://github.com/jellyfin/jellyfin",
|
||||
"containerConfig": {
|
||||
"ports": ["8096:8096"],
|
||||
"volumes": ["/var/lib/archipelago/jellyfin/config:/config", "/var/lib/archipelago/jellyfin/cache:/cache"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "immich",
|
||||
@@ -206,7 +233,12 @@
|
||||
"author": "Home Assistant",
|
||||
"category": "home",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/home-assistant:2024.1",
|
||||
"repoUrl": "https://github.com/home-assistant/core"
|
||||
"repoUrl": "https://github.com/home-assistant/core",
|
||||
"containerConfig": {
|
||||
"ports": ["8123:8123"],
|
||||
"volumes": ["/var/lib/archipelago/home-assistant:/config"],
|
||||
"env": ["TZ=UTC"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "grafana",
|
||||
@@ -218,7 +250,12 @@
|
||||
"category": "data",
|
||||
"tier": "recommended",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/grafana:10.2.0",
|
||||
"repoUrl": "https://github.com/grafana/grafana"
|
||||
"repoUrl": "https://github.com/grafana/grafana",
|
||||
"containerConfig": {
|
||||
"ports": ["3000:3000"],
|
||||
"volumes": ["/var/lib/archipelago/grafana:/var/lib/grafana"],
|
||||
"env": ["GF_PATHS_DATA=/var/lib/grafana", "GF_USERS_ALLOW_SIGN_UP=false"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "tailscale",
|
||||
@@ -230,7 +267,13 @@
|
||||
"category": "networking",
|
||||
"tier": "recommended",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/tailscale:stable",
|
||||
"repoUrl": "https://github.com/tailscale/tailscale"
|
||||
"repoUrl": "https://github.com/tailscale/tailscale",
|
||||
"containerConfig": {
|
||||
"ports": ["8240:8240"],
|
||||
"volumes": ["/var/lib/archipelago/tailscale:/var/lib/tailscale"],
|
||||
"env": ["TS_STATE_DIR=/var/lib/tailscale"],
|
||||
"args": ["sh", "-c", "tailscaled --tun=userspace-networking & sleep 2; tailscale web --listen 0.0.0.0:8240 & wait"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "uptime-kuma",
|
||||
@@ -242,7 +285,13 @@
|
||||
"category": "data",
|
||||
"tier": "recommended",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/uptime-kuma:1",
|
||||
"repoUrl": "https://github.com/louislam/uptime-kuma"
|
||||
"repoUrl": "https://github.com/louislam/uptime-kuma",
|
||||
"containerConfig": {
|
||||
"ports": ["3002:3001"],
|
||||
"volumes": ["/var/lib/archipelago/uptime-kuma:/app/data"],
|
||||
"env": ["TZ=UTC"],
|
||||
"args": ["--", "node", "server/server.js"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "photoprism",
|
||||
@@ -253,7 +302,12 @@
|
||||
"author": "PhotoPrism",
|
||||
"category": "data",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/photoprism:240915",
|
||||
"repoUrl": "https://github.com/photoprism/photoprism"
|
||||
"repoUrl": "https://github.com/photoprism/photoprism",
|
||||
"containerConfig": {
|
||||
"ports": ["2342:2342"],
|
||||
"volumes": ["/var/lib/archipelago/photoprism:/photoprism/storage"],
|
||||
"env": ["PHOTOPRISM_ADMIN_PASSWORD=archipelago", "PHOTOPRISM_DEFAULT_LOCALE=en"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "nextcloud",
|
||||
@@ -264,7 +318,11 @@
|
||||
"author": "Nextcloud",
|
||||
"category": "data",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/nextcloud:28",
|
||||
"repoUrl": "https://github.com/nextcloud/server"
|
||||
"repoUrl": "https://github.com/nextcloud/server",
|
||||
"containerConfig": {
|
||||
"ports": ["8085:80"],
|
||||
"volumes": ["/var/lib/archipelago/nextcloud:/var/www/html"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -161,7 +161,7 @@ function onKeyDown(e: KeyboardEvent) {
|
||||
}
|
||||
// 's' key activates screensaver when authenticated (skip if typing in input)
|
||||
if (e.key === 's' || e.key === 'S') {
|
||||
if (!isInput && appStore.isAuthenticated && !screensaverStore.isActive) {
|
||||
if (!isInput && appStore.isAuthenticated && !screensaverStore.isActive && !screensaverStore.isSuppressed) {
|
||||
e.preventDefault()
|
||||
screensaverStore.activate()
|
||||
}
|
||||
|
||||
@@ -16,6 +16,13 @@ export interface MarketplaceAppInfo {
|
||||
dockerImage: string
|
||||
/** External web URL for iframe-based web apps (no container needed) */
|
||||
webUrl?: string
|
||||
containerConfig?: {
|
||||
ports?: string[]
|
||||
volumes?: string[]
|
||||
env?: string[]
|
||||
command?: string
|
||||
args?: string[]
|
||||
}
|
||||
}
|
||||
|
||||
// Simple in-memory store for the current marketplace app
|
||||
@@ -39,6 +46,7 @@ export function useMarketplaceApp() {
|
||||
s9pkUrl: app.s9pkUrl ?? '',
|
||||
dockerImage: app.dockerImage ?? '',
|
||||
webUrl: (app as Record<string, unknown>).webUrl as string | undefined,
|
||||
containerConfig: (app as Record<string, unknown>).containerConfig as MarketplaceAppInfo['containerConfig'],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -98,7 +98,7 @@ describe('useAppLauncherStore', () => {
|
||||
expect(store.panelAppId).toBe('btcpay-server')
|
||||
})
|
||||
|
||||
it('opens Nginx Proxy Manager in new tab even when URL resolves', () => {
|
||||
it('normalizes old Nginx Proxy Manager port 81 to 8081', () => {
|
||||
const store = useAppLauncherStore()
|
||||
|
||||
store.open({ url: 'http://192.168.1.228:81', title: 'Nginx Proxy Manager' })
|
||||
@@ -106,12 +106,28 @@ describe('useAppLauncherStore', () => {
|
||||
expect(store.isOpen).toBe(false)
|
||||
expect(store.panelAppId).toBe(null)
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
'http://192.168.1.228:81',
|
||||
'http://192.168.1.228:8081',
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
)
|
||||
})
|
||||
|
||||
it('routes desktop new-tab apps into app session on mobile', () => {
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
value: 390,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
const store = useAppLauncherStore()
|
||||
|
||||
store.open({ url: 'http://192.168.1.228:8081', title: 'Nginx Proxy Manager' })
|
||||
|
||||
expect(store.isOpen).toBe(false)
|
||||
expect(store.panelAppId).toBe(null)
|
||||
expect(mockWindowOpen).not.toHaveBeenCalled()
|
||||
expect(mockPush).toHaveBeenCalledWith({ name: 'app-session', params: { appId: 'nginx-proxy-manager' } })
|
||||
})
|
||||
|
||||
it('opens Nginx Proxy Manager in new tab using title hint when URL is path-only', () => {
|
||||
const store = useAppLauncherStore()
|
||||
|
||||
@@ -125,13 +141,13 @@ describe('useAppLauncherStore', () => {
|
||||
expect(store.panelAppId).toBe(null)
|
||||
})
|
||||
|
||||
it('normalizes legacy Nginx Proxy Manager port 8181 to 81', () => {
|
||||
it('normalizes legacy Nginx Proxy Manager ports to 8081', () => {
|
||||
const store = useAppLauncherStore()
|
||||
|
||||
store.open({ url: 'http://192.168.1.228:8181', title: 'Nginx Proxy Manager' })
|
||||
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
'http://192.168.1.228:81',
|
||||
'http://192.168.1.228:8081',
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
)
|
||||
@@ -219,6 +235,35 @@ describe('useAppLauncherStore', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('opens known prepackaged websites in new tab on desktop when requested', () => {
|
||||
const store = useAppLauncherStore()
|
||||
|
||||
store.open({ url: 'https://nwnn.l484.com', title: 'Next Web News Network', openInNewTab: true })
|
||||
|
||||
expect(store.isOpen).toBe(false)
|
||||
expect(store.panelAppId).toBe(null)
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
'https://nwnn.l484.com',
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
)
|
||||
})
|
||||
|
||||
it('routes prepackaged websites into app session on mobile', () => {
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
value: 390,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
const store = useAppLauncherStore()
|
||||
|
||||
store.open({ url: 'https://present.l484.com', title: 'Arch Presentation', openInNewTab: true })
|
||||
|
||||
expect(store.isOpen).toBe(false)
|
||||
expect(mockWindowOpen).not.toHaveBeenCalled()
|
||||
expect(mockPush).toHaveBeenCalledWith({ name: 'app-session', params: { appId: 'arch-presentation' } })
|
||||
})
|
||||
|
||||
it('routes HTTPS same-host apps via session view', () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { origin: 'https://192.168.1.228', protocol: 'https:', hostname: '192.168.1.228' },
|
||||
|
||||
@@ -68,6 +68,24 @@ describe('useScreensaverStore', () => {
|
||||
expect(store.isActive).toBe(false)
|
||||
})
|
||||
|
||||
it('suppression prevents automatic and manual activation until resumed', () => {
|
||||
const store = useScreensaverStore()
|
||||
store.suppress('video')
|
||||
expect(store.isSuppressed).toBe(true)
|
||||
|
||||
store.resetInactivityTimer()
|
||||
vi.advanceTimersByTime(5 * 60 * 1000)
|
||||
expect(store.isActive).toBe(false)
|
||||
|
||||
store.activate()
|
||||
expect(store.isActive).toBe(false)
|
||||
|
||||
store.resume('video')
|
||||
expect(store.isSuppressed).toBe(false)
|
||||
vi.advanceTimersByTime(3 * 60 * 1000)
|
||||
expect(store.isActive).toBe(true)
|
||||
})
|
||||
|
||||
it('activate clears any pending timer', () => {
|
||||
const store = useScreensaverStore()
|
||||
store.deactivate()
|
||||
|
||||
@@ -31,6 +31,10 @@ function mustOpenInNewTab(url: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function isMobileViewport(): boolean {
|
||||
return typeof window !== 'undefined' && window.innerWidth < 768
|
||||
}
|
||||
|
||||
function inferAppIdFromTitle(title?: string): string | null {
|
||||
const t = (title || '').toLowerCase()
|
||||
if (!t) return null
|
||||
@@ -61,8 +65,8 @@ function normalizeLaunchUrl(urlStr: string, appIdHint?: string | null): string {
|
||||
return rebuilt('3002')
|
||||
}
|
||||
|
||||
if (sameHost && appIdHint === 'nginx-proxy-manager' && u.port === '8181') {
|
||||
return rebuilt('81')
|
||||
if (sameHost && appIdHint === 'nginx-proxy-manager' && (u.port === '81' || u.port === '8181')) {
|
||||
return rebuilt('8081')
|
||||
}
|
||||
|
||||
return rewrittenLocalhost ? u.toString() : urlStr
|
||||
@@ -74,6 +78,7 @@ function normalizeLaunchUrl(urlStr: string, appIdHint?: string | null): string {
|
||||
/** Port → app ID for resolving URLs to AppSession routes */
|
||||
const PORT_TO_APP_ID: Record<string, string> = {
|
||||
'81': 'nginx-proxy-manager',
|
||||
'8081': 'nginx-proxy-manager',
|
||||
'8181': 'nginx-proxy-manager',
|
||||
'3000': 'grafana',
|
||||
'3002': 'uptime-kuma',
|
||||
@@ -148,8 +153,7 @@ 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'
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
if (mode === 'panel' && !isMobile) {
|
||||
if (mode === 'panel' && !isMobileViewport()) {
|
||||
panelAppId.value = appId
|
||||
} else {
|
||||
panelAppId.value = null
|
||||
@@ -167,8 +171,15 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
const launchUrl = normalizeLaunchUrl(payload.url, titleHintId)
|
||||
const resolvedId = resolveAppIdFromUrl(launchUrl) || titleHintId
|
||||
|
||||
// Force selected apps to open directly in new tab
|
||||
if (resolvedId && NEW_TAB_APP_IDS.has(resolvedId)) {
|
||||
if (!isMobileViewport() && payload.openInNewTab) {
|
||||
window.open(launchUrl, '_blank', 'noopener,noreferrer')
|
||||
return
|
||||
}
|
||||
|
||||
// Force selected apps to open directly in new tab on desktop only. On
|
||||
// phones, route through the app session/webview so app icons behave like
|
||||
// native launchers and keep the user inside Archipelago.
|
||||
if (!isMobileViewport() && resolvedId && NEW_TAB_APP_IDS.has(resolvedId)) {
|
||||
window.open(launchUrl, '_blank', 'noopener,noreferrer')
|
||||
return
|
||||
}
|
||||
@@ -180,7 +191,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
}
|
||||
|
||||
// Unknown apps that block iframes — open directly in new tab
|
||||
if (payload.openInNewTab || mustOpenInNewTab(launchUrl)) {
|
||||
if (!isMobileViewport() && mustOpenInNewTab(launchUrl)) {
|
||||
window.open(launchUrl, '_blank', 'noopener,noreferrer')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { PackageState, type PackageDataEntry } from '@/types/api'
|
||||
|
||||
type LoadState = 'idle' | 'loading' | 'ready' | 'error'
|
||||
|
||||
interface SystemStatsSnapshot {
|
||||
cpuPercent: number
|
||||
memUsed: number
|
||||
memTotal: number
|
||||
memPercent: number
|
||||
diskUsed: number
|
||||
diskTotal: number
|
||||
diskPercent: number
|
||||
uptimeSecs: number
|
||||
loadAvg1: number
|
||||
loadAvg5: number
|
||||
loadAvg15: number
|
||||
bitcoinSyncPercent: number
|
||||
bitcoinBlockHeight: number
|
||||
bitcoinAvailable: boolean | null
|
||||
}
|
||||
|
||||
const emptyStats = (): SystemStatsSnapshot => ({
|
||||
cpuPercent: 0,
|
||||
memUsed: 0,
|
||||
memTotal: 0,
|
||||
memPercent: 0,
|
||||
diskUsed: 0,
|
||||
diskTotal: 0,
|
||||
diskPercent: 0,
|
||||
uptimeSecs: 0,
|
||||
loadAvg1: 0,
|
||||
loadAvg5: 0,
|
||||
loadAvg15: 0,
|
||||
bitcoinSyncPercent: 0,
|
||||
bitcoinBlockHeight: 0,
|
||||
bitcoinAvailable: null,
|
||||
})
|
||||
|
||||
export const useHomeStatusStore = defineStore('homeStatus', () => {
|
||||
const stats = reactive<SystemStatsSnapshot>(emptyStats())
|
||||
const systemLoadState = ref<LoadState>('idle')
|
||||
const bitcoinLoadState = ref<LoadState>('idle')
|
||||
const vpnLoadState = ref<LoadState>('idle')
|
||||
const fipsLoadState = ref<LoadState>('idle')
|
||||
const lastSystemRefreshAt = ref<number | null>(null)
|
||||
const lastBitcoinRefreshAt = ref<number | null>(null)
|
||||
const lastVpnRefreshAt = ref<number | null>(null)
|
||||
const lastFipsRefreshAt = ref<number | null>(null)
|
||||
|
||||
const vpnStatus = ref<{
|
||||
connected: boolean | null
|
||||
provider: string
|
||||
}>({ connected: null, provider: '' })
|
||||
|
||||
const fipsStatus = ref<{
|
||||
installed: boolean
|
||||
service_active: boolean
|
||||
key_present: boolean
|
||||
anchor_connected?: boolean
|
||||
authenticated_peer_count?: number
|
||||
} | null>(null)
|
||||
|
||||
const systemStatsLoaded = computed(() => systemLoadState.value === 'ready')
|
||||
const bitcoinKnown = computed(() => stats.bitcoinAvailable !== null)
|
||||
const vpnKnown = computed(() => vpnStatus.value.connected !== null)
|
||||
|
||||
async function refreshSystemStats() {
|
||||
systemLoadState.value = systemLoadState.value === 'ready' ? 'ready' : 'loading'
|
||||
try {
|
||||
const res = await rpcClient.call<{
|
||||
cpu_usage_percent: number
|
||||
mem_used_bytes: number
|
||||
mem_total_bytes: number
|
||||
disk_used_bytes: number
|
||||
disk_total_bytes: number
|
||||
uptime_secs: number
|
||||
load_avg_1?: number
|
||||
load_avg_5?: number
|
||||
load_avg_15?: number
|
||||
}>({ method: 'system.stats' })
|
||||
stats.cpuPercent = res.cpu_usage_percent
|
||||
stats.memUsed = res.mem_used_bytes
|
||||
stats.memTotal = res.mem_total_bytes
|
||||
stats.memPercent = res.mem_total_bytes > 0 ? (res.mem_used_bytes / res.mem_total_bytes) * 100 : 0
|
||||
stats.diskUsed = res.disk_used_bytes
|
||||
stats.diskTotal = res.disk_total_bytes
|
||||
stats.diskPercent = res.disk_total_bytes > 0 ? (res.disk_used_bytes / res.disk_total_bytes) * 100 : 0
|
||||
stats.uptimeSecs = res.uptime_secs
|
||||
stats.loadAvg1 = res.load_avg_1 ?? 0
|
||||
stats.loadAvg5 = res.load_avg_5 ?? 0
|
||||
stats.loadAvg15 = res.load_avg_15 ?? 0
|
||||
systemLoadState.value = 'ready'
|
||||
lastSystemRefreshAt.value = Date.now()
|
||||
} catch {
|
||||
systemLoadState.value = stats.uptimeSecs > 0 ? 'ready' : 'error'
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshBitcoin(packages: Record<string, PackageDataEntry>) {
|
||||
bitcoinLoadState.value = bitcoinLoadState.value === 'ready' ? 'ready' : 'loading'
|
||||
try {
|
||||
const btc = await rpcClient.call<{ block_height: number; sync_progress: number }>({
|
||||
method: 'bitcoin.getinfo',
|
||||
timeout: 5000,
|
||||
})
|
||||
stats.bitcoinSyncPercent = (btc.sync_progress ?? 0) * 100
|
||||
stats.bitcoinBlockHeight = btc.block_height ?? 0
|
||||
stats.bitcoinAvailable = true
|
||||
bitcoinLoadState.value = 'ready'
|
||||
lastBitcoinRefreshAt.value = Date.now()
|
||||
} catch {
|
||||
const btcPkg = packages['bitcoin-knots'] || packages['bitcoin-core'] || packages.bitcoin
|
||||
if (btcPkg?.state === PackageState.Running) {
|
||||
stats.bitcoinAvailable = true
|
||||
bitcoinLoadState.value = 'ready'
|
||||
lastBitcoinRefreshAt.value = Date.now()
|
||||
return
|
||||
}
|
||||
|
||||
if (btcPkg && (btcPkg.state === PackageState.Stopped || btcPkg.state === PackageState.Exited)) {
|
||||
stats.bitcoinAvailable = false
|
||||
bitcoinLoadState.value = 'ready'
|
||||
lastBitcoinRefreshAt.value = Date.now()
|
||||
return
|
||||
}
|
||||
|
||||
// No authoritative package data yet. Keep the previous known value
|
||||
// rather than flashing "Not running" during route changes/scans.
|
||||
bitcoinLoadState.value = stats.bitcoinAvailable === null ? 'error' : 'ready'
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshVpn(packages: Record<string, PackageDataEntry>) {
|
||||
vpnLoadState.value = vpnLoadState.value === 'ready' ? 'ready' : 'loading'
|
||||
try {
|
||||
const status = await rpcClient.vpnStatus()
|
||||
vpnStatus.value = {
|
||||
connected: status.connected,
|
||||
provider: status.provider ?? status.configured_provider ?? '',
|
||||
}
|
||||
vpnLoadState.value = 'ready'
|
||||
lastVpnRefreshAt.value = Date.now()
|
||||
} catch {
|
||||
const tailscale = packages.tailscale
|
||||
if (tailscale?.state === PackageState.Running) {
|
||||
vpnStatus.value = { connected: true, provider: 'tailscale' }
|
||||
vpnLoadState.value = 'ready'
|
||||
lastVpnRefreshAt.value = Date.now()
|
||||
return
|
||||
}
|
||||
vpnLoadState.value = vpnStatus.value.connected === null ? 'error' : 'ready'
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshFips() {
|
||||
fipsLoadState.value = fipsLoadState.value === 'ready' ? 'ready' : 'loading'
|
||||
try {
|
||||
const status = await rpcClient.call<{
|
||||
installed: boolean
|
||||
service_active: boolean
|
||||
key_present: boolean
|
||||
anchor_connected?: boolean
|
||||
authenticated_peer_count?: number
|
||||
}>({ method: 'fips.status' })
|
||||
fipsStatus.value = status
|
||||
fipsLoadState.value = 'ready'
|
||||
lastFipsRefreshAt.value = Date.now()
|
||||
} catch {
|
||||
fipsLoadState.value = fipsStatus.value ? 'ready' : 'error'
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh(packages: Record<string, PackageDataEntry>) {
|
||||
await Promise.all([
|
||||
refreshSystemStats(),
|
||||
refreshBitcoin(packages),
|
||||
refreshVpn(packages),
|
||||
refreshFips(),
|
||||
])
|
||||
}
|
||||
|
||||
return {
|
||||
stats,
|
||||
systemLoadState,
|
||||
bitcoinLoadState,
|
||||
vpnLoadState,
|
||||
fipsLoadState,
|
||||
systemStatsLoaded,
|
||||
bitcoinKnown,
|
||||
vpnKnown,
|
||||
vpnStatus,
|
||||
fipsStatus,
|
||||
lastSystemRefreshAt,
|
||||
lastBitcoinRefreshAt,
|
||||
lastVpnRefreshAt,
|
||||
lastFipsRefreshAt,
|
||||
refresh,
|
||||
refreshSystemStats,
|
||||
refreshBitcoin,
|
||||
refreshVpn,
|
||||
refreshFips,
|
||||
}
|
||||
})
|
||||
@@ -6,12 +6,15 @@ const INACTIVITY_MS = 3 * 60 * 1000 // 3 minutes
|
||||
export const useScreensaverStore = defineStore('screensaver', () => {
|
||||
const isActive = ref(false)
|
||||
const activationCount = ref(0)
|
||||
const suppressionReasons = ref<Set<string>>(new Set())
|
||||
let inactivityTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
/** True when the current activation is the ASCII variant (every 3rd time) */
|
||||
const isAsciiMode = computed(() => activationCount.value > 0 && activationCount.value % 3 === 0)
|
||||
const isSuppressed = computed(() => suppressionReasons.value.size > 0)
|
||||
|
||||
function activate() {
|
||||
if (isSuppressed.value) return
|
||||
activationCount.value++
|
||||
isActive.value = true
|
||||
clearInactivityTimer()
|
||||
@@ -24,8 +27,10 @@ export const useScreensaverStore = defineStore('screensaver', () => {
|
||||
|
||||
function resetInactivityTimer() {
|
||||
clearInactivityTimer()
|
||||
if (isSuppressed.value) return
|
||||
inactivityTimer = setTimeout(() => {
|
||||
inactivityTimer = null
|
||||
if (isSuppressed.value) return
|
||||
isActive.value = true
|
||||
}, INACTIVITY_MS)
|
||||
}
|
||||
@@ -37,13 +42,30 @@ export const useScreensaverStore = defineStore('screensaver', () => {
|
||||
}
|
||||
}
|
||||
|
||||
function suppress(reason: string) {
|
||||
suppressionReasons.value = new Set(suppressionReasons.value).add(reason)
|
||||
clearInactivityTimer()
|
||||
isActive.value = false
|
||||
}
|
||||
|
||||
function resume(reason: string) {
|
||||
if (!suppressionReasons.value.has(reason)) return
|
||||
const next = new Set(suppressionReasons.value)
|
||||
next.delete(reason)
|
||||
suppressionReasons.value = next
|
||||
if (next.size === 0) resetInactivityTimer()
|
||||
}
|
||||
|
||||
return {
|
||||
isActive,
|
||||
isAsciiMode,
|
||||
isSuppressed,
|
||||
activationCount,
|
||||
activate,
|
||||
deactivate,
|
||||
resetInactivityTimer,
|
||||
clearInactivityTimer,
|
||||
suppress,
|
||||
resume,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -185,6 +185,20 @@ input[type="radio"]:active + * {
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
/* The Apps grid has many backdrop-filter cards inside an animated dashboard
|
||||
viewport. Chromium/Brave can corrupt those layers into black rectangles,
|
||||
so keep the translucency but avoid per-card backdrop compositor layers. */
|
||||
.apps-view .glass-card,
|
||||
.apps-view .glass,
|
||||
.apps-view .mode-switcher,
|
||||
.apps-view .glass-button,
|
||||
.apps-view input {
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
transform: none;
|
||||
isolation: auto;
|
||||
}
|
||||
|
||||
/* Mode switcher - sidebar toggle */
|
||||
.mode-switcher {
|
||||
display: inline-flex;
|
||||
|
||||
@@ -140,6 +140,8 @@ import { rpcClient } from '@/api/rpc-client'
|
||||
import AppHeroSection from './appDetails/AppHeroSection.vue'
|
||||
import AppContentSection from './appDetails/AppContentSection.vue'
|
||||
import AppSidebar from './appDetails/AppSidebar.vue'
|
||||
import { resolveAppUrl } from './appSession/appSessionConfig'
|
||||
import { isWebsitePackage, resolveRuntimeLaunchUrl } from './apps/appsConfig'
|
||||
import {
|
||||
WEB_ONLY_APP_URLS,
|
||||
PACKAGE_ALIASES,
|
||||
@@ -266,7 +268,9 @@ const backButtonText = computed(() => {
|
||||
const canLaunch = computed(() => {
|
||||
if (!pkg.value) return false
|
||||
if (isWebOnly.value) return true
|
||||
const hasUI = !!(pkg.value.manifest.interfaces?.main?.ui || pkg.value.installed?.['interface-addresses']?.main)
|
||||
const hasRuntimeAddress = !!pkg.value.installed?.['interface-addresses']?.main?.['lan-address']
|
||||
const hasKnownLaunchUrl = typeof window !== 'undefined' && !!resolveAppUrl(pkg.value.manifest.id)
|
||||
const hasUI = !!(pkg.value.manifest.interfaces?.main?.ui || hasRuntimeAddress || hasKnownLaunchUrl)
|
||||
return hasUI && pkg.value.state === 'running' && pkg.value.health !== 'starting' && pkg.value.health !== 'unhealthy'
|
||||
})
|
||||
|
||||
@@ -284,10 +288,25 @@ function goBack() {
|
||||
function launchApp() {
|
||||
if (!pkg.value) return
|
||||
const id = appId.value
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
|
||||
const webOnlyUrl = WEB_ONLY_APP_URLS[id]
|
||||
if (webOnlyUrl) {
|
||||
useAppLauncherStore().open({ url: webOnlyUrl, title: pkg.value.manifest.title })
|
||||
useAppLauncherStore().open({ url: webOnlyUrl, title: pkg.value.manifest.title, openInNewTab: !isMobile })
|
||||
return
|
||||
}
|
||||
|
||||
if (isWebsitePackage(id, pkg.value)) {
|
||||
const url = resolveRuntimeLaunchUrl(pkg.value)
|
||||
if (url) {
|
||||
useAppLauncherStore().open({ url, title: pkg.value.manifest.title, openInNewTab: !isMobile })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const runtimeUrl = resolveRuntimeLaunchUrl(pkg.value)
|
||||
if (runtimeUrl) {
|
||||
useAppLauncherStore().open({ url: runtimeUrl, title: pkg.value.manifest.title })
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -91,6 +91,7 @@
|
||||
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import { useScreensaverStore } from '@/stores/screensaver'
|
||||
import NostrIdentityPicker from '@/components/NostrIdentityPicker.vue'
|
||||
import AppSessionHeader from './appSession/AppSessionHeader.vue'
|
||||
import AppSessionFrame from './appSession/AppSessionFrame.vue'
|
||||
@@ -115,6 +116,7 @@ const isInlinePanel = computed(() => !!props.appIdProp)
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const screensaverStore = useScreensaverStore()
|
||||
|
||||
const sessionRef = ref<HTMLElement | null>(null)
|
||||
const frameRef = ref<InstanceType<typeof AppSessionFrame> | null>(null)
|
||||
@@ -144,12 +146,33 @@ const appId = computed(() => {
|
||||
|
||||
const appTitle = computed(() => resolveAppTitle(appId.value))
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
const mustOpenNewTab = computed(() => NEW_TAB_APPS.has(appId.value))
|
||||
const mustOpenNewTab = computed(() => !isMobile && NEW_TAB_APPS.has(appId.value))
|
||||
const screensaverReason = computed(() => `app-session:${appId.value}`)
|
||||
const screensaverSuppressedApps = new Set([
|
||||
'indeedhub',
|
||||
'jellyfin',
|
||||
'immich',
|
||||
'photoprism',
|
||||
'filebrowser',
|
||||
])
|
||||
|
||||
const appUrl = computed(() => {
|
||||
return resolveAppUrl(appId.value, route.query.path as string | undefined)
|
||||
})
|
||||
|
||||
function closeRouteSession() {
|
||||
const fallback = route.query.returnTo
|
||||
const fallbackPath = typeof fallback === 'string' && fallback.startsWith('/dashboard')
|
||||
? fallback
|
||||
: '/dashboard/apps'
|
||||
const previous = router.options.history.state.back
|
||||
if (typeof previous === 'string' && previous.startsWith('/dashboard') && router.resolve(previous).name !== 'app-session') {
|
||||
router.back()
|
||||
return
|
||||
}
|
||||
router.replace(fallbackPath).catch(() => {})
|
||||
}
|
||||
|
||||
// --- Identity & Nostr bridge ---
|
||||
|
||||
const iframeRef = computed(() => frameRef.value?.iframeRef ?? null)
|
||||
@@ -285,7 +308,7 @@ function handleBackdropClick() {
|
||||
function closeSession() {
|
||||
if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
|
||||
if (isInlinePanel.value) emit('close')
|
||||
else router.back()
|
||||
else closeRouteSession()
|
||||
}
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
@@ -306,6 +329,8 @@ function onFullscreenChange() {
|
||||
function onMessage(e: MessageEvent) {
|
||||
if (e.data?.type === 'nostr-request') nostrBridge.handleNostrRequest(e)
|
||||
if (e.data?.type === 'archipelago:identity:request') identity.handleIdentityRequest()
|
||||
if (e.data?.type === 'archipelago:media:playing') screensaverStore.suppress(screensaverReason.value)
|
||||
if (e.data?.type === 'archipelago:media:idle') screensaverStore.resume(screensaverReason.value)
|
||||
}
|
||||
|
||||
// Enter fullscreen on mount if mode is fullscreen
|
||||
@@ -320,7 +345,7 @@ onMounted(() => {
|
||||
if (mustOpenNewTab.value && appUrl.value) {
|
||||
window.open(appUrl.value, '_blank', 'noopener,noreferrer')
|
||||
if (isInlinePanel.value) emit('close')
|
||||
else router.back()
|
||||
else closeRouteSession()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -338,6 +363,9 @@ onMounted(() => {
|
||||
sessionRef.value?.requestFullscreen().catch(() => {})
|
||||
})
|
||||
}
|
||||
if (screensaverSuppressedApps.has(appId.value)) {
|
||||
screensaverStore.suppress(screensaverReason.value)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
@@ -347,6 +375,7 @@ onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', onKeyDown, true)
|
||||
window.removeEventListener('message', onMessage)
|
||||
document.removeEventListener('fullscreenchange', onFullscreenChange)
|
||||
screensaverStore.resume(screensaverReason.value)
|
||||
if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
|
||||
})
|
||||
</script>
|
||||
@@ -514,6 +543,8 @@ onBeforeUnmount(() => {
|
||||
.app-session-fullscreen {
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
padding-top: var(--safe-area-top, env(safe-area-inset-top, 0px));
|
||||
background: black;
|
||||
}
|
||||
.app-session-panel.glass-card {
|
||||
border: none !important;
|
||||
@@ -527,8 +558,8 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
.app-session-frame-safe {
|
||||
flex: none !important;
|
||||
height: calc(100vh - var(--app-session-mobile-bar-height, 84px));
|
||||
height: calc(100dvh - var(--app-session-mobile-bar-height, 84px));
|
||||
height: calc(100vh - var(--app-session-mobile-bar-height, 84px) - var(--safe-area-top, env(safe-area-inset-top, 0px)));
|
||||
height: calc(100dvh - var(--app-session-mobile-bar-height, 84px) - var(--safe-area-top, env(safe-area-inset-top, 0px)));
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
+272
-16
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="pb-6">
|
||||
<div class="apps-view pb-6">
|
||||
<!-- Nav header -- tabs + categories + search -->
|
||||
<div class="mb-4">
|
||||
<!-- Desktop: page tabs + category tabs + search -->
|
||||
@@ -7,7 +7,7 @@
|
||||
<div class="mode-switcher flex-shrink-0">
|
||||
<button class="mode-switcher-btn" :class="{ 'mode-switcher-btn-active': activeTab === 'apps' }" @click="activeTab = 'apps'; router.replace({ query: {} })">My Apps</button>
|
||||
<RouterLink to="/dashboard/discover" class="mode-switcher-btn">App Store</RouterLink>
|
||||
<button class="mode-switcher-btn" :class="{ 'mode-switcher-btn-active': activeTab === 'services' }" @click="activeTab = 'services'; router.replace({ query: { tab: 'services' } })">Services</button>
|
||||
<button class="mode-switcher-btn" :class="{ 'mode-switcher-btn-active': activeTab === 'websites' }" @click="activeTab = 'websites'; router.replace({ query: { tab: 'websites' } })">Websites</button>
|
||||
</div>
|
||||
<div v-if="activeTab === 'apps' && categoriesWithApps.length > 1" class="mode-switcher flex-shrink-0">
|
||||
<button
|
||||
@@ -18,24 +18,48 @@
|
||||
:class="{ 'mode-switcher-btn-active': selectedCategory === category.id }"
|
||||
>{{ category.name }}</button>
|
||||
</div>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="t('apps.searchPlaceholder')"
|
||||
:aria-label="t('apps.searchLabel')"
|
||||
class="flex-1 px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
|
||||
/>
|
||||
<div class="flex-1 flex items-center gap-2">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="t('apps.searchPlaceholder')"
|
||||
:aria-label="t('apps.searchLabel')"
|
||||
class="min-w-0 flex-1 px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="sideload-icon-btn"
|
||||
aria-label="Sideload app"
|
||||
title="Sideload app"
|
||||
@click="showSideload = true"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 16V4m0 0l-4 4m4-4l4 4M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile: search only (tabs handled by Dashboard.vue header) -->
|
||||
<div class="md:hidden">
|
||||
<!-- Mobile: search + sideload button (tabs handled by Dashboard.vue header) -->
|
||||
<div class="md:hidden flex items-center gap-2">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="t('apps.searchPlaceholder')"
|
||||
:aria-label="t('apps.searchLabel')"
|
||||
class="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
|
||||
class="min-w-0 flex-1 px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="sideload-icon-btn sideload-icon-btn-mobile"
|
||||
aria-label="Sideload app"
|
||||
title="Sideload app"
|
||||
@click="showSideload = true"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 16V4m0 0l-4 4m4-4l4 4M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -125,6 +149,63 @@
|
||||
@confirm="onConfirmUninstall"
|
||||
/>
|
||||
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="showSideload"
|
||||
class="fixed inset-0 z-[2600] flex items-end justify-center bg-black/60 backdrop-blur-md p-0 md:items-center md:p-6"
|
||||
@click.self="closeSideload"
|
||||
>
|
||||
<form class="sideload-modal" @submit.prevent="submitSideload">
|
||||
<div class="flex items-start justify-between gap-4 mb-5">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-white">Sideload app</h2>
|
||||
<p class="text-sm text-white/55 mt-1">Install a trusted Docker image with a simple web UI.</p>
|
||||
</div>
|
||||
<button type="button" class="sideload-close-btn" aria-label="Close" @click="closeSideload">×</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<label class="block">
|
||||
<span class="sideload-label">App ID</span>
|
||||
<input v-model.trim="sideloadForm.id" class="sideload-input" placeholder="excalidraw" pattern="[a-z0-9][a-z0-9-]{0,63}" required />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="sideload-label">Docker image</span>
|
||||
<input v-model.trim="sideloadForm.image" class="sideload-input" placeholder="docker.io/excalidraw/excalidraw:latest" required />
|
||||
</label>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<label class="block">
|
||||
<span class="sideload-label">Title</span>
|
||||
<input v-model.trim="sideloadForm.title" class="sideload-input" placeholder="Excalidraw" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="sideload-label">Port mapping</span>
|
||||
<input v-model.trim="sideloadForm.port" class="sideload-input" placeholder="3009:80" />
|
||||
</label>
|
||||
</div>
|
||||
<label class="block">
|
||||
<span class="sideload-label">Description</span>
|
||||
<input v-model.trim="sideloadForm.description" class="sideload-input" placeholder="Collaborative whiteboard" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="sideloadError" class="alert-error mt-4 text-sm">{{ sideloadError }}</div>
|
||||
|
||||
<div class="mt-5 rounded-xl border border-white/10 bg-white/[0.04] p-4 text-sm text-white/65">
|
||||
<p class="font-medium text-white/80 mb-2">Easy sources</p>
|
||||
<p>Use images from Docker Hub, GHCR, git.tx1138.com, the VPS2 Gitea registry, or localhost. Good first candidates: Excalidraw, Stirling PDF, FreshRSS, Wallabag, HedgeDoc, CyberChef, Mealie, or PairDrop.</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 flex gap-3">
|
||||
<button type="button" class="flex-1 glass-button px-4 py-3 rounded-lg" @click="closeSideload">Cancel</button>
|
||||
<button type="submit" class="flex-1 glass-button px-4 py-3 rounded-lg font-semibold" :disabled="sideloading">
|
||||
{{ sideloading ? 'Installing...' : 'Install' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Action error toast -->
|
||||
<Transition name="fade">
|
||||
<div v-if="actions.actionError.value" class="fixed bottom-20 left-1/2 -translate-x-1/2 z-50 max-w-md w-full px-4" role="alert" aria-live="assertive">
|
||||
@@ -149,14 +230,15 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useServerStore } from '@/stores/server'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { type PackageDataEntry, type PackageState } from '@/types/api'
|
||||
import AppCard from './apps/AppCard.vue'
|
||||
import AppIconGrid from './apps/AppIconGrid.vue'
|
||||
import AppsUninstallModal from './apps/AppsUninstallModal.vue'
|
||||
import { useAppsActions } from './apps/useAppsActions'
|
||||
import {
|
||||
filterEntriesForTab, isWebOnlyApp,
|
||||
WEB_ONLY_APPS, buildAllCategories, useCategoriesWithApps,
|
||||
type AppsTab, filterEntriesForTab, isWebOnlyApp, isWebsitePackage, opensInTab, resolveRuntimeLaunchUrl,
|
||||
WEB_ONLY_APPS, WEB_ONLY_APP_URLS, buildAllCategories, useCategoriesWithApps,
|
||||
} from './apps/appsConfig'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -165,13 +247,23 @@ const route = useRoute()
|
||||
const store = useAppStore()
|
||||
const serverStore = useServerStore()
|
||||
const actions = useAppsActions()
|
||||
const showSideload = ref(false)
|
||||
const sideloading = ref(false)
|
||||
const sideloadError = ref('')
|
||||
const sideloadForm = ref({
|
||||
id: '',
|
||||
image: '',
|
||||
title: '',
|
||||
port: '',
|
||||
description: '',
|
||||
})
|
||||
|
||||
// Only stagger-animate on first mount
|
||||
const showStagger = !appsAnimationDone
|
||||
|
||||
// Tabs
|
||||
const activeTab = ref<'apps' | 'services'>(
|
||||
route.query.tab === 'services' ? 'services' : 'apps'
|
||||
const activeTab = ref<AppsTab>(
|
||||
route.query.tab === 'websites' || route.query.tab === 'services' ? 'websites' : 'apps'
|
||||
)
|
||||
|
||||
// Search (debounced)
|
||||
@@ -285,6 +377,27 @@ function goToApp(id: string) {
|
||||
}
|
||||
|
||||
function launchApp(id: string) {
|
||||
const pkg = packages.value[id]
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
const webOnlyUrl = WEB_ONLY_APP_URLS[id]
|
||||
if (pkg && webOnlyUrl) {
|
||||
useAppLauncherStore().open({ url: webOnlyUrl, title: pkg.manifest.title, openInNewTab: !isMobile })
|
||||
return
|
||||
}
|
||||
if (pkg && isWebsitePackage(id, pkg)) {
|
||||
const url = resolveRuntimeLaunchUrl(pkg)
|
||||
if (url) {
|
||||
useAppLauncherStore().open({ url, title: pkg.manifest.title, openInNewTab: !isMobile })
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!isMobile && pkg && opensInTab(id)) {
|
||||
const url = resolveRuntimeLaunchUrl(pkg)
|
||||
if (url) {
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
return
|
||||
}
|
||||
}
|
||||
useAppLauncherStore().openSession(id)
|
||||
}
|
||||
|
||||
@@ -295,4 +408,147 @@ async function updateApp(id: string) {
|
||||
actions.actionError.value = `Failed to update ${id}: ${err instanceof Error ? err.message : 'Unknown error'}`
|
||||
}
|
||||
}
|
||||
|
||||
function closeSideload() {
|
||||
if (sideloading.value) return
|
||||
showSideload.value = false
|
||||
sideloadError.value = ''
|
||||
}
|
||||
|
||||
function inferPortMapping(image: string): string {
|
||||
const lower = image.toLowerCase()
|
||||
if (lower.includes('excalidraw')) return '3009:80'
|
||||
if (lower.includes('stirling')) return '3011:8080'
|
||||
if (lower.includes('freshrss')) return '3012:80'
|
||||
if (lower.includes('wallabag')) return '3013:80'
|
||||
if (lower.includes('hedgedoc')) return '3014:3000'
|
||||
if (lower.includes('cyberchef')) return '3015:80'
|
||||
if (lower.includes('mealie')) return '3016:9000'
|
||||
if (lower.includes('pairdrop')) return '3017:3000'
|
||||
return ''
|
||||
}
|
||||
|
||||
async function submitSideload() {
|
||||
const id = sideloadForm.value.id.trim().toLowerCase()
|
||||
const image = sideloadForm.value.image.trim()
|
||||
const title = sideloadForm.value.title.trim() || id.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
|
||||
const port = sideloadForm.value.port.trim() || inferPortMapping(image)
|
||||
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(id)) {
|
||||
sideloadError.value = 'Use lowercase letters, numbers, and hyphens only.'
|
||||
return
|
||||
}
|
||||
if (!image || !image.includes('/')) {
|
||||
sideloadError.value = 'Enter a full image name, for example docker.io/library/nginx:alpine.'
|
||||
return
|
||||
}
|
||||
sideloading.value = true
|
||||
sideloadError.value = ''
|
||||
const containerConfig: Record<string, unknown> = {}
|
||||
containerConfig.title = title
|
||||
if (sideloadForm.value.description.trim()) containerConfig.description = sideloadForm.value.description.trim()
|
||||
if (port) containerConfig.ports = [port]
|
||||
try {
|
||||
serverStore.setInstallProgress(id, {
|
||||
id,
|
||||
title,
|
||||
status: 'downloading',
|
||||
progress: 2,
|
||||
message: 'Sideload queued...',
|
||||
attempt: 0,
|
||||
})
|
||||
await rpcClient.call({
|
||||
method: 'package.install',
|
||||
params: {
|
||||
id,
|
||||
dockerImage: image,
|
||||
version: 'sideload',
|
||||
containerConfig,
|
||||
},
|
||||
timeout: 15000,
|
||||
})
|
||||
closeSideload()
|
||||
sideloadForm.value = { id: '', image: '', title: '', port: '', description: '' }
|
||||
} catch (err) {
|
||||
sideloadError.value = err instanceof Error ? err.message : 'Install failed'
|
||||
serverStore.setInstallProgress(id, {
|
||||
id,
|
||||
title,
|
||||
status: 'error',
|
||||
progress: 0,
|
||||
message: sideloadError.value,
|
||||
attempt: 0,
|
||||
})
|
||||
} finally {
|
||||
sideloading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sideload-icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
transition: border-color 0.15s ease, background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
.sideload-icon-btn:hover,
|
||||
.sideload-icon-btn:focus-visible {
|
||||
border-color: rgba(255, 255, 255, 0.38);
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: white;
|
||||
}
|
||||
.sideload-icon-btn-mobile {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
.sideload-modal {
|
||||
width: 100%;
|
||||
max-width: 34rem;
|
||||
max-height: calc(100dvh - var(--safe-area-top, env(safe-area-inset-top, 0px)) - 12px);
|
||||
overflow-y: auto;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 1.5rem 1.5rem 0 0;
|
||||
background: rgba(8, 10, 18, 0.94);
|
||||
padding: 1.25rem;
|
||||
padding-bottom: calc(1.25rem + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)));
|
||||
box-shadow: 0 -24px 70px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
.sideload-close-btn {
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border-radius: 0.75rem;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.sideload-label {
|
||||
display: block;
|
||||
margin-bottom: 0.4rem;
|
||||
font-size: 0.75rem;
|
||||
color: rgba(255, 255, 255, 0.62);
|
||||
}
|
||||
.sideload-input {
|
||||
width: 100%;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
padding: 0.75rem 0.9rem;
|
||||
color: white;
|
||||
outline: none;
|
||||
}
|
||||
.sideload-input::placeholder { color: rgba(255, 255, 255, 0.38); }
|
||||
.sideload-input:focus { border-color: rgba(255, 255, 255, 0.38); }
|
||||
@media (min-width: 768px) {
|
||||
.sideload-modal {
|
||||
border-radius: 1.25rem;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 25px 80px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<div class="mode-switcher flex-shrink-0">
|
||||
<RouterLink to="/dashboard/apps" class="mode-switcher-btn">My Apps</RouterLink>
|
||||
<RouterLink to="/dashboard/discover" class="mode-switcher-btn mode-switcher-btn-active">App Store</RouterLink>
|
||||
<RouterLink to="/dashboard/apps?tab=services" class="mode-switcher-btn">Services</RouterLink>
|
||||
<RouterLink to="/dashboard/apps?tab=websites" class="mode-switcher-btn">Websites</RouterLink>
|
||||
</div>
|
||||
<div class="mode-switcher flex-shrink-0">
|
||||
<RouterLink
|
||||
|
||||
+40
-23
@@ -144,12 +144,12 @@
|
||||
<span class="text-sm font-medium" :class="torConnected ? 'text-purple-400' : 'text-white/40'">{{ torConnected ? 'Connected' : 'Offline' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3"><div class="w-2 h-2 rounded-full" :class="vpnConnected ? 'bg-orange-400' : 'bg-white/40'"></div><span class="text-sm text-white/80">VPN</span></div>
|
||||
<span class="text-sm font-medium" :class="vpnConnected ? 'text-orange-400' : 'text-white/40'">{{ vpnConnected ? 'WireGuard' : 'Not configured' }}</span>
|
||||
<div class="flex items-center gap-3"><div class="w-2 h-2 rounded-full" :class="vpnDotClass"></div><span class="text-sm text-white/80">VPN</span></div>
|
||||
<span class="text-sm font-medium" :class="vpnTextClass">{{ vpnStatusLabel }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3"><div class="w-2 h-2 rounded-full" :class="systemStats.bitcoinAvailable ? 'bg-orange-400' : 'bg-white/40'"></div><span class="text-sm text-white/80">Bitcoin</span></div>
|
||||
<span class="text-sm font-medium" :class="systemStats.bitcoinAvailable ? 'text-orange-400' : 'text-white/40'">{{ bitcoinSyncDisplay }}</span>
|
||||
<div class="flex items-center gap-3"><div class="w-2 h-2 rounded-full" :class="bitcoinDotClass"></div><span class="text-sm text-white/80">Bitcoin</span></div>
|
||||
<span class="text-sm font-medium" :class="bitcoinTextClass">{{ bitcoinSyncDisplay }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3"><div class="w-2 h-2 rounded-full" :class="fipsDotClass"></div><span class="text-sm text-white/80">FIPS</span></div>
|
||||
@@ -229,7 +229,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch, onBeforeUnmount, onMounted } from 'vue'
|
||||
import { computed, ref, watch, onBeforeUnmount, onMounted } from 'vue'
|
||||
import { RouterLink, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import SendBitcoinModal from '@/components/SendBitcoinModal.vue'
|
||||
@@ -239,6 +239,7 @@ import { useAppStore } from '../stores/app'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import { useLoginTransitionStore } from '../stores/loginTransition'
|
||||
import { useUIModeStore } from '@/stores/uiMode'
|
||||
import { useHomeStatusStore } from '@/stores/homeStatus'
|
||||
import { PackageState } from '../types/api'
|
||||
import { playTypingSound } from '@/composables/useLoginSounds'
|
||||
import { GOALS } from '@/data/goals'
|
||||
@@ -261,6 +262,7 @@ const QUICK_START_KEY = 'archipelago-quick-start-dismissed'
|
||||
const QUICK_START_RESHOW_LOGINS = 5
|
||||
|
||||
const store = useAppStore()
|
||||
const homeStatus = useHomeStatusStore()
|
||||
const loginTransition = useLoginTransitionStore()
|
||||
|
||||
const LINE1 = t('home.title')
|
||||
@@ -315,11 +317,19 @@ const torConnected = computed(() => {
|
||||
const torAddr = store.data?.['server-info']?.['tor-address']
|
||||
return !!torAddr && torAddr.length > 0
|
||||
})
|
||||
const vpnStatus = ref({ connected: false, provider: '' })
|
||||
const vpnConnected = computed(() => vpnStatus.value.connected || (!!packages.value['tailscale'] && packages.value['tailscale'].state === PackageState.Running))
|
||||
const fipsStatus = ref<{ installed: boolean; service_active: boolean; key_present: boolean; anchor_connected?: boolean; authenticated_peer_count?: number } | null>(null)
|
||||
const vpnConnected = computed(() => homeStatus.vpnStatus.connected === true || (!!packages.value['tailscale'] && packages.value['tailscale'].state === PackageState.Running))
|
||||
const vpnDotClass = computed(() => {
|
||||
if (vpnConnected.value) return 'bg-orange-400'
|
||||
return homeStatus.vpnKnown ? 'bg-white/40' : 'bg-white/25 animate-pulse'
|
||||
})
|
||||
const vpnTextClass = computed(() => vpnConnected.value ? 'text-orange-400' : (homeStatus.vpnKnown ? 'text-white/40' : 'text-white/50'))
|
||||
const vpnStatusLabel = computed(() => {
|
||||
if (vpnConnected.value) return homeStatus.vpnStatus.provider || 'WireGuard'
|
||||
if (!homeStatus.vpnKnown) return 'Checking…'
|
||||
return 'Not configured'
|
||||
})
|
||||
const fipsDotClass = computed(() => {
|
||||
const s = fipsStatus.value
|
||||
const s = homeStatus.fipsStatus
|
||||
if (!s || !s.installed) return 'bg-white/40'
|
||||
if (!s.service_active) return 'bg-white/40'
|
||||
// Active but no anchor = degraded, not fully green
|
||||
@@ -327,15 +337,15 @@ const fipsDotClass = computed(() => {
|
||||
return 'bg-green-400'
|
||||
})
|
||||
const fipsTextClass = computed(() => {
|
||||
const s = fipsStatus.value
|
||||
const s = homeStatus.fipsStatus
|
||||
if (!s || !s.installed) return 'text-white/40'
|
||||
if (!s.service_active) return 'text-white/40'
|
||||
if (s.anchor_connected === false) return 'text-orange-400'
|
||||
return 'text-green-400'
|
||||
})
|
||||
const fipsStatusLabel = computed(() => {
|
||||
const s = fipsStatus.value
|
||||
if (!s) return '…'
|
||||
const s = homeStatus.fipsStatus
|
||||
if (!s) return homeStatus.fipsLoadState === 'loading' ? 'Checking…' : '…'
|
||||
if (!s.installed) return 'Not installed'
|
||||
if (!s.service_active) {
|
||||
if (!s.key_present) return 'Awaiting seed'
|
||||
@@ -348,11 +358,18 @@ const fipsStatusLabel = computed(() => {
|
||||
return peers === 1 ? 'Active · 1 peer' : `Active · ${peers} peers`
|
||||
})
|
||||
const bitcoinSyncDisplay = computed(() => {
|
||||
if (!systemStats.bitcoinAvailable) return 'Not running'
|
||||
if (systemStats.bitcoinSyncPercent >= 99.9) return 'Synced'
|
||||
if (systemStats.bitcoinSyncPercent < 0.01 && systemStats.bitcoinBlockHeight === 0) return 'Loading...'
|
||||
return `${systemStats.bitcoinSyncPercent.toFixed(1)}%`
|
||||
if (homeStatus.stats.bitcoinAvailable === null) return 'Checking…'
|
||||
if (!homeStatus.stats.bitcoinAvailable) return 'Not running'
|
||||
if (homeStatus.stats.bitcoinSyncPercent >= 99.9) return 'Synced'
|
||||
if (homeStatus.stats.bitcoinSyncPercent < 0.01 && homeStatus.stats.bitcoinBlockHeight === 0) return 'Loading...'
|
||||
return `${homeStatus.stats.bitcoinSyncPercent.toFixed(1)}%`
|
||||
})
|
||||
const bitcoinDotClass = computed(() => {
|
||||
if (homeStatus.stats.bitcoinAvailable === true) return 'bg-orange-400'
|
||||
if (homeStatus.stats.bitcoinAvailable === false) return 'bg-white/40'
|
||||
return 'bg-white/25 animate-pulse'
|
||||
})
|
||||
const bitcoinTextClass = computed(() => homeStatus.stats.bitcoinAvailable ? 'text-orange-400' : (homeStatus.stats.bitcoinAvailable === null ? 'text-white/50' : 'text-white/40'))
|
||||
|
||||
// Quick Start
|
||||
const quickStartDismissed = ref(false)
|
||||
@@ -382,8 +399,6 @@ const cloudFolderDisplay = computed(() => cloudFolderCount.value !== null ? Stri
|
||||
onMounted(async () => {
|
||||
try { const usage = await fileBrowserClient.getUsage(); cloudStorageUsed.value = usage.totalSize; cloudFolderCount.value = usage.folderCount } catch { /* not running */ }
|
||||
loadSystemStats(); systemStatsInterval = setInterval(loadSystemStats, 30000); checkUpdateStatus(); loadWeb5Status()
|
||||
rpcClient.vpnStatus().then(s => { vpnStatus.value = { connected: s.connected, provider: s.provider ?? '' } }).catch(() => {})
|
||||
rpcClient.call<{ installed: boolean; service_active: boolean; key_present: boolean }>({ method: 'fips.status' }).then(s => { fipsStatus.value = s }).catch(() => {})
|
||||
})
|
||||
|
||||
// Wallet modals
|
||||
@@ -403,15 +418,17 @@ async function loadWeb5Status() {
|
||||
}
|
||||
|
||||
// System stats
|
||||
const systemStatsLoaded = ref(false)
|
||||
const systemStats = reactive({ cpuPercent: 0, memUsed: 0, memTotal: 0, memPercent: 0, diskUsed: 0, diskTotal: 0, diskPercent: 0, uptimeSecs: 0, bitcoinSyncPercent: 0, bitcoinBlockHeight: 0, bitcoinAvailable: false })
|
||||
const systemUptimeDisplay = computed(() => { if (systemStats.uptimeSecs === 0) return t('home.systemMonitoring'); const days = Math.floor(systemStats.uptimeSecs / 86400); const hours = Math.floor((systemStats.uptimeSecs % 86400) / 3600); if (days > 0) return `Uptime: ${days}d ${hours}h`; const mins = Math.floor((systemStats.uptimeSecs % 3600) / 60); return `Uptime: ${hours}h ${mins}m` })
|
||||
const systemStatsLoaded = computed(() => homeStatus.systemStatsLoaded)
|
||||
const systemStats = computed(() => ({
|
||||
...homeStatus.stats,
|
||||
bitcoinAvailable: homeStatus.stats.bitcoinAvailable === true,
|
||||
}))
|
||||
const systemUptimeDisplay = computed(() => { if (homeStatus.stats.uptimeSecs === 0) return t('home.systemMonitoring'); const days = Math.floor(homeStatus.stats.uptimeSecs / 86400); const hours = Math.floor((homeStatus.stats.uptimeSecs % 86400) / 3600); if (days > 0) return `Uptime: ${days}d ${hours}h`; const mins = Math.floor((homeStatus.stats.uptimeSecs % 3600) / 60); return `Uptime: ${hours}h ${mins}m` })
|
||||
|
||||
let systemStatsInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
async function loadSystemStats() {
|
||||
try { const res = await rpcClient.call<{ cpu_usage_percent: number; mem_used_bytes: number; mem_total_bytes: number; disk_used_bytes: number; disk_total_bytes: number; uptime_secs: number }>({ method: 'system.stats' }); systemStats.cpuPercent = res.cpu_usage_percent; systemStats.memUsed = res.mem_used_bytes; systemStats.memTotal = res.mem_total_bytes; systemStats.memPercent = res.mem_total_bytes > 0 ? (res.mem_used_bytes / res.mem_total_bytes) * 100 : 0; systemStats.diskUsed = res.disk_used_bytes; systemStats.diskTotal = res.disk_total_bytes; systemStats.diskPercent = res.disk_total_bytes > 0 ? (res.disk_used_bytes / res.disk_total_bytes) * 100 : 0; systemStats.uptimeSecs = res.uptime_secs; systemStatsLoaded.value = true } catch { systemStatsLoaded.value = true }
|
||||
try { const btc = await rpcClient.call<{ block_height: number; sync_progress: number }>({ method: 'bitcoin.getinfo', timeout: 5000 }); systemStats.bitcoinSyncPercent = (btc.sync_progress ?? 0) * 100; systemStats.bitcoinBlockHeight = btc.block_height ?? 0; systemStats.bitcoinAvailable = true } catch { /* RPC failed — check if container is at least running (loading/syncing) */ const btcPkg = packages.value['bitcoin-knots']; systemStats.bitcoinAvailable = btcPkg?.state === PackageState.Running }
|
||||
await homeStatus.refresh(packages.value)
|
||||
}
|
||||
|
||||
function uploadFiles() { const pkg = packages.value['filebrowser']; if (pkg && pkg.state === PackageState.Running) { const host = window.location.hostname; useAppLauncherStore().open({ url: `http://${host}:8083`, title: 'File Browser' }) } else { router.push('/dashboard/cloud') } }
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<div class="mode-switcher flex-shrink-0">
|
||||
<RouterLink to="/dashboard/apps" class="mode-switcher-btn">My Apps</RouterLink>
|
||||
<RouterLink to="/dashboard/discover" class="mode-switcher-btn mode-switcher-btn-active">App Store</RouterLink>
|
||||
<RouterLink to="/dashboard/apps?tab=services" class="mode-switcher-btn">Services</RouterLink>
|
||||
<RouterLink to="/dashboard/apps?tab=websites" class="mode-switcher-btn">Websites</RouterLink>
|
||||
</div>
|
||||
<div class="mode-switcher flex-shrink-0">
|
||||
<RouterLink
|
||||
@@ -436,9 +436,11 @@ async function installCommunityApp(app: MarketplaceApp) {
|
||||
router.push('/dashboard/apps').catch(() => {})
|
||||
|
||||
try {
|
||||
const installParams: Record<string, unknown> = { id: app.id, dockerImage: app.dockerImage, version: app.version }
|
||||
if (app.containerConfig) installParams.containerConfig = app.containerConfig
|
||||
await rpcClient.call({
|
||||
method: 'package.install',
|
||||
params: { id: app.id, dockerImage: app.dockerImage, version: app.version },
|
||||
params: installParams,
|
||||
timeout: 15000,
|
||||
})
|
||||
} catch (err) {
|
||||
|
||||
@@ -382,6 +382,7 @@ import { useMarketplaceApp, type MarketplaceAppInfo } from '../composables/useMa
|
||||
import { useMobileBackButton } from '../composables/useMobileBackButton'
|
||||
import { useAppLauncherStore } from '../stores/appLauncher'
|
||||
import { useToast } from '../composables/useToast'
|
||||
import { handleImageError } from './apps/appsConfig'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { bottomPosition } = useMobileBackButton()
|
||||
@@ -530,11 +531,6 @@ onBeforeUnmount(() => {
|
||||
if (pendingRedirect) { clearTimeout(pendingRedirect); pendingRedirect = null }
|
||||
})
|
||||
|
||||
function handleImageError(e: Event) {
|
||||
const target = e.target as HTMLImageElement
|
||||
target.src = '/assets/img/logo-archipelago.svg'
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
if (route.query.from === 'discover') {
|
||||
router.push('/dashboard/discover').catch(() => {})
|
||||
@@ -611,13 +607,15 @@ async function installApp() {
|
||||
try {
|
||||
if (app.value.dockerImage) {
|
||||
// Docker-based app installation
|
||||
const installParams: Record<string, unknown> = {
|
||||
id: app.value.id,
|
||||
dockerImage: app.value.dockerImage,
|
||||
version: app.value.version,
|
||||
}
|
||||
if (app.value.containerConfig) installParams.containerConfig = app.value.containerConfig
|
||||
await rpcClient.call({
|
||||
method: 'package.install',
|
||||
params: {
|
||||
id: app.value.id,
|
||||
dockerImage: app.value.dockerImage,
|
||||
version: app.value.version,
|
||||
},
|
||||
params: installParams,
|
||||
timeout: 15000,
|
||||
})
|
||||
} else {
|
||||
|
||||
@@ -41,23 +41,23 @@
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<div class="monitoring-stat-card">
|
||||
<p class="text-xs text-white/50 uppercase tracking-wide">{{ t('monitoring.cpu') }}</p>
|
||||
<p class="text-2xl font-bold text-white">{{ current?.system.cpu_percent.toFixed(1) ?? '--' }}%</p>
|
||||
<p class="text-xs text-white/40">{{ t('monitoring.load') }} {{ current?.system.load_avg_1.toFixed(2) ?? '--' }}</p>
|
||||
<p class="text-2xl font-bold text-white">{{ liveSystem.cpu_percent.toFixed(1) }}%</p>
|
||||
<p class="text-xs text-white/40">{{ t('monitoring.load') }} {{ liveSystem.load_avg_1.toFixed(2) }}</p>
|
||||
</div>
|
||||
<div class="monitoring-stat-card">
|
||||
<p class="text-xs text-white/50 uppercase tracking-wide">{{ t('monitoring.memory') }}</p>
|
||||
<p class="text-2xl font-bold text-white">{{ memPercent }}%</p>
|
||||
<p class="text-xs text-white/40">{{ formatBytes(current?.system.mem_used_bytes ?? 0) }} / {{ formatBytes(current?.system.mem_total_bytes ?? 0) }}</p>
|
||||
<p class="text-xs text-white/40">{{ formatBytes(liveSystem.mem_used_bytes) }} / {{ formatBytes(liveSystem.mem_total_bytes) }}</p>
|
||||
</div>
|
||||
<div class="monitoring-stat-card">
|
||||
<p class="text-xs text-white/50 uppercase tracking-wide">{{ t('monitoring.diskUsage') }}</p>
|
||||
<p class="text-2xl font-bold text-white">{{ diskPercent }}%</p>
|
||||
<p class="text-xs text-white/40">{{ formatBytes(current?.system.disk_used_bytes ?? 0) }} / {{ formatBytes(current?.system.disk_total_bytes ?? 0) }}</p>
|
||||
<p class="text-xs text-white/40">{{ formatBytes(liveSystem.disk_used_bytes) }} / {{ formatBytes(liveSystem.disk_total_bytes) }}</p>
|
||||
</div>
|
||||
<div class="monitoring-stat-card">
|
||||
<p class="text-xs text-white/50 uppercase tracking-wide">{{ t('monitoring.network') }}</p>
|
||||
<p class="text-2xl font-bold text-white">{{ formatBytes(current?.system.net_rx_bytes ?? 0) }}</p>
|
||||
<p class="text-xs text-white/40">TX: {{ formatBytes(current?.system.net_tx_bytes ?? 0) }}</p>
|
||||
<p class="text-2xl font-bold text-white">{{ formatBytes(liveSystem.net_rx_bytes) }}</p>
|
||||
<p class="text-xs text-white/40">TX: {{ formatBytes(liveSystem.net_tx_bytes) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -236,6 +236,7 @@ import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { useHomeStatusStore } from '@/stores/homeStatus'
|
||||
import LineChart from '@/components/LineChart.vue'
|
||||
import ToggleSwitch from '@/components/ToggleSwitch.vue'
|
||||
import type { ChartDataset } from '@/components/LineChart.vue'
|
||||
@@ -297,6 +298,7 @@ interface FiredAlert {
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const homeStatus = useHomeStatusStore()
|
||||
|
||||
const current = ref<MetricSnapshot | null>(null)
|
||||
const history = ref<MetricSnapshot[]>([])
|
||||
@@ -307,14 +309,27 @@ const showAlertConfig = ref(false)
|
||||
const chartWidth = ref(380)
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const liveSystem = computed<SystemMetrics>(() => ({
|
||||
cpu_percent: homeStatus.stats.cpuPercent,
|
||||
mem_used_bytes: homeStatus.stats.memUsed,
|
||||
mem_total_bytes: homeStatus.stats.memTotal,
|
||||
disk_used_bytes: homeStatus.stats.diskUsed,
|
||||
disk_total_bytes: homeStatus.stats.diskTotal,
|
||||
net_rx_bytes: current.value?.system.net_rx_bytes ?? 0,
|
||||
net_tx_bytes: current.value?.system.net_tx_bytes ?? 0,
|
||||
load_avg_1: homeStatus.stats.loadAvg1,
|
||||
load_avg_5: homeStatus.stats.loadAvg5,
|
||||
load_avg_15: homeStatus.stats.loadAvg15,
|
||||
}))
|
||||
|
||||
const memPercent = computed(() => {
|
||||
if (!current.value?.system.mem_total_bytes) return '--'
|
||||
return ((current.value.system.mem_used_bytes / current.value.system.mem_total_bytes) * 100).toFixed(1)
|
||||
if (!liveSystem.value.mem_total_bytes) return '--'
|
||||
return ((liveSystem.value.mem_used_bytes / liveSystem.value.mem_total_bytes) * 100).toFixed(1)
|
||||
})
|
||||
|
||||
const diskPercent = computed(() => {
|
||||
if (!current.value?.system.disk_total_bytes) return '--'
|
||||
return ((current.value.system.disk_used_bytes / current.value.system.disk_total_bytes) * 100).toFixed(1)
|
||||
if (!liveSystem.value.disk_total_bytes) return '--'
|
||||
return ((liveSystem.value.disk_used_bytes / liveSystem.value.disk_total_bytes) * 100).toFixed(1)
|
||||
})
|
||||
|
||||
const historyMinutesAgo = computed(() => history.value.length || 60)
|
||||
@@ -460,6 +475,7 @@ async function exportMetrics(format: 'csv' | 'json') {
|
||||
|
||||
async function fetchCurrent() {
|
||||
try {
|
||||
await homeStatus.refreshSystemStats()
|
||||
const data = await rpcClient.call<MetricSnapshot | { status: string }>({
|
||||
method: 'monitoring.current',
|
||||
})
|
||||
|
||||
@@ -344,94 +344,96 @@
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<!-- Add-mirror modal -->
|
||||
<Transition name="fade">
|
||||
<div v-if="addingMirror" class="fixed inset-0 z-50 flex items-center justify-center bg-black/10 backdrop-blur-md" @click.self="cancelAddMirror">
|
||||
<div class="glass-card p-6 max-w-md w-full mx-4">
|
||||
<h3 class="text-lg font-semibold text-white mb-1">Add update mirror</h3>
|
||||
<p class="text-sm text-white/60 mb-5">
|
||||
The URL should point directly at a <span class="font-mono text-white/80">manifest.json</span> served by a Gitea mirror or equivalent. It's added to the end of the list; use "Make primary" to change order.
|
||||
</p>
|
||||
<form class="space-y-3" @submit.prevent="submitMirror">
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">Manifest URL</label>
|
||||
<input
|
||||
v-model="mirrorDraft.url"
|
||||
type="text"
|
||||
autofocus
|
||||
placeholder="https://host/.../manifest.json"
|
||||
class="w-full px-3 py-2 rounded-md bg-white/5 border border-white/10 text-sm text-white focus:border-white/30 focus:outline-none font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">Label (optional)</label>
|
||||
<input
|
||||
v-model="mirrorDraft.label"
|
||||
type="text"
|
||||
placeholder="Home VPS"
|
||||
class="w-full px-3 py-2 rounded-md bg-white/5 border border-white/10 text-sm text-white focus:border-white/30 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-3 justify-end pt-2">
|
||||
<button
|
||||
type="button"
|
||||
@click="cancelAddMirror"
|
||||
class="glass-button rounded-lg px-4 py-2 text-sm font-medium"
|
||||
>{{ t('common.cancel') }}</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="glass-button rounded-lg px-4 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:disabled="mirrorSaving || !mirrorDraft.url.trim()"
|
||||
>{{ mirrorSaving ? 'Adding…' : 'Add mirror' }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Confirmation modal -->
|
||||
<Transition name="fade">
|
||||
<div v-if="confirmAction" class="fixed inset-0 z-50 flex items-center justify-center bg-black/10 backdrop-blur-md" @click.self="cancelConfirm">
|
||||
<div class="glass-card p-6 max-w-sm w-full mx-4">
|
||||
<h3 class="text-lg font-semibold text-white mb-3">
|
||||
{{ confirmAction === 'rollback'
|
||||
? t('systemUpdate.rollbackTitle')
|
||||
: confirmAction === 'git-apply'
|
||||
? t('systemUpdate.gitApplyTitle')
|
||||
: confirmAction === 'cancel-download'
|
||||
? t('systemUpdate.cancelDownloadTitle')
|
||||
: t('systemUpdate.applyTitle') }}
|
||||
</h3>
|
||||
<p class="text-sm text-white/70 mb-6">
|
||||
{{ confirmAction === 'rollback'
|
||||
? t('systemUpdate.rollbackMessage')
|
||||
: confirmAction === 'git-apply'
|
||||
? t('systemUpdate.gitApplyMessage')
|
||||
: confirmAction === 'cancel-download'
|
||||
? t('systemUpdate.cancelDownloadConfirm')
|
||||
: t('systemUpdate.applyMessage') }}
|
||||
</p>
|
||||
<div class="flex gap-3 justify-end">
|
||||
<button @click="cancelConfirm" class="glass-button rounded-lg px-4 py-2 text-sm font-medium">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
@click="executeConfirm"
|
||||
class="glass-button rounded-lg px-4 py-2 text-sm font-medium"
|
||||
:class="(confirmAction === 'rollback' || confirmAction === 'cancel-download') ? 'bg-red-500/20 border-red-400/30' : 'bg-orange-500/20 border-orange-400/30'"
|
||||
>
|
||||
{{ confirmAction === 'rollback'
|
||||
? t('systemUpdate.rollbackButton')
|
||||
: confirmAction === 'git-apply'
|
||||
? t('systemUpdate.pullAndRebuild')
|
||||
: confirmAction === 'cancel-download'
|
||||
? t('systemUpdate.cancelDownloadButton')
|
||||
: t('systemUpdate.applyNow') }}
|
||||
</button>
|
||||
<Teleport to="body">
|
||||
<!-- Add-mirror modal -->
|
||||
<Transition name="fade">
|
||||
<div v-if="addingMirror" class="fixed inset-0 z-[3000] flex items-center justify-center p-4 bg-black/60 backdrop-blur-md" @click.self="cancelAddMirror">
|
||||
<div class="glass-card p-6 max-w-md w-full">
|
||||
<h3 class="text-lg font-semibold text-white mb-1">Add update mirror</h3>
|
||||
<p class="text-sm text-white/60 mb-5">
|
||||
The URL should point directly at a <span class="font-mono text-white/80">manifest.json</span> served by a Gitea mirror or equivalent. It's added to the end of the list; use "Make primary" to change order.
|
||||
</p>
|
||||
<form class="space-y-3" @submit.prevent="submitMirror">
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">Manifest URL</label>
|
||||
<input
|
||||
v-model="mirrorDraft.url"
|
||||
type="text"
|
||||
autofocus
|
||||
placeholder="https://host/.../manifest.json"
|
||||
class="w-full px-3 py-2 rounded-md bg-white/5 border border-white/10 text-sm text-white focus:border-white/30 focus:outline-none font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">Label (optional)</label>
|
||||
<input
|
||||
v-model="mirrorDraft.label"
|
||||
type="text"
|
||||
placeholder="Home VPS"
|
||||
class="w-full px-3 py-2 rounded-md bg-white/5 border border-white/10 text-sm text-white focus:border-white/30 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-3 justify-end pt-2">
|
||||
<button
|
||||
type="button"
|
||||
@click="cancelAddMirror"
|
||||
class="glass-button rounded-lg px-4 py-2 text-sm font-medium"
|
||||
>{{ t('common.cancel') }}</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="glass-button rounded-lg px-4 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:disabled="mirrorSaving || !mirrorDraft.url.trim()"
|
||||
>{{ mirrorSaving ? 'Adding…' : 'Add mirror' }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Transition>
|
||||
|
||||
<!-- Confirmation modal -->
|
||||
<Transition name="fade">
|
||||
<div v-if="confirmAction" class="fixed inset-0 z-[3000] flex items-center justify-center p-4 bg-black/60 backdrop-blur-md" @click.self="cancelConfirm">
|
||||
<div class="glass-card p-6 max-w-sm w-full">
|
||||
<h3 class="text-lg font-semibold text-white mb-3">
|
||||
{{ confirmAction === 'rollback'
|
||||
? t('systemUpdate.rollbackTitle')
|
||||
: confirmAction === 'git-apply'
|
||||
? t('systemUpdate.gitApplyTitle')
|
||||
: confirmAction === 'cancel-download'
|
||||
? t('systemUpdate.cancelDownloadTitle')
|
||||
: t('systemUpdate.applyTitle') }}
|
||||
</h3>
|
||||
<p class="text-sm text-white/70 mb-6">
|
||||
{{ confirmAction === 'rollback'
|
||||
? t('systemUpdate.rollbackMessage')
|
||||
: confirmAction === 'git-apply'
|
||||
? t('systemUpdate.gitApplyMessage')
|
||||
: confirmAction === 'cancel-download'
|
||||
? t('systemUpdate.cancelDownloadConfirm')
|
||||
: t('systemUpdate.applyMessage') }}
|
||||
</p>
|
||||
<div class="flex gap-3 justify-end">
|
||||
<button @click="cancelConfirm" class="glass-button rounded-lg px-4 py-2 text-sm font-medium">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
@click="executeConfirm"
|
||||
class="glass-button rounded-lg px-4 py-2 text-sm font-medium"
|
||||
:class="(confirmAction === 'rollback' || confirmAction === 'cancel-download') ? 'bg-red-500/20 border-red-400/30' : 'bg-orange-500/20 border-orange-400/30'"
|
||||
>
|
||||
{{ confirmAction === 'rollback'
|
||||
? t('systemUpdate.rollbackButton')
|
||||
: confirmAction === 'git-apply'
|
||||
? t('systemUpdate.pullAndRebuild')
|
||||
: confirmAction === 'cancel-download'
|
||||
? t('systemUpdate.cancelDownloadButton')
|
||||
: t('systemUpdate.applyNow') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -739,6 +741,10 @@ function showStatus(msg: string, isError = false) {
|
||||
setTimeout(() => { statusMessage.value = '' }, 8000)
|
||||
}
|
||||
|
||||
function errorMessage(e: unknown): string {
|
||||
return e instanceof Error ? e.message : String(e)
|
||||
}
|
||||
|
||||
async function loadStatus() {
|
||||
try {
|
||||
const res = await rpcClient.call<{
|
||||
@@ -814,7 +820,17 @@ async function downloadUpdate() {
|
||||
const sizeMB = (res.downloaded_bytes / 1_048_576).toFixed(1)
|
||||
showStatus(t('systemUpdate.downloadSuccess', { count: res.components_downloaded, size: sizeMB }))
|
||||
} catch (e) {
|
||||
showStatus(t('systemUpdate.downloadFailed'), true)
|
||||
const msg = errorMessage(e)
|
||||
if (/no update.*available/i.test(msg)) {
|
||||
updateInfo.value = null
|
||||
updateMethod.value = null
|
||||
downloaded.value = false
|
||||
updateInProgress.value = false
|
||||
await loadStatus()
|
||||
showStatus(t('systemUpdate.upToDateMessage'))
|
||||
} else {
|
||||
showStatus(`${t('systemUpdate.downloadFailed')} ${msg}`, true)
|
||||
}
|
||||
if (import.meta.env.DEV) console.warn('Download failed', e)
|
||||
} finally {
|
||||
clearInterval(progressInterval)
|
||||
|
||||
@@ -83,7 +83,7 @@ export const APP_URLS: Record<string, { dev: string; prod: string }> = {
|
||||
'photoprism': { dev: 'http://localhost:2342', prod: 'http://localhost:2342' },
|
||||
'immich': { dev: 'http://localhost:2283', prod: 'http://localhost:2283' },
|
||||
'filebrowser': { dev: 'http://localhost:8083', prod: 'http://localhost:8083' },
|
||||
'nginx-proxy-manager': { dev: 'http://localhost:81', prod: 'http://localhost:81' },
|
||||
'nginx-proxy-manager': { dev: 'http://localhost:8081', prod: 'http://localhost:8081' },
|
||||
'gitea': { dev: 'http://localhost:3001', prod: 'http://localhost:3001' },
|
||||
'portainer': { dev: 'http://localhost:9000', prod: 'http://localhost:9000' },
|
||||
'uptime-kuma': { dev: 'http://localhost:3002', prod: 'http://localhost:3002' },
|
||||
@@ -160,7 +160,7 @@ export function getStatusLabel(state: PackageState, health?: string | null, exit
|
||||
if (state === PackageState.Running && health === 'unhealthy') return 'unhealthy'
|
||||
if (state === PackageState.Running && health === 'healthy') return 'healthy'
|
||||
if (state === PackageState.Exited) {
|
||||
if (exitCode === 137) return 'killed (OOM)'
|
||||
if (exitCode === 137) return 'killed (SIGKILL)'
|
||||
if (exitCode != null && exitCode !== 0) return 'crashed'
|
||||
return 'stopped'
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export const APP_PORTS: Record<string, number> = {
|
||||
'immich': 2283,
|
||||
'immich_server': 2283,
|
||||
'filebrowser': 8083,
|
||||
'nginx-proxy-manager': 81,
|
||||
'nginx-proxy-manager': 8081,
|
||||
'gitea': 3001,
|
||||
'portainer': 9000,
|
||||
'tailscale': 8240,
|
||||
@@ -62,6 +62,7 @@ export const EXTERNAL_URLS: Record<string, string> = {
|
||||
'nwnn': 'https://nwnn.l484.com',
|
||||
'484-kitchen': 'https://484.kitchen',
|
||||
'call-the-operator': 'https://cta.tx1138.com',
|
||||
'arch-presentation': 'https://present.l484.com',
|
||||
'syntropy-institute': 'https://syntropy.institute',
|
||||
't-zero': 'https://teeminuszero.net',
|
||||
'nostrudel': 'https://nostrudel.ninja',
|
||||
|
||||
@@ -145,7 +145,7 @@
|
||||
class="flex-1 px-4 py-2 glass-button glass-button-sm rounded-lg text-sm font-medium flex items-center justify-center gap-1.5"
|
||||
>
|
||||
{{ t('common.launch') }}
|
||||
<svg v-if="opensInTab(id)" class="w-3.5 h-3.5 opacity-60" 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" /></svg>
|
||||
<svg v-if="opensInTab(id)" class="hidden md:block w-3.5 h-3.5 opacity-60" 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" /></svg>
|
||||
</button>
|
||||
<!-- Start (play icon) -->
|
||||
<button
|
||||
|
||||
@@ -79,7 +79,7 @@ import { useServerStore } from '@/stores/server'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import type { PackageDataEntry } from '@/types/api'
|
||||
import { resolveAppUrl } from '@/views/appSession/appSessionConfig'
|
||||
import { canLaunch, handleImageError, opensInTab, resolveAppIcon } from './appsConfig'
|
||||
import { canLaunch, handleImageError, isWebsitePackage, opensInTab, resolveAppIcon, resolveRuntimeLaunchUrl, WEB_ONLY_APP_URLS } from './appsConfig'
|
||||
import { getCuratedAppList } from '../discover/curatedApps'
|
||||
|
||||
const ITEMS_PER_PAGE = 16 // 4 columns x 4 rows
|
||||
@@ -120,8 +120,21 @@ function getIcon(id: string, pkg: PackageDataEntry): string {
|
||||
|
||||
function handleTap(id: string, pkg: PackageDataEntry) {
|
||||
if (canLaunch(pkg)) {
|
||||
if (opensInTab(id)) {
|
||||
const appUrl = resolveAppUrl(id)
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
const webOnlyUrl = WEB_ONLY_APP_URLS[id]
|
||||
if (webOnlyUrl) {
|
||||
appLauncher.open({ url: webOnlyUrl, title: getTitle(id, pkg), openInNewTab: !isMobile })
|
||||
return
|
||||
}
|
||||
if (isWebsitePackage(id, pkg)) {
|
||||
const url = resolveRuntimeLaunchUrl(pkg)
|
||||
if (url) {
|
||||
appLauncher.open({ url, title: getTitle(id, pkg), openInNewTab: !isMobile })
|
||||
return
|
||||
}
|
||||
}
|
||||
if (!isMobile && opensInTab(id)) {
|
||||
const appUrl = resolveRuntimeLaunchUrl(pkg) || resolveAppUrl(id)
|
||||
if (appUrl) {
|
||||
window.open(appUrl, '_blank', 'noopener,noreferrer')
|
||||
return
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { PackageState, type PackageDataEntry } from '@/types/api'
|
||||
import { resolveAppUrl } from '../appSession/appSessionConfig'
|
||||
|
||||
export type AppsTab = 'apps' | 'websites' | 'services'
|
||||
|
||||
// Service container name patterns (backend/infra, not user-facing)
|
||||
export const SERVICE_NAMES = new Set([
|
||||
@@ -21,6 +24,15 @@ export const SERVICE_NAMES = new Set([
|
||||
'syntropy-institute', 't-zero', 'arch-presentation',
|
||||
])
|
||||
|
||||
const INTERNAL_TOOLING_NAMES = new Set([
|
||||
'buildx_buildkit_default',
|
||||
])
|
||||
|
||||
export function isInternalToolingPackage(id: string, pkg?: PackageDataEntry): boolean {
|
||||
const manifestId = pkg?.manifest?.id || ''
|
||||
return INTERNAL_TOOLING_NAMES.has(id) || INTERNAL_TOOLING_NAMES.has(manifestId) || id.startsWith('buildx_buildkit') || manifestId.startsWith('buildx_buildkit')
|
||||
}
|
||||
|
||||
export function isServiceContainer(id: string): boolean {
|
||||
if (SERVICE_NAMES.has(id)) return true
|
||||
if (id.startsWith('indeedhub-build_')) return true
|
||||
@@ -57,14 +69,32 @@ export function getAppCategory(id: string, pkg: PackageDataEntry): string {
|
||||
return cat || 'other'
|
||||
}
|
||||
|
||||
export function runtimeLanAddress(pkg: PackageDataEntry): string {
|
||||
return pkg.installed?.['interface-addresses']?.main?.['lan-address'] || ''
|
||||
}
|
||||
|
||||
export function isKnownApp(id: string, pkg?: PackageDataEntry): boolean {
|
||||
const manifestId = pkg?.manifest?.id
|
||||
return !!(APP_CATEGORY_MAP[id] || (manifestId && APP_CATEGORY_MAP[manifestId]) || isWebOnlyApp(id))
|
||||
}
|
||||
|
||||
export function isWebsitePackage(id: string, pkg?: PackageDataEntry): boolean {
|
||||
if (isInternalToolingPackage(id, pkg)) return false
|
||||
if (isServicePackage(id, pkg)) return true
|
||||
if (isKnownApp(id, pkg)) return false
|
||||
return !!pkg && !!runtimeLanAddress(pkg)
|
||||
}
|
||||
|
||||
export function filterEntriesForTab(
|
||||
entries: Array<[string, PackageDataEntry]>,
|
||||
activeTab: 'apps' | 'services',
|
||||
activeTab: AppsTab,
|
||||
selectedCategory: string,
|
||||
): Array<[string, PackageDataEntry]> {
|
||||
return entries.filter(([id, pkg]) => {
|
||||
const isSvc = isServicePackage(id, pkg)
|
||||
if (activeTab === 'services' ? !isSvc : isSvc) return false
|
||||
if (isInternalToolingPackage(id, pkg)) return false
|
||||
const wantsWebsites = activeTab === 'websites' || activeTab === 'services'
|
||||
const isWebsite = isWebsitePackage(id, pkg)
|
||||
if (wantsWebsites ? !isWebsite : isWebsite) return false
|
||||
if (activeTab === 'apps' && selectedCategory !== 'all') {
|
||||
return getAppCategory(id, pkg) === selectedCategory
|
||||
}
|
||||
@@ -77,6 +107,7 @@ export const WEB_ONLY_APP_URLS: Record<string, string> = {
|
||||
'nwnn': 'https://nwnn.l484.com',
|
||||
'484-kitchen': 'https://484.kitchen',
|
||||
'call-the-operator': 'https://cta.tx1138.com',
|
||||
'arch-presentation': 'https://present.l484.com',
|
||||
'syntropy-institute': 'https://syntropy.institute',
|
||||
't-zero': 'https://teeminuszero.net',
|
||||
}
|
||||
@@ -102,6 +133,11 @@ export const WEB_ONLY_APPS: Record<string, PackageDataEntry> = {
|
||||
manifest: { id: 'call-the-operator', title: 'Call the Operator', version: '1.0.0', description: { short: 'Escape the Matrix — explore decentralized alternatives', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
|
||||
'static-files': { license: '', instructions: '', icon: '/assets/img/app-icons/call-the-operator.png' },
|
||||
},
|
||||
'arch-presentation': {
|
||||
state: 'running' as PackageState,
|
||||
manifest: { id: 'arch-presentation', title: 'Arch Presentation', version: '1.0.0', description: { short: 'Archipelago: The Future of Decentralized Infrastructure', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
|
||||
'static-files': { license: '', instructions: '', icon: '/assets/img/app-icons/arch-presentation.png' },
|
||||
},
|
||||
'syntropy-institute': {
|
||||
state: 'running' as PackageState,
|
||||
manifest: { id: 'syntropy-institute', title: 'Syntropy Institute', version: '1.0.0', description: { short: 'Medicine Reimagined — frequency analysis-therapy', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
|
||||
@@ -144,10 +180,18 @@ export function resolveAppIcon(id: string, pkg: PackageDataEntry, curatedIcon?:
|
||||
|
||||
export function canLaunch(pkg: PackageDataEntry): boolean {
|
||||
if (isWebOnlyApp(pkg.manifest.id)) return true
|
||||
const hasUI = pkg.manifest.interfaces?.main?.ui || pkg.installed?.['interface-addresses']?.main
|
||||
const hasRuntimeAddress = !!pkg.installed?.['interface-addresses']?.main?.['lan-address']
|
||||
const hasKnownLaunchUrl = typeof window !== 'undefined' && !!resolveAppUrl(pkg.manifest.id)
|
||||
const hasUI = pkg.manifest.interfaces?.main?.ui || hasRuntimeAddress || hasKnownLaunchUrl
|
||||
return !!hasUI && pkg.state === 'running' && pkg.health !== 'starting' && pkg.health !== 'unhealthy'
|
||||
}
|
||||
|
||||
export function resolveRuntimeLaunchUrl(pkg: PackageDataEntry): string {
|
||||
const addr = runtimeLanAddress(pkg)
|
||||
if (!addr || typeof window === 'undefined') return addr
|
||||
return addr.replace(/^http:\/\/(localhost|127\.0\.0\.1)(?=[:/]|$)/, `http://${window.location.hostname}`)
|
||||
}
|
||||
|
||||
export function getStatusClass(state: PackageState, health?: string | null, exitCode?: number | null): string {
|
||||
if (state === PackageState.Running && health === 'starting') return 'bg-yellow-500/20 text-yellow-200'
|
||||
if (state === PackageState.Running && health === 'unhealthy') return 'bg-orange-500/20 text-orange-200'
|
||||
@@ -181,7 +225,7 @@ export function getStatusLabel(state: PackageState, health?: string | null, exit
|
||||
if (state === PackageState.Updating) return 'updating...'
|
||||
if (state === PackageState.Running) return 'running'
|
||||
if (state === PackageState.Exited || state === PackageState.Stopped) {
|
||||
if (exitCode === 137) return 'killed (OOM)'
|
||||
if (exitCode === 137) return 'killed (SIGKILL)'
|
||||
if (exitCode != null && exitCode !== 0) return 'crashed'
|
||||
return 'stopped'
|
||||
}
|
||||
@@ -209,7 +253,7 @@ export function useCategoriesWithApps(
|
||||
allCategories: Ref<Array<{ id: string; name: string }>>,
|
||||
) {
|
||||
return computed(() => {
|
||||
const entries = Object.entries(packages.value).filter(([id, pkg]) => !isServicePackage(id, pkg))
|
||||
const entries = Object.entries(packages.value).filter(([id, pkg]) => !isWebsitePackage(id, pkg) && !isInternalToolingPackage(id, pkg))
|
||||
return allCategories.value.filter(cat => {
|
||||
if (cat.id === 'all') return true
|
||||
return entries.some(([id, pkg]) => getAppCategory(id, pkg) === cat.id)
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<RouterLink
|
||||
to="/dashboard/apps"
|
||||
class="mode-switcher-btn"
|
||||
:class="{ 'mode-switcher-btn-active': (route.path === '/dashboard/apps' || route.path.startsWith('/dashboard/apps/')) && route.query.tab !== 'services' }"
|
||||
:class="{ 'mode-switcher-btn-active': (route.path === '/dashboard/apps' || route.path.startsWith('/dashboard/apps/')) && route.query.tab !== 'services' && route.query.tab !== 'websites' }"
|
||||
@click.prevent="router.push({ path: '/dashboard/apps', query: {} })"
|
||||
>My Apps</RouterLink>
|
||||
<RouterLink
|
||||
@@ -19,10 +19,10 @@
|
||||
:class="{ 'mode-switcher-btn-active': route.path === '/dashboard/marketplace' || route.path.startsWith('/dashboard/marketplace/') || route.path === '/dashboard/discover' }"
|
||||
>App Store</RouterLink>
|
||||
<RouterLink
|
||||
to="/dashboard/apps?tab=services"
|
||||
to="/dashboard/apps?tab=websites"
|
||||
class="mode-switcher-btn"
|
||||
:class="{ 'mode-switcher-btn-active': route.query.tab === 'services' }"
|
||||
>Services</RouterLink>
|
||||
:class="{ 'mode-switcher-btn-active': route.query.tab === 'services' || route.query.tab === 'websites' }"
|
||||
>Websites</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -96,8 +96,8 @@ export function useRouteTransitions() {
|
||||
|
||||
// Mobile: Horizontal slide transitions between sub-tabs
|
||||
if (typeof window !== 'undefined' && window.innerWidth < 768) {
|
||||
const isServices = currentPath === '/dashboard/apps' && currentRoute.query.tab === 'services'
|
||||
const wasServices = previousTab === 'services'
|
||||
const isServices = currentPath === '/dashboard/apps' && (currentRoute.query.tab === 'services' || currentRoute.query.tab === 'websites')
|
||||
const wasServices = previousTab === 'services' || previousTab === 'websites'
|
||||
const currentAppsIdx = isServices ? 2
|
||||
: currentPath === '/dashboard/marketplace' ? 1
|
||||
: currentPath === '/dashboard/apps' ? 0 : -1
|
||||
|
||||
@@ -78,7 +78,7 @@ export function getCuratedAppList(): MarketplaceApp[] {
|
||||
return [
|
||||
{ id: 'bitcoin-knots', title: 'Bitcoin Knots', version: '28.1.0', description: 'Run a full Bitcoin node. Validate and relay blocks and transactions on the Bitcoin network.', icon: '/assets/img/app-icons/bitcoin-knots.webp', author: 'Bitcoin Knots', dockerImage: `${R}/bitcoin-knots:latest`, repoUrl: 'https://github.com/bitcoinknots/bitcoin' },
|
||||
{ id: 'bitcoin-core', title: 'Bitcoin Core', version: '28.4', description: 'Reference implementation of the Bitcoin protocol. Run a full node validating and relaying blocks on the Bitcoin network.', icon: '/assets/img/app-icons/bitcoin-core.svg', author: 'Bitcoin Core contributors', dockerImage: 'docker.io/bitcoin/bitcoin:28.4', repoUrl: 'https://github.com/bitcoin/bitcoin' },
|
||||
{ id: 'btcpay-server', title: 'BTCPay Server', version: '1.13.7', description: 'Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries or fees.', icon: '/assets/img/app-icons/btcpay-server.png', author: 'BTCPay Server Foundation', dockerImage: `${R}/btcpayserver:1.13.7`, repoUrl: 'https://github.com/btcpayserver/btcpayserver' },
|
||||
{ id: 'btcpay-server', title: 'BTCPay Server', version: '2.3.9', description: 'Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries or fees.', icon: '/assets/img/app-icons/btcpay-server.png', author: 'BTCPay Server Foundation', dockerImage: 'docker.io/btcpayserver/btcpayserver:2.3.9', repoUrl: 'https://github.com/btcpayserver/btcpayserver' },
|
||||
{ id: 'lnd', title: 'LND', version: '0.18.4', description: 'Lightning Network Daemon. Fast and cheap Bitcoin payments through the Lightning Network.', icon: '/assets/img/app-icons/lnd.svg', author: 'Lightning Labs', dockerImage: `${R}/lnd:v0.18.4-beta`, repoUrl: 'https://github.com/lightningnetwork/lnd' },
|
||||
{ id: 'mempool', title: 'Mempool Explorer', version: '3.0.0', description: 'Self-hosted Bitcoin blockchain and mempool visualizer. Monitor transactions without revealing your addresses to third parties.', icon: '/assets/img/app-icons/mempool.webp', author: 'Mempool', dockerImage: `${R}/mempool-frontend:v3.0.0`, repoUrl: 'https://github.com/mempool/mempool' },
|
||||
{ id: 'homeassistant', title: 'Home Assistant', version: '2024.1', description: 'Open-source home automation. Control smart home devices privately, on your own hardware.', icon: '/assets/img/app-icons/homeassistant.png', author: 'Home Assistant', dockerImage: `${R}/home-assistant:2024.1`, repoUrl: 'https://github.com/home-assistant/core' },
|
||||
|
||||
@@ -6,6 +6,8 @@ export interface ContainerConfig {
|
||||
ports?: string[]
|
||||
volumes?: string[]
|
||||
env?: string[]
|
||||
command?: string
|
||||
args?: string[]
|
||||
}
|
||||
|
||||
export type MarketplaceApp = Partial<MarketplaceAppInfo> & {
|
||||
|
||||
@@ -18,6 +18,13 @@ export interface MarketplaceApp {
|
||||
source?: string
|
||||
url?: string
|
||||
s9pkUrl?: string
|
||||
containerConfig?: {
|
||||
ports?: string[]
|
||||
volumes?: string[]
|
||||
env?: string[]
|
||||
command?: string
|
||||
args?: string[]
|
||||
}
|
||||
trustScore?: number
|
||||
trustTier?: string
|
||||
relayCount?: number
|
||||
@@ -141,11 +148,11 @@ export function getCuratedAppList(): MarketplaceApp[] {
|
||||
{
|
||||
id: 'btcpay-server',
|
||||
title: 'BTCPay Server',
|
||||
version: '1.13.7',
|
||||
version: '2.3.9',
|
||||
description: 'Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries or fees.',
|
||||
icon: '/assets/img/app-icons/btcpay-server.png',
|
||||
author: 'BTCPay Server Foundation',
|
||||
dockerImage: `${REGISTRY}/btcpayserver:1.13.7`,
|
||||
dockerImage: 'docker.io/btcpayserver/btcpayserver:2.3.9',
|
||||
manifestUrl: undefined,
|
||||
repoUrl: 'https://github.com/btcpayserver/btcpayserver'
|
||||
},
|
||||
|
||||
@@ -180,6 +180,256 @@ init()
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
|
||||
<!-- v1.7.67-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.67-alpha</span>
|
||||
<span class="text-xs text-white/40">May 18, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Home status cards are calmer and more honest now. System, VPN, Bitcoin, and FIPS values keep their last known good state while route changes or short RPC failures are in flight, so the dashboard no longer flashes false "not configured" or "not running" states during normal refreshes.</p>
|
||||
<p>Home, Web5 Monitoring, and the full Monitoring page now agree on the headline CPU, memory, disk, uptime, and load numbers. The UI uses one live system-stat snapshot for the visible cards while keeping the Monitoring page's historical store for charts, alerts, and container history.</p>
|
||||
<p>The missing What's New history is filled in through this release, including every curated entry from v1.7.44-alpha through v1.7.66-alpha.</p>
|
||||
<p>Bitcoin lifecycle specs are aligned again across Rust, first boot, and reconcile. Bitcoin Core/Knots get the intended memory headroom on normal hosts, and pruned Knots uses a larger dbcache when the node has enough RAM, improving IBD throughput without raising pressure on low-memory machines.</p>
|
||||
<p>ElectrumX/electrs lifecycle specs now use the same memory policy everywhere, reducing drift between fresh installs, app lifecycle actions, and reconciliation.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.66-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.66-alpha</span>
|
||||
<span class="text-xs text-white/40">May 18, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Nginx Proxy Manager stale-port repair now catches stopped or Created Podman records that still remember old port mappings. That means a stuck record can be removed and recreated before it blocks the current NPM ports.</p>
|
||||
<p>Live recovery on the field node preserved the existing Nginx Proxy Manager data directory while recreating only the stale container metadata with the current 8081, 8084, and 8444 host ports.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.65-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.65-alpha</span>
|
||||
<span class="text-xs text-white/40">May 18, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Orchestrator-backed app starts now run the same pre-start repair path as the legacy Podman start flow. Nginx Proxy Manager can clean up stale port metadata before the orchestrator tries to bring it online.</p>
|
||||
<p>Diagnostics confirmed host nginx was healthy while Nginx Proxy Manager itself had no listeners on its expected ports, narrowing the outage to NPM container lifecycle repair instead of the system proxy.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.64-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.64-alpha</span>
|
||||
<span class="text-xs text-white/40">May 18, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Authenticated update applies are no longer throttled so aggressively during troubleshooting. The System Update page now allows legitimate retry flows without immediately running into 429 Too Many Requests.</p>
|
||||
<p>The release still includes the corrected backend rebuild protection, so OTA artifacts are built from the fresh Rust binary instead of an older compiled version.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.63-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.63-alpha</span>
|
||||
<span class="text-xs text-white/40">May 18, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>The release script now rebuilds the backend after bumping the version and before hashing artifacts. OTA manifests no longer point at a stale backend binary.</p>
|
||||
<p>This corrected the previous stale-artifact issue and carries the Nginx Proxy Manager stale-port repair in a backend binary that nodes can actually install and run.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.62-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.62-alpha</span>
|
||||
<span class="text-xs text-white/40">May 18, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Nginx Proxy Manager start and restart now repair stale Podman containers that still publish the admin UI on host port 81, which conflicts with host nginx on updated nodes.</p>
|
||||
<p>The repair recreates only NPM container metadata while preserving its persistent data and using the current 8081, 8084, and 8444 host mappings.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.61-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.61-alpha</span>
|
||||
<span class="text-xs text-white/40">May 18, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Multi-container stack installs stay in Installing for up to 20 minutes while dependency containers are being pulled and prepared. BTCPay no longer appears to vanish after two minutes while Postgres and NBXplorer are still being created.</p>
|
||||
<p>Lifecycle stale-state recovery remains short for start, stop, restart, update, and removal actions, so genuinely wedged operations still clear quickly.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.60-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.60-alpha</span>
|
||||
<span class="text-xs text-white/40">May 18, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Meshtastic serial detection now rejects malformed handshakes and skips known non-mesh serial devices such as Sierra Wireless LTE modems and Zooz/Z-Wave sticks.</p>
|
||||
<p>Meshtastic config sync sends the correct protobuf wire type, allowing node-info and contact ingestion to work reliably. The mesh udev rule also stops claiming every ttyACM device and now targets known mesh adapters/vendors.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.59-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.59-alpha</span>
|
||||
<span class="text-xs text-white/40">May 17, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Mobile app launching now keeps known container apps inside Archipelago's app-session flow instead of forcing desktop-only new-tab behavior.</p>
|
||||
<p>App sessions on mobile respect the status-bar safe area, while the fullscreen backdrop remains edge-to-edge. The Apps page also gained a compact sideload button and modal for trusted Docker images.</p>
|
||||
<p>Sideloaded app title and description metadata now persist through backend app config, and Meshtastic contact discovery retries config sync when the radio contact cache is empty.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.58-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.58-alpha</span>
|
||||
<span class="text-xs text-white/40">May 17, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Mesh networking now supports Meshtastic radios over the serial API alongside existing MeshCore Companion USB radios. The shared listener probes preferred and auto-detected serial paths for both firmware families.</p>
|
||||
<p>Meshtastic text packets are translated into Archipelago's existing mesh frame pipeline, and Meshtastic node information appears as normal mesh contacts using stable synthetic public keys.</p>
|
||||
<p>Frontend OTA behavior improved: hashed assets no longer fall back to index.html, the HTML shell revalidates on every load, and runtime promotion installs the bundled nginx config on update.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.57-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.57-alpha</span>
|
||||
<span class="text-xs text-white/40">May 17, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Nginx Proxy Manager now avoids privileged rootless Podman host port 81, preferring 8081 for its admin UI while host nginx keeps a compatibility proxy on :81 for stale launch buttons.</p>
|
||||
<p>App installs allocate ports by checking live host bind availability, falling back to a free high port when preferred ports are occupied. Portainer-created launchable containers now appear in a Websites tab through their discovered host ports.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.56-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.56-alpha</span>
|
||||
<span class="text-xs text-white/40">May 15, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Fresh installs include the full Wi-Fi userspace stack and grant the Archipelago service user NetworkManager PolicyKit access, so Intel Wi-Fi scanning and connection changes work from the web UI.</p>
|
||||
<p>Container health and reconciliation are more honest and resilient: stale alerts clear, Stopping containers can be recreated, health states come from Podman, and drifted Quadlet settings trigger proper restarts.</p>
|
||||
<p>Bitcoin Knots and ElectrumX get more CPU and memory headroom, LND helpers tolerate container-owned files better, and the screensaver stays out of media-heavy app sessions.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.55-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.55-alpha</span>
|
||||
<span class="text-xs text-white/40">May 13, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Container reconcile can force-recreate Podman records stuck in Stopping while preserving bind-mounted app data, recovering wedged containers automatically.</p>
|
||||
<p>Lifecycle audits on the hardened container layer passed on the validation node, with direct app probes returning healthy responses.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.54-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.54-alpha</span>
|
||||
<span class="text-xs text-white/40">May 6, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Existing installs now self-repair nginx backend proxy locations for Bitcoin status and app catalog calls, including hosts where the active config is a copied file rather than a symlink.</p>
|
||||
<p>LND UI is consistently served on port 18083 across first boot, Tor config, Quadlet reconciliation, OTA runtime payloads, and ISO scripts. OTA frontend tarballs also carry a cleaner runtime payload so startup promotion does not reintroduce stale host assets.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.53-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.53-alpha</span>
|
||||
<span class="text-xs text-white/40">May 5, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Bitcoin Knots/Core config generation no longer duplicates RPC bind and port settings between bitcoin.conf and container command arguments, fixing startup failures from RPC endpoint binding conflicts.</p>
|
||||
<p>Legacy Bitcoin healthchecks no longer depend on bitcoin-cli being present in current images. Update checks now prefer manifest OTA releases over stale git remotes unless git updates are explicitly enabled.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.52-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.52-alpha</span>
|
||||
<span class="text-xs text-white/40">May 5, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Tailscale now launches its local installed web UI on port 8240 and starts tailscaled before tailscale web, fixing unreachable installs after container creation.</p>
|
||||
<p>Grafana lifecycle actions repair missing rootless host listeners on port 3000, and Debian 13 install paths pull security updates from trixie-security during image/install creation.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.49-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.49-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 30, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Bitcoin Knots/Core UI now reports connection, reconnecting, syncing, and error states from a backend status bridge instead of showing stale connection failures while the node warms up.</p>
|
||||
<p>ElectrumX exposes indexed height, local Bitcoin height, known headers, status, and progress source, making long initial indexing states readable. Bitcoin Core and Bitcoin Knots are now mutually exclusive variants with corrected install conflict handling.</p>
|
||||
<p>IndeeHub launches only on its direct web UI port, and BTCPay/NBXplorer Postgres environment formatting was fixed to avoid malformed connection strings.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.48-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.48-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 29, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>archipelago.service now creates /run/containers before startup, fixing systemd mount-namespace failures on nodes where that runtime directory did not already exist.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.47-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.47-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 29, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Bitcoin Knots/Core sync is significantly faster: containers now use every available core for script verification and have 8 GB of memory so the 4 GB UTXO cache has headroom.</p>
|
||||
<p>ElectrumX initial indexing is faster too, with CPU caps removed, 4 GB of container memory, and a 3 GB internal cache.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.46-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.46-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 29, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Health monitoring no longer pages auto-restart failures for orphaned containers left behind after Bitcoin variant switches.</p>
|
||||
<p>Apps no longer disappear from My Apps when an install fails, and multi-image stack pull progress now advances during the download phase instead of sticking at 20%.</p>
|
||||
<p>Several docker.io images were mirrored into Archipelago registries, reducing first-boot install dependency on Docker Hub.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.45-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.45-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 29, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Bitcoin RPC auth is durable across container restart, image update, and reboot. The dashboard no longer fails because registry-pulled images shipped stale baked-in credentials.</p>
|
||||
<p>Multi-container apps show real install progress, app cards stay visible while containers are being created, IndeedHub installs cleanly on fresh nodes, and Tailscale install no longer fails from a malformed command.</p>
|
||||
<p>The installer now allocates swap on the encrypted data partition, capped at 8 GB, so image builds and memory spikes are less likely to OOM the system.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.44-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.44-alpha</span>
|
||||
<span class="text-xs text-white/40">Apr 28, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Container orchestration migration and release hardening continued, including OTA synchronization for rebuilt UI containers and aligned LND UI port handling across runtime specs.</p>
|
||||
<p>Release packaging moved toward tarball-only artifacts with archived ISO build recipes, keeping update payloads focused on the files existing nodes need.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.43-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
|
||||
@@ -71,22 +71,18 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { useHomeStatusStore } from '@/stores/homeStatus'
|
||||
|
||||
const cpuPercent = ref(0)
|
||||
const memUsed = ref(0)
|
||||
const memTotal = ref(0)
|
||||
const diskUsed = ref(0)
|
||||
const diskTotal = ref(0)
|
||||
const uptimeSecs = ref(0)
|
||||
const homeStatus = useHomeStatusStore()
|
||||
|
||||
const memPercent = computed(() => memTotal.value > 0 ? (memUsed.value / memTotal.value) * 100 : 0)
|
||||
const diskPercent = computed(() => diskTotal.value > 0 ? (diskUsed.value / diskTotal.value) * 100 : 0)
|
||||
const cpuPercent = computed(() => homeStatus.stats.cpuPercent)
|
||||
const memPercent = computed(() => homeStatus.stats.memPercent)
|
||||
const diskPercent = computed(() => homeStatus.stats.diskPercent)
|
||||
|
||||
const uptimeDisplay = computed(() => {
|
||||
const s = uptimeSecs.value
|
||||
const s = homeStatus.stats.uptimeSecs
|
||||
if (s === 0) return '--'
|
||||
const days = Math.floor(s / 86400)
|
||||
const hours = Math.floor((s % 86400) / 3600)
|
||||
@@ -102,22 +98,7 @@ function barColor(pct: number): string {
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const res = await rpcClient.call<{
|
||||
cpu_usage_percent: number
|
||||
mem_used_bytes: number
|
||||
mem_total_bytes: number
|
||||
disk_used_bytes: number
|
||||
disk_total_bytes: number
|
||||
uptime_secs: number
|
||||
}>({ method: 'system.stats' })
|
||||
cpuPercent.value = res.cpu_usage_percent
|
||||
memUsed.value = res.mem_used_bytes
|
||||
memTotal.value = res.mem_total_bytes
|
||||
diskUsed.value = res.disk_used_bytes
|
||||
diskTotal.value = res.disk_total_bytes
|
||||
uptimeSecs.value = res.uptime_secs
|
||||
} catch { /* unavailable */ }
|
||||
await homeStatus.refreshSystemStats()
|
||||
}
|
||||
|
||||
let refreshInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
@@ -38,6 +38,7 @@ export default defineConfig({
|
||||
},
|
||||
workbox: {
|
||||
navigateFallbackDenylist: [/^\/app\//, /^\/rpc\//, /^\/ws/, /^\/aiui\//],
|
||||
cleanupOutdatedCaches: true,
|
||||
globPatterns: ['**/*.{js,css,html,ico,png,svg,jpg,jpeg,mp4,webp}'],
|
||||
globIgnores: [
|
||||
'**/*-backup-*.mp4',
|
||||
|
||||
+23
-18
@@ -1,29 +1,34 @@
|
||||
{
|
||||
"version": "1.7.54-alpha",
|
||||
"release_date": "2026-05-06",
|
||||
"version": "1.7.68-alpha",
|
||||
"release_date": "2026-05-19",
|
||||
"changelog": [
|
||||
"Existing installs now self-repair nginx backend proxy locations for `/bitcoin-status` and `/api/app-catalog`, including hosts where `sites-enabled/archipelago` is a copied active file instead of a symlink.",
|
||||
"LND UI is consistently served on `18083` across first boot, Tor config, companion Quadlet reconciliation, OTA runtime payloads, and ISO scripts; stale companion units/images are rewritten instead of only checking service active state.",
|
||||
"OTA frontend tarballs now carry a clean runtime payload with updated scripts, docker UI sources, and canonical nginx config, preventing startup promotion from reintroducing stale host assets.",
|
||||
"Release ISO builds now support the primary HTTP app registry when bundling core images, so unbundled media includes File Browser/Cloud support instead of requiring a post-install Marketplace download.",
|
||||
"`.116` was live-updated with the new backend and runtime scripts; focused non-destructive lifecycle audit passes for Bitcoin Knots, LND, BTCPay, Mempool, and Grafana."
|
||||
"BTCPay Server now ships on the official `docker.io/btcpayserver/btcpayserver:2.3.9` image, fixing the plugin catalog crash caused by newer plugin dependency version metadata while preserving existing datadirs and Postgres databases.",
|
||||
"BTCPay release and first-boot health checks no longer depend on `curl` inside the container; they use a bash TCP probe that works with the official image out of the box.",
|
||||
"Host nginx now serves Nginx Proxy Manager HTTP-01 challenge files before the Archipelago SPA fallback and is marked as the default HTTP/HTTPS virtual host, so public proxy hosts can issue certificates without hijacking local API traffic.",
|
||||
"Nginx Proxy Manager first-boot, runtime repair, and container-doctor paths now pre-create the ACME webroot, keep bind mounts owned by the rootless Archipelago user, and sync issued public proxy hosts into host nginx vhosts.",
|
||||
"The Nginx Proxy Manager host-nginx sync now skips proxy hosts with missing certificate files and rolls back the generated nginx include if validation fails, preventing a bad certificate path from poisoning later nginx reloads.",
|
||||
"App session close buttons now return to the previous dashboard screen when possible and otherwise fall back to My Apps, avoiding the 404 page after closing an app launched from an invalid or stale history entry.",
|
||||
"System Update confirmation and mirror modals now teleport to the document body with a full-screen overlay, so they cover the whole app instead of only the right-hand dashboard panel.",
|
||||
"Mobile app launches stay inside Archipelago's app-session webview and hide desktop-only new-tab launch affordances, including apps such as Home Assistant that previously looked like they would leave the mobile shell.",
|
||||
"Live recovery on `100.70.96.88` upgraded only the `btcpay-server` container to `docker.io/btcpayserver/btcpayserver:2.3.9`, preserved the existing datadir and Postgres database, and confirmed the container is healthy after a pre-upgrade backup.",
|
||||
"Public validation confirmed `spay.tx1138.com`/`www` redirect to BTCPay login over HTTPS and `sapien.tx1138.com`/`www` serve the L484 page over HTTPS using the issued Let's Encrypt certificates."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"name": "archipelago",
|
||||
"current_version": "1.7.54-alpha",
|
||||
"new_version": "1.7.54-alpha",
|
||||
"download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.54-alpha/archipelago",
|
||||
"sha256": "77e3a236a6196a5ab9ec2411b150490e78ffc95ea6ab8eb34ab29b3df53cd632",
|
||||
"size_bytes": 42600560
|
||||
"current_version": "1.7.68-alpha",
|
||||
"new_version": "1.7.68-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.68-alpha/archipelago",
|
||||
"sha256": "48b24d5fae5a65f423095ce6166823dab1a3b539581caa3d49a3dac77bf38e6d",
|
||||
"size_bytes": 42957000
|
||||
},
|
||||
{
|
||||
"name": "archipelago-frontend-1.7.54-alpha.tar.gz",
|
||||
"current_version": "1.7.54-alpha",
|
||||
"new_version": "1.7.54-alpha",
|
||||
"download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.54-alpha/archipelago-frontend-1.7.54-alpha.tar.gz",
|
||||
"sha256": "a010ac43a2dd02f528202cb2f7b99b61ceab80adc6827877594e41df4ea951fb",
|
||||
"size_bytes": 166461921
|
||||
"name": "archipelago-frontend-1.7.68-alpha.tar.gz",
|
||||
"current_version": "1.7.68-alpha",
|
||||
"new_version": "1.7.68-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.68-alpha/archipelago-frontend-1.7.68-alpha.tar.gz",
|
||||
"sha256": "769d77ad98931cf8200a966ce774aac4341f0b59c17e1115bc56ef1c361bb7ec",
|
||||
"size_bytes": 166477407
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"version": "1.7.68-alpha",
|
||||
"release_date": "2026-05-19",
|
||||
"changelog": [
|
||||
"BTCPay Server now ships on the official `docker.io/btcpayserver/btcpayserver:2.3.9` image, fixing the plugin catalog crash caused by newer plugin dependency version metadata while preserving existing datadirs and Postgres databases.",
|
||||
"BTCPay release and first-boot health checks no longer depend on `curl` inside the container; they use a bash TCP probe that works with the official image out of the box.",
|
||||
"Host nginx now serves Nginx Proxy Manager HTTP-01 challenge files before the Archipelago SPA fallback and is marked as the default HTTP/HTTPS virtual host, so public proxy hosts can issue certificates without hijacking local API traffic.",
|
||||
"Nginx Proxy Manager first-boot, runtime repair, and container-doctor paths now pre-create the ACME webroot, keep bind mounts owned by the rootless Archipelago user, and sync issued public proxy hosts into host nginx vhosts.",
|
||||
"The Nginx Proxy Manager host-nginx sync now skips proxy hosts with missing certificate files and rolls back the generated nginx include if validation fails, preventing a bad certificate path from poisoning later nginx reloads.",
|
||||
"App session close buttons now return to the previous dashboard screen when possible and otherwise fall back to My Apps, avoiding the 404 page after closing an app launched from an invalid or stale history entry.",
|
||||
"System Update confirmation and mirror modals now teleport to the document body with a full-screen overlay, so they cover the whole app instead of only the right-hand dashboard panel.",
|
||||
"Mobile app launches stay inside Archipelago's app-session webview and hide desktop-only new-tab launch affordances, including apps such as Home Assistant that previously looked like they would leave the mobile shell.",
|
||||
"Live recovery on `100.70.96.88` upgraded only the `btcpay-server` container to `docker.io/btcpayserver/btcpayserver:2.3.9`, preserved the existing datadir and Postgres database, and confirmed the container is healthy after a pre-upgrade backup.",
|
||||
"Public validation confirmed `spay.tx1138.com`/`www` redirect to BTCPay login over HTTPS and `sapien.tx1138.com`/`www` serve the L484 page over HTTPS using the issued Let's Encrypt certificates."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"name": "archipelago",
|
||||
"current_version": "1.7.68-alpha",
|
||||
"new_version": "1.7.68-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.68-alpha/archipelago",
|
||||
"sha256": "48b24d5fae5a65f423095ce6166823dab1a3b539581caa3d49a3dac77bf38e6d",
|
||||
"size_bytes": 42957000
|
||||
},
|
||||
{
|
||||
"name": "archipelago-frontend-1.7.68-alpha.tar.gz",
|
||||
"current_version": "1.7.68-alpha",
|
||||
"new_version": "1.7.68-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.68-alpha/archipelago-frontend-1.7.68-alpha.tar.gz",
|
||||
"sha256": "769d77ad98931cf8200a966ce774aac4341f0b59c17e1115bc56ef1c361bb7ec",
|
||||
"size_bytes": 166477407
|
||||
}
|
||||
]
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user