Compare commits
157
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd8a6cd9d7 | ||
|
|
ab96c97cb9 | ||
|
|
881779005a | ||
|
|
20bc9f250c | ||
|
|
87be717f40 | ||
|
|
75d147b69f | ||
|
|
edaece8716 | ||
|
|
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 | ||
|
|
c0751e2551 | ||
|
|
1a0d8a432c | ||
|
|
745cb1c626 | ||
|
|
10fbb8f87c | ||
|
|
aad0ba5234 | ||
|
|
281e65e697 | ||
|
|
384f12de7a | ||
|
|
bd96c0475d | ||
|
|
9a89a000d4 | ||
|
|
97ce23d773 | ||
|
|
65576bd755 | ||
|
|
5b2e02bd43 | ||
|
|
9becafafd3 | ||
|
|
5074572373 | ||
|
|
ec1dce93a9 | ||
|
|
b9eb6eb18a | ||
|
|
c55a4f4e86 | ||
|
|
01f416ae5d | ||
|
|
f80daff8ba | ||
|
|
1103c2c710 | ||
|
|
1b6c500657 | ||
|
|
5be2febe13 | ||
|
|
6bbe1b96cf | ||
|
|
8f13298805 | ||
|
|
ba2eece9aa | ||
|
|
6603227874 | ||
|
|
27ff1d5b52 | ||
|
|
f9e34fd0c6 | ||
|
|
23c4e7441f | ||
|
|
2bf8181110 | ||
|
|
0684491072 | ||
|
|
05e6c2e738 | ||
|
|
be9f9528c3 | ||
|
|
7ab788d178 | ||
|
|
f507b847ef | ||
|
|
8a2899ab4a | ||
|
|
992b673b20 | ||
|
|
4ec6ca98c1 | ||
|
|
dffa7e99bb | ||
|
|
8f83b37d51 | ||
|
|
4d05705315 | ||
|
|
05b41f8946 | ||
|
|
ed73e4709b | ||
|
|
0bd4e49a8c | ||
|
|
310c709aba | ||
|
|
dbf755e908 | ||
|
|
2572688468 | ||
|
|
4bf35f95e6 | ||
|
|
4edc420459 | ||
|
|
7af048cc1a | ||
|
|
2843cc1e84 | ||
|
|
c5ea41d0cb | ||
|
|
9d42645aa3 | ||
|
|
f6efe2f356 | ||
|
|
c4efb30382 | ||
|
|
cd6f8bad70 | ||
|
|
9f3d66e24e | ||
|
|
a272a79706 | ||
|
|
694e5b0a9d | ||
|
|
0f1ad47aec | ||
|
|
06dcdafda4 | ||
|
|
92612ddc70 | ||
|
|
353825b66c | ||
|
|
12f93cc15e | ||
|
|
4faac9cb74 | ||
|
|
b62b731db0 | ||
|
|
6c8cb50679 | ||
|
|
28e38a36a9 | ||
|
|
d9d5fa65e5 | ||
|
|
980c1b25f4 | ||
|
|
7e62ea07f7 | ||
|
|
576ff1a6de | ||
|
|
49b98e0271 | ||
|
|
702b5d64d3 | ||
|
|
1ad889608f | ||
|
|
0ea4f96de9 | ||
|
|
a8158b1ef5 | ||
|
|
cd69c3b2f6 | ||
|
|
39dd1d9dcc | ||
|
|
5baced5f5b | ||
|
|
cad63bdd76 | ||
|
|
bb2e3fab42 | ||
|
|
6a5fab709a | ||
|
|
2a2f10608b | ||
|
|
7257f72f4a | ||
|
|
30b31b3670 | ||
|
|
28819d1197 | ||
|
|
80765c5755 | ||
|
|
8acf7d1112 | ||
|
|
c396be8068 | ||
|
|
236a2dee85 | ||
|
|
758d3e47d8 | ||
|
|
3e9c192b48 | ||
|
|
ba8bd0bb86 | ||
|
|
6a0809d386 | ||
|
|
81c1613040 | ||
|
|
89199bb03b | ||
|
|
ca299e70e8 | ||
|
|
40a6eaca72 | ||
|
|
e103925a4e | ||
|
|
56af57a6f8 | ||
|
|
919055f3f1 | ||
|
|
0ac673deb4 | ||
|
|
d1bcf271f9 | ||
|
|
85417de952 | ||
|
|
b8d084368e | ||
|
|
36a6101026 | ||
|
|
cfc98c600e | ||
|
|
e206e1fc94 | ||
|
|
9cf1177b73 | ||
|
|
a7048f6d8e | ||
|
|
06feb85aa5 |
@@ -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
|
||||
|
||||
+16
@@ -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/
|
||||
|
||||
@@ -73,3 +78,14 @@ loop/loop.log.bak
|
||||
# Separate repos nested in tree
|
||||
web/
|
||||
|
||||
._*
|
||||
|
||||
# Resilience harness reports (generated, contains session cookies)
|
||||
scripts/resilience/reports/
|
||||
|
||||
# Codex / pnpm / python caches / editor backups
|
||||
.codex
|
||||
.pnpm-store/
|
||||
**/__pycache__/
|
||||
*.bak
|
||||
.claude/scheduled_tasks.lock
|
||||
|
||||
+210
@@ -1,5 +1,215 @@
|
||||
# Changelog
|
||||
|
||||
## v1.7.70-alpha (2026-05-19)
|
||||
|
||||
- NetBird is being corrected from the peer/client daemon image to the self-hosted NetBird control-plane stack with a launchable dashboard on port `8087`, a combined management/signal/relay server on `8086`, and STUN on UDP `3478`.
|
||||
- App sessions now always launch local apps through direct host ports and carry an explicit dashboard return target, so closing an iframe returns to the launching dashboard screen instead of falling through to browser history or a 404.
|
||||
- Mobile app launches ignore stale desktop panel state and route into the full app-session webview consistently.
|
||||
- The desktop sidebar now pins the logo/version at the top and controller/online/mode controls at the bottom, with only the navigation section scrolling on shorter screens.
|
||||
- Validation passed with catalog JSON checks, `scripts/image-versions.sh` syntax check, `npm run type-check`, `cargo fmt --all --check --manifest-path core/Cargo.toml`, and `cargo check -p archipelago --manifest-path core/Cargo.toml`.
|
||||
|
||||
## v1.7.69-alpha (2026-05-19)
|
||||
|
||||
- App installs now allow up to 10 minutes for the initial `package.install` RPC to return, matching slow container image pulls and preventing apps from disappearing from My Apps while the backend is still pulling or retrying mirrors.
|
||||
- Live diagnostics on `100.70.96.88` confirmed the Gitea install did not fail; the primary registry pull timed out after 300 seconds, the fallback mirror succeeded, and Gitea came up healthy on `3001` while the frontend had already timed out at 15 seconds.
|
||||
- Gitea and other Docker-image app installs now stay visible during slow registry pulls instead of being marked as failed by the browser before backend install progress can complete.
|
||||
- Gitea is now categorized as a known Data app in My Apps, so a running Gitea container appears with installed apps instead of being filtered into the Websites/Services split.
|
||||
- NetBird `0.71.2` is now available in the app catalog and fallback marketplace data as a recommended networking app using the official `docker.io/netbirdio/netbird:0.71.2` image.
|
||||
- NetBird installs get persistent state under `/var/lib/archipelago/netbird`, `NET_ADMIN`/`NET_RAW`, `/dev/net/tun`, `slirp4netns`, image-version pinning, backend metadata, and health checks through `netbird status`.
|
||||
- The Archipelago terminal now includes `nano` on new disk installs and ISO builds, and self-update installs it on existing nodes if it is missing.
|
||||
- Validation passed with catalog JSON checks, shell syntax checks, `npm run type-check`, `cargo fmt --all --check --manifest-path core/Cargo.toml`, and `cargo check -p archipelago --manifest-path core/Cargo.toml`.
|
||||
|
||||
## 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.
|
||||
- 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.
|
||||
|
||||
## v1.7.53-alpha (2026-05-05)
|
||||
|
||||
- Bitcoin Knots/Core config generation no longer duplicates RPC bind and port settings between `bitcoin.conf` and container command args, fixing `Unable to bind all endpoints for RPC server` startup failures.
|
||||
- Legacy Bitcoin container healthchecks no longer depend on `bitcoin-cli`, which is absent from current Knots images and can wedge Podman healthcheck runners.
|
||||
- Update checks now prefer manifest OTA releases over stale git remotes unless `ARCHIPELAGO_GIT_UPDATES` is explicitly enabled, so installed nodes can see published releases from the VPS mirror.
|
||||
|
||||
## v1.7.52-alpha (2026-05-05)
|
||||
|
||||
- Tailscale now launches the local installed web UI on port `8240` and starts `tailscaled` before `tailscale web`, fixing unreachable installs after container creation.
|
||||
- Grafana install/start/restart now repairs missing rootless host listeners on port `3000`, matching the existing SearXNG, Uptime Kuma, and Gitea recovery path.
|
||||
- Debian 13/Trixie ISO and disk-install paths now force security updates from `trixie-security` during image/install creation so rebuilt release media includes patched base packages.
|
||||
- Broad `.198` lifecycle audit passes with the current qualified app set; known absent blockers remain `electrumx`, `photoprism`, `dwn`, and `ollama`.
|
||||
|
||||
## v1.7.49-alpha (2026-04-30)
|
||||
|
||||
- Bitcoin Knots/Core UI now reports connection, reconnecting, syncing, and error states from a backend status bridge instead of showing a stale "Unable to connect" message while the node is warming up.
|
||||
- ElectrumX UI now exposes indexed height, local Bitcoin height, known headers, status, and progress source so indexing/waiting states are readable during long initial sync.
|
||||
- Added container doctor timer and smoke/lifecycle test coverage for Bitcoin Knots/Core, ElectrumX, Mempool, BTCPay/NBXplorer, and UI surface availability.
|
||||
- Bitcoin Core and Bitcoin Knots are mutually exclusive variants, with a real Bitcoin Core manifest and corrected install conflict handling.
|
||||
- IndeeHub now launches only on direct web UI port `7778`; the broken `/app/indeedhub/` path proxy was removed, and port `7777` remains the Nostr relay.
|
||||
- BTCPay/NBXplorer Postgres environment formatting fixed so installs do not carry malformed connection strings.
|
||||
|
||||
## v1.7.48-alpha (2026-04-29)
|
||||
|
||||
- archipelago.service no longer fails to start with "Failed to set up mount namespacing: /run/containers: No such file or directory" on nodes where /run/containers wasn't pre-created. ExecStartPre now creates it. Existing nodes need a one-time `systemctl edit archipelago` to add the mkdir; ISO installs from this version forward have the fix baked in.
|
||||
|
||||
## v1.7.47-alpha (2026-04-29)
|
||||
|
||||
- Bitcoin Knots/Core sync is now significantly faster. The container now uses every available core for script verification (was capped at 2) and has 8GB of memory instead of 4GB so its 4GB UTXO cache has headroom for the mempool and peer connections. Existing nodes pick up the new limits on next install/update; freshly-installed nodes start at full speed.
|
||||
- ElectrumX initial indexing is faster too. Its 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)
|
||||
|
||||
- Health monitor no longer pages "Auto-restart failed" for orphaned containers. After a variant switch (bitcoin-core ↔ bitcoin-knots) the previous variant's container could survive uninstall and the health monitor would try restarting it forever. Now skipped silently with a debug log.
|
||||
- Apps no longer disappear from My Apps when an install fails. The card stays visible with state=Stopped so the user can retry or uninstall, with the failure reason surfaced via the new install_progress.message field.
|
||||
- "Downloading…" progress now actually advances during multi-image stack pulls. Was sticking at 20% until all pulls finished; now interpolates 20%→70% based on which image of N has landed.
|
||||
- Pulled four docker.io images (bitcoin, gitea, nextcloud, valkey) into the lfg2025 registries on OVH and tx1138. Removes a docker.io dependency from first-boot installs.
|
||||
- Resilience harness improvements: install-fail entries no longer vanish, install/uninstall/probe cells are timing-tolerant (60s retry on ui_probe and auth_probe), dep snapshots no longer leak companion containers into the dependent app's "new containers" set.
|
||||
|
||||
## v1.7.45-alpha (2026-04-29)
|
||||
|
||||
- Bitcoin RPC auth is durable. The dashboard reliably connects across container restart, image update, and reboot. Was failing on registry-pulled images that shipped a stale baked-in password.
|
||||
- Multi-container apps show real install progress. IndeedHub (7), BTCPay (4), Mempool (3), Immich (3) — bar advances through Preparing → Pulling → Creating → Done instead of sitting at 0% until the very end.
|
||||
- Apps no longer disappear from the dashboard mid-install. The container scanner now respects in-flight installs and updates instead of evicting an entry while its containers are still being created.
|
||||
- IndeedHub installs cleanly on a fresh node. Five missing environment variables fixed; Nostr sign-in works on first install.
|
||||
- Tailscale install no longer fails with "executable not found". Container command was a malformed shell string; now a proper command array.
|
||||
- Removed three catalog entries that hung installs for ten minutes (dwn, endurain, ollama — no source images in our registries). Restored Nextcloud, sourced from docker.io.
|
||||
- Bitcoin Core update path uses the correct image name (was pulling from a non-existent path).
|
||||
- New ISO installs now allocate swap (sized to RAM, capped at 8GB, on the encrypted data partition). Without swap, container image builds and memory spikes were hitting OOM under load.
|
||||
|
||||
## v1.7.44-alpha (2026-04-28)
|
||||
|
||||
43de3b73 feat(orchestrator): complete container migration and release hardening
|
||||
ce39430b feat(self-update): sync and rebuild UI containers on OTA
|
||||
72dec5aa fix(lnd-ui): align container port across all specs
|
||||
83aacdf2 chore(release): archive ISO build recipes, tarball-only releases
|
||||
|
||||
|
||||
All notable changes to Archipelago will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
|
||||
@@ -101,14 +101,20 @@ npm run build # Production build → web/dist/neode-ui/
|
||||
./scripts/deploy-to-target.sh --both # Deploy to both LAN servers
|
||||
```
|
||||
|
||||
### Build ISO
|
||||
### Release (tarball-only)
|
||||
|
||||
Releases ship as a backend binary and a frontend tarball referenced by
|
||||
`releases/manifest.json`. Nodes OTA-update via `scripts/self-update.sh`.
|
||||
|
||||
```bash
|
||||
ssh archipelago@<server>
|
||||
cd ~/archy/image-recipe
|
||||
sudo ./build-auto-installer-iso.sh
|
||||
./scripts/create-release.sh 1.2.3
|
||||
git push gitea-local main --tags
|
||||
git push gitea-vps2 main --tags
|
||||
```
|
||||
|
||||
ISO builds are archived under `image-recipe/_archived/` and not part of the
|
||||
release deliverable.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
|
||||
+250
-147
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"version": 2,
|
||||
"updated": "2026-04-12T00:00:00Z",
|
||||
"registry": "git.tx1138.com/lfg2025",
|
||||
"updated": "2026-04-22T00:00:00Z",
|
||||
"registry": "146.59.87.168:3000/lfg2025",
|
||||
"featured": {
|
||||
"id": "indeedhub",
|
||||
"banner": "/assets/img/featured/indeedhub-banner.jpg",
|
||||
@@ -11,232 +11,335 @@
|
||||
},
|
||||
"apps": [
|
||||
{
|
||||
"id": "bitcoin-knots", "title": "Bitcoin Knots", "version": "28.1.0",
|
||||
"id": "bitcoin-knots",
|
||||
"title": "Bitcoin Knots",
|
||||
"version": "28.1.0",
|
||||
"description": "Run a full Bitcoin node. Validate and relay blocks and transactions.",
|
||||
"icon": "/assets/img/app-icons/bitcoin-knots.webp",
|
||||
"author": "Bitcoin Knots", "category": "money", "tier": "core",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/bitcoin-knots:latest",
|
||||
"author": "Bitcoin Knots",
|
||||
"category": "money",
|
||||
"tier": "core",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/bitcoin-knots:latest",
|
||||
"repoUrl": "https://github.com/bitcoinknots/bitcoin"
|
||||
},
|
||||
{
|
||||
"id": "lnd", "title": "LND", "version": "0.18.4",
|
||||
"id": "bitcoin-core",
|
||||
"title": "Bitcoin Core",
|
||||
"version": "28.4",
|
||||
"description": "Reference Bitcoin node implementation. Alternative to Bitcoin Knots; uninstall Knots before switching.",
|
||||
"icon": "/assets/img/app-icons/bitcoin-core.svg",
|
||||
"author": "Bitcoin Core contributors",
|
||||
"category": "money",
|
||||
"tier": "optional",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/bitcoin:28.4",
|
||||
"repoUrl": "https://github.com/bitcoin/bitcoin"
|
||||
},
|
||||
{
|
||||
"id": "lnd",
|
||||
"title": "LND",
|
||||
"version": "0.18.4",
|
||||
"description": "Lightning Network Daemon. Fast Bitcoin payments through Lightning.",
|
||||
"icon": "/assets/img/app-icons/lnd.svg",
|
||||
"author": "Lightning Labs", "category": "money", "tier": "core",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/lnd:v0.18.4-beta",
|
||||
"author": "Lightning Labs",
|
||||
"category": "money",
|
||||
"tier": "core",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/lnd:v0.18.4-beta",
|
||||
"repoUrl": "https://github.com/lightningnetwork/lnd",
|
||||
"requires": ["bitcoin-knots"]
|
||||
"requires": [
|
||||
"bitcoin-knots"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "btcpay-server", "title": "BTCPay Server", "version": "1.13.7",
|
||||
"id": "btcpay-server",
|
||||
"title": "BTCPay Server",
|
||||
"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": "git.tx1138.com/lfg2025/btcpayserver:1.13.7",
|
||||
"author": "BTCPay Server Foundation",
|
||||
"category": "commerce",
|
||||
"tier": "core",
|
||||
"dockerImage": "docker.io/btcpayserver/btcpayserver:2.3.9",
|
||||
"repoUrl": "https://github.com/btcpayserver/btcpayserver",
|
||||
"requires": ["bitcoin-knots"]
|
||||
"requires": [
|
||||
"bitcoin-knots"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mempool", "title": "Mempool Explorer", "version": "3.0.0",
|
||||
"id": "mempool",
|
||||
"title": "Mempool Explorer",
|
||||
"version": "3.0.0",
|
||||
"description": "Self-hosted Bitcoin blockchain and mempool visualizer.",
|
||||
"icon": "/assets/img/app-icons/mempool.webp",
|
||||
"author": "Mempool", "category": "money", "tier": "core",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/mempool-frontend:v3.0.0",
|
||||
"author": "Mempool",
|
||||
"category": "money",
|
||||
"tier": "core",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/mempool-frontend:v3.0.0",
|
||||
"repoUrl": "https://github.com/mempool/mempool",
|
||||
"requires": ["bitcoin-knots", "electrumx"]
|
||||
"requires": [
|
||||
"bitcoin-knots",
|
||||
"electrumx"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "electrumx", "title": "ElectrumX", "version": "1.18.0",
|
||||
"id": "electrumx",
|
||||
"title": "ElectrumX",
|
||||
"version": "1.18.0",
|
||||
"description": "Electrum protocol server. Index the blockchain for fast wallet lookups.",
|
||||
"icon": "/assets/img/app-icons/electrumx.webp",
|
||||
"author": "Luke Childs", "category": "money", "tier": "core",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/electrumx:v1.18.0",
|
||||
"icon": "/assets/img/app-icons/electrumx.png",
|
||||
"author": "Luke Childs",
|
||||
"category": "money",
|
||||
"tier": "core",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/electrumx:v1.18.0",
|
||||
"repoUrl": "https://github.com/spesmilo/electrumx",
|
||||
"requires": ["bitcoin-knots"]
|
||||
"requires": [
|
||||
"bitcoin-knots"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "indeedhub", "title": "IndeeHub", "version": "1.0.0",
|
||||
"id": "indeedhub",
|
||||
"title": "IndeeHub",
|
||||
"version": "1.0.0",
|
||||
"description": "Bitcoin documentary streaming with Nostr identity.",
|
||||
"icon": "/assets/img/app-icons/indeedhub.png",
|
||||
"author": "IndeeHub", "category": "community",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/indeedhub:1.0.0",
|
||||
"author": "IndeeHub",
|
||||
"category": "community",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/indeedhub:1.0.0",
|
||||
"repoUrl": "https://github.com/indeedhub/indeedhub"
|
||||
},
|
||||
{
|
||||
"id": "botfights", "title": "BotFights", "version": "1.1.0",
|
||||
"id": "botfights",
|
||||
"title": "BotFights",
|
||||
"version": "1.1.0",
|
||||
"description": "Bot arena + 2-player arcade fighter with controller support and Adventure Mode.",
|
||||
"icon": "/assets/img/app-icons/botfights.svg",
|
||||
"author": "BotFights", "category": "community",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/botfights:1.1.0",
|
||||
"repoUrl": "https://botfights.net"
|
||||
"author": "BotFights",
|
||||
"category": "community",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/botfights:1.1.0",
|
||||
"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", "title": "Gitea", "version": "1.23",
|
||||
"id": "gitea",
|
||||
"title": "Gitea",
|
||||
"version": "1.23",
|
||||
"description": "Self-hosted Git service with container registry, CI/CD, issue tracking.",
|
||||
"icon": "/assets/img/app-icons/gitea.svg",
|
||||
"author": "Gitea", "category": "development",
|
||||
"dockerImage": "docker.io/gitea/gitea:1.23",
|
||||
"repoUrl": "https://gitea.com"
|
||||
"author": "Gitea",
|
||||
"category": "development",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/gitea:1.23",
|
||||
"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", "title": "File Browser", "version": "2.27.0",
|
||||
"id": "filebrowser",
|
||||
"title": "File Browser",
|
||||
"version": "2.27.0",
|
||||
"description": "Web-based file manager.",
|
||||
"icon": "/assets/img/app-icons/file-browser.webp",
|
||||
"author": "File Browser", "category": "data", "tier": "core",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/filebrowser:v2.27.0",
|
||||
"repoUrl": "https://github.com/filebrowser/filebrowser"
|
||||
"author": "File Browser",
|
||||
"category": "data",
|
||||
"tier": "core",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/filebrowser:v2.27.0",
|
||||
"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", "title": "Vaultwarden", "version": "1.30.0",
|
||||
"id": "vaultwarden",
|
||||
"title": "Vaultwarden",
|
||||
"version": "1.30.0",
|
||||
"description": "Self-hosted password vault with zero-knowledge encryption.",
|
||||
"icon": "/assets/img/app-icons/vaultwarden.webp",
|
||||
"author": "Vaultwarden", "category": "data", "tier": "recommended",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/vaultwarden:1.30.0-alpine",
|
||||
"repoUrl": "https://github.com/dani-garcia/vaultwarden"
|
||||
"author": "Vaultwarden",
|
||||
"category": "data",
|
||||
"tier": "recommended",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/vaultwarden:1.30.0-alpine",
|
||||
"repoUrl": "https://github.com/dani-garcia/vaultwarden",
|
||||
"containerConfig": {
|
||||
"ports": ["8082:80"],
|
||||
"volumes": ["/var/lib/archipelago/vaultwarden:/data"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "searxng", "title": "SearXNG", "version": "2024.1.0",
|
||||
"id": "searxng",
|
||||
"title": "SearXNG",
|
||||
"version": "2024.1.0",
|
||||
"description": "Privacy-respecting metasearch engine.",
|
||||
"icon": "/assets/img/app-icons/searxng.png",
|
||||
"author": "SearXNG", "category": "data", "tier": "recommended",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/searxng:latest",
|
||||
"repoUrl": "https://github.com/searxng/searxng"
|
||||
"author": "SearXNG",
|
||||
"category": "data",
|
||||
"tier": "recommended",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/searxng:latest",
|
||||
"repoUrl": "https://github.com/searxng/searxng",
|
||||
"containerConfig": {
|
||||
"ports": ["8888:8080"],
|
||||
"volumes": ["/var/lib/archipelago/searxng:/etc/searxng"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "nostr-rs-relay", "title": "Nostr Relay", "version": "0.9.0",
|
||||
"description": "Your own Nostr relay. Store events locally, relay for friends.",
|
||||
"icon": "/assets/img/app-icons/nostr-rs-relay.svg",
|
||||
"author": "scsiblade", "category": "nostr",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/nostr-rs-relay:0.9.0",
|
||||
"repoUrl": "https://sr.ht/~gheartsfield/nostr-rs-relay/"
|
||||
},
|
||||
{
|
||||
"id": "fedimint", "title": "Fedimint", "version": "0.10.0",
|
||||
"id": "fedimint",
|
||||
"title": "Fedimint",
|
||||
"version": "0.10.0",
|
||||
"description": "Federated Bitcoin mint with privacy through federated guardians.",
|
||||
"icon": "/assets/img/app-icons/fedimint.png",
|
||||
"author": "Fedimint", "category": "money",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/fedimintd:v0.10.0",
|
||||
"author": "Fedimint",
|
||||
"category": "money",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/fedimintd:v0.10.0",
|
||||
"repoUrl": "https://github.com/fedimint/fedimint"
|
||||
},
|
||||
{
|
||||
"id": "ollama", "title": "Ollama", "version": "0.5.4",
|
||||
"description": "Run AI models locally. Private and on your hardware.",
|
||||
"icon": "/assets/img/app-icons/ollama.png",
|
||||
"author": "Ollama", "category": "data",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/ollama:latest",
|
||||
"repoUrl": "https://github.com/ollama/ollama"
|
||||
},
|
||||
{
|
||||
"id": "nextcloud", "title": "Nextcloud", "version": "28",
|
||||
"description": "Your own private cloud. File sync, calendars, contacts.",
|
||||
"icon": "/assets/img/app-icons/nextcloud.webp",
|
||||
"author": "Nextcloud", "category": "data",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/nextcloud:28",
|
||||
"repoUrl": "https://github.com/nextcloud/server"
|
||||
},
|
||||
{
|
||||
"id": "jellyfin", "title": "Jellyfin", "version": "10.8.13",
|
||||
"id": "jellyfin",
|
||||
"title": "Jellyfin",
|
||||
"version": "10.8.13",
|
||||
"description": "Free media server. Stream movies, music, and photos.",
|
||||
"icon": "/assets/img/app-icons/jellyfin.webp",
|
||||
"author": "Jellyfin", "category": "data",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/jellyfin:10.8.13",
|
||||
"repoUrl": "https://github.com/jellyfin/jellyfin"
|
||||
"author": "Jellyfin",
|
||||
"category": "data",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/jellyfin:10.8.13",
|
||||
"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", "title": "Immich", "version": "1.90.0",
|
||||
"id": "immich",
|
||||
"title": "Immich",
|
||||
"version": "1.90.0",
|
||||
"description": "High-performance photo and video backup with ML.",
|
||||
"icon": "/assets/img/app-icons/immich.png",
|
||||
"author": "Immich", "category": "data",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/immich-server:release",
|
||||
"author": "Immich",
|
||||
"category": "data",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/immich-server:release",
|
||||
"repoUrl": "https://github.com/immich-app/immich"
|
||||
},
|
||||
{
|
||||
"id": "homeassistant", "title": "Home Assistant", "version": "2024.1",
|
||||
"id": "homeassistant",
|
||||
"title": "Home Assistant",
|
||||
"version": "2024.1",
|
||||
"description": "Open-source home automation.",
|
||||
"icon": "/assets/img/app-icons/homeassistant.png",
|
||||
"author": "Home Assistant", "category": "home",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/home-assistant:2024.1",
|
||||
"repoUrl": "https://github.com/home-assistant/core"
|
||||
"author": "Home Assistant",
|
||||
"category": "home",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/home-assistant:2024.1",
|
||||
"repoUrl": "https://github.com/home-assistant/core",
|
||||
"containerConfig": {
|
||||
"ports": ["8123:8123"],
|
||||
"volumes": ["/var/lib/archipelago/home-assistant:/config"],
|
||||
"env": ["TZ=UTC"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "grafana", "title": "Grafana", "version": "10.2.0",
|
||||
"id": "grafana",
|
||||
"title": "Grafana",
|
||||
"version": "10.2.0",
|
||||
"description": "Analytics and monitoring dashboards.",
|
||||
"icon": "/assets/img/app-icons/grafana.png",
|
||||
"author": "Grafana Labs", "category": "data", "tier": "recommended",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/grafana:10.2.0",
|
||||
"repoUrl": "https://github.com/grafana/grafana"
|
||||
"author": "Grafana Labs",
|
||||
"category": "data",
|
||||
"tier": "recommended",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/grafana:10.2.0",
|
||||
"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", "title": "Tailscale", "version": "1.78.0",
|
||||
"id": "tailscale",
|
||||
"title": "Tailscale",
|
||||
"version": "1.78.0",
|
||||
"description": "Zero-config VPN with WireGuard mesh networking.",
|
||||
"icon": "/assets/img/app-icons/tailscale.webp",
|
||||
"author": "Tailscale", "category": "networking", "tier": "recommended",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/tailscale:stable",
|
||||
"repoUrl": "https://github.com/tailscale/tailscale"
|
||||
"author": "Tailscale",
|
||||
"category": "networking",
|
||||
"tier": "recommended",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/tailscale:stable",
|
||||
"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", "title": "Uptime Kuma", "version": "1.23.0",
|
||||
"id": "netbird",
|
||||
"title": "NetBird",
|
||||
"version": "0.71.2",
|
||||
"description": "Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN service.",
|
||||
"icon": "/assets/img/app-icons/netbird.svg",
|
||||
"author": "NetBird",
|
||||
"category": "networking",
|
||||
"tier": "recommended",
|
||||
"dockerImage": "docker.io/netbirdio/dashboard:v2.38.0",
|
||||
"repoUrl": "https://github.com/netbirdio/netbird",
|
||||
"containerConfig": {
|
||||
"ports": ["8087:80", "8086:80", "3478:3478/udp"],
|
||||
"volumes": ["/var/lib/archipelago/netbird:/var/lib/netbird"],
|
||||
"notes": "Installed as a two-container stack: netbird dashboard on 8087 and netbird-server control plane on 8086 plus UDP 3478. For production clients, publish a DNS name over HTTPS with gRPC/WebSocket routing."
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "uptime-kuma",
|
||||
"title": "Uptime Kuma",
|
||||
"version": "1.23.0",
|
||||
"description": "Self-hosted uptime monitoring.",
|
||||
"icon": "/assets/img/app-icons/uptime-kuma.webp",
|
||||
"author": "Uptime Kuma", "category": "data", "tier": "recommended",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/uptime-kuma:1",
|
||||
"repoUrl": "https://github.com/louislam/uptime-kuma"
|
||||
"author": "Uptime Kuma",
|
||||
"category": "data",
|
||||
"tier": "recommended",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/uptime-kuma:1",
|
||||
"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": "nostr-vpn", "title": "Nostr VPN", "version": "0.3.7",
|
||||
"description": "Tailscale-style mesh VPN with Nostr control plane.",
|
||||
"icon": "/assets/img/app-icons/nostr-vpn.svg",
|
||||
"author": "Martti Malmi", "category": "networking",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/nostr-vpn:v0.3.7",
|
||||
"repoUrl": "https://github.com/mmalmi/nostr-vpn"
|
||||
},
|
||||
{
|
||||
"id": "fips", "title": "FIPS", "version": "0.1.0",
|
||||
"description": "Free Internetworking Peering System. Encrypted mesh network.",
|
||||
"icon": "/assets/img/app-icons/fips.svg",
|
||||
"author": "Jim Corgan", "category": "networking",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/fips:v0.1.0",
|
||||
"repoUrl": "https://github.com/jmcorgan/fips"
|
||||
},
|
||||
{
|
||||
"id": "routstr", "title": "Routstr", "version": "0.4.3",
|
||||
"description": "Decentralized AI inference proxy with Cashu ecash.",
|
||||
"icon": "/assets/img/app-icons/routstr.svg",
|
||||
"author": "Routstr", "category": "community",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/routstr:v0.4.3",
|
||||
"repoUrl": "https://github.com/routstr/routstr-core"
|
||||
},
|
||||
{
|
||||
"id": "dwn", "title": "Decentralized Web Node", "version": "0.4.0",
|
||||
"description": "Own your data with DID-based access control.",
|
||||
"icon": "/assets/img/app-icons/dwn.svg",
|
||||
"author": "TBD", "category": "data",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/dwn-server:main",
|
||||
"repoUrl": "https://github.com/TBD54566975/dwn-server"
|
||||
},
|
||||
{
|
||||
"id": "endurain", "title": "Endurain", "version": "0.8.0",
|
||||
"description": "Self-hosted fitness tracking. Strava alternative.",
|
||||
"icon": "/assets/img/app-icons/endurain.png",
|
||||
"author": "Endurain", "category": "data",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/endurain:0.8.0",
|
||||
"repoUrl": "https://github.com/joaovitoriasilva/endurain"
|
||||
},
|
||||
{
|
||||
"id": "penpot", "title": "Penpot", "version": "2.4",
|
||||
"description": "Open-source design platform. Self-hosted Figma alternative.",
|
||||
"icon": "/assets/img/app-icons/penpot.webp",
|
||||
"author": "Penpot", "category": "data",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/penpot-frontend:2.4",
|
||||
"repoUrl": "https://github.com/penpot/penpot"
|
||||
},
|
||||
{
|
||||
"id": "photoprism", "title": "PhotoPrism", "version": "240915",
|
||||
"id": "photoprism",
|
||||
"title": "PhotoPrism",
|
||||
"version": "240915",
|
||||
"description": "AI-powered photo management with facial recognition.",
|
||||
"icon": "/assets/img/app-icons/photoprism.svg",
|
||||
"author": "PhotoPrism", "category": "data",
|
||||
"dockerImage": "git.tx1138.com/lfg2025/photoprism:240915",
|
||||
"repoUrl": "https://github.com/photoprism/photoprism"
|
||||
"author": "PhotoPrism",
|
||||
"category": "data",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/photoprism:240915",
|
||||
"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",
|
||||
"title": "Nextcloud",
|
||||
"version": "28",
|
||||
"description": "Your own private cloud. File sync, calendars, contacts.",
|
||||
"icon": "/assets/img/app-icons/nextcloud.webp",
|
||||
"author": "Nextcloud",
|
||||
"category": "data",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/nextcloud:28",
|
||||
"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 |
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
app:
|
||||
id: archy-btcpay-db
|
||||
name: BTCPay Postgres
|
||||
version: 15.17
|
||||
description: Postgres backend for BTCPay and NBXplorer.
|
||||
|
||||
container:
|
||||
image: git.tx1138.com/lfg2025/postgres:15.17
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
data_uid: "100998:100998"
|
||||
secret_env:
|
||||
- key: POSTGRES_PASSWORD
|
||||
secret_file: btcpay-db-password
|
||||
|
||||
dependencies:
|
||||
- storage: 20Gi
|
||||
|
||||
resources:
|
||||
memory_limit: 1Gi
|
||||
disk_limit: 20Gi
|
||||
|
||||
security:
|
||||
capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE]
|
||||
readonly_root: false
|
||||
network_policy: isolated
|
||||
|
||||
ports: []
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/postgres-btcpay
|
||||
target: /var/lib/postgresql/data
|
||||
options: [rw]
|
||||
|
||||
environment:
|
||||
- POSTGRES_DB=btcpay
|
||||
- POSTGRES_USER=btcpay
|
||||
|
||||
health_check:
|
||||
type: tcp
|
||||
endpoint: localhost:5432
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
bitcoin_integration:
|
||||
rpc_access: none
|
||||
sync_required: false
|
||||
@@ -0,0 +1,51 @@
|
||||
app:
|
||||
id: archy-mempool-db
|
||||
name: Mempool MariaDB
|
||||
version: 11.4.10
|
||||
description: MariaDB backend for the mempool explorer stack.
|
||||
|
||||
container:
|
||||
image: git.tx1138.com/lfg2025/mariadb:11.4.10
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
data_uid: "100998:100998"
|
||||
secret_env:
|
||||
- key: MYSQL_PASSWORD
|
||||
secret_file: mempool-db-password
|
||||
- key: MYSQL_ROOT_PASSWORD
|
||||
secret_file: mysql-root-db-password
|
||||
|
||||
dependencies:
|
||||
- storage: 20Gi
|
||||
|
||||
resources:
|
||||
memory_limit: 512Mi
|
||||
disk_limit: 20Gi
|
||||
|
||||
security:
|
||||
capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE]
|
||||
readonly_root: false
|
||||
network_policy: isolated
|
||||
|
||||
ports: []
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/mysql-mempool
|
||||
target: /var/lib/mysql
|
||||
options: [rw]
|
||||
|
||||
environment:
|
||||
- MYSQL_DATABASE=mempool
|
||||
- MYSQL_USER=mempool
|
||||
|
||||
health_check:
|
||||
type: tcp
|
||||
endpoint: localhost:3306
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
bitcoin_integration:
|
||||
rpc_access: none
|
||||
sync_required: false
|
||||
@@ -0,0 +1,44 @@
|
||||
app:
|
||||
id: archy-mempool-web
|
||||
name: Mempool Web
|
||||
version: 3.0.0
|
||||
description: Frontend web UI for mempool explorer.
|
||||
container_name: mempool
|
||||
|
||||
container:
|
||||
image: git.tx1138.com/lfg2025/mempool-frontend:v3.0.0
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
|
||||
dependencies:
|
||||
- app_id: mempool-api
|
||||
version: ">=3.0.0"
|
||||
|
||||
resources:
|
||||
memory_limit: 512Mi
|
||||
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: false
|
||||
network_policy: isolated
|
||||
|
||||
ports:
|
||||
- host: 4080
|
||||
container: 8080
|
||||
protocol: tcp
|
||||
|
||||
environment:
|
||||
- FRONTEND_HTTP_PORT=8080
|
||||
- BACKEND_MAINNET_HTTP_HOST=mempool-api
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://localhost:8080
|
||||
path: /
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
bitcoin_integration:
|
||||
rpc_access: none
|
||||
sync_required: false
|
||||
@@ -0,0 +1,64 @@
|
||||
app:
|
||||
id: archy-nbxplorer
|
||||
name: NBXplorer
|
||||
version: 2.6.0
|
||||
description: BTCPay blockchain indexer service.
|
||||
|
||||
container:
|
||||
image: git.tx1138.com/lfg2025/nbxplorer:2.6.0
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
secret_env:
|
||||
- key: NBXPLORER_BTCRPCPASSWORD
|
||||
secret_file: bitcoin-rpc-password
|
||||
- key: BTCPAY_DB_PASS
|
||||
secret_file: btcpay-db-password
|
||||
|
||||
dependencies:
|
||||
- app_id: bitcoin-core
|
||||
version: ">=26.0"
|
||||
- app_id: archy-btcpay-db
|
||||
version: ">=15.17"
|
||||
|
||||
resources:
|
||||
memory_limit: 2Gi
|
||||
disk_limit: 20Gi
|
||||
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: false
|
||||
network_policy: isolated
|
||||
|
||||
ports:
|
||||
- host: 32838
|
||||
container: 32838
|
||||
protocol: tcp
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/nbxplorer
|
||||
target: /data
|
||||
options: [rw]
|
||||
|
||||
environment:
|
||||
- NBXPLORER_DATADIR=/data
|
||||
- NBXPLORER_NETWORK=mainnet
|
||||
- NBXPLORER_CHAINS=btc
|
||||
- NBXPLORER_BIND=0.0.0.0:32838
|
||||
- NBXPLORER_BTCRPCURL=http://bitcoin-knots:8332
|
||||
- NBXPLORER_BTCRPCUSER=archipelago
|
||||
- NBXPLORER_BTCNODEENDPOINT=bitcoin-knots:8333
|
||||
- NBXPLORER_NOAUTH=1
|
||||
- NBXPLORER_POSTGRES=Username=btcpay;Password=${BTCPAY_DB_PASS};Host=archy-btcpay-db;Port=5432;Database=nbxplorer
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://localhost:32838
|
||||
path: /
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
bitcoin_integration:
|
||||
rpc_access: read-only
|
||||
sync_required: true
|
||||
@@ -1,61 +1,83 @@
|
||||
app:
|
||||
id: bitcoin-core
|
||||
name: Bitcoin Core
|
||||
version: 24.0.0
|
||||
description: Full Bitcoin node implementation. The reference implementation of the Bitcoin protocol.
|
||||
|
||||
version: 28.4.0
|
||||
description: Reference Bitcoin Core node with dynamic prune/full-mode startup based on host disk.
|
||||
|
||||
container_name: bitcoin-core
|
||||
|
||||
container:
|
||||
image: bitcoin/bitcoin:24.0
|
||||
image_signature: cosign://...
|
||||
pull_policy: verify-signature
|
||||
|
||||
image: 146.59.87.168:3000/lfg2025/bitcoin:28.4
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
entrypoint: ["sh", "-lc"]
|
||||
custom_args:
|
||||
# Sync-speed flags: -par=0 uses every core (was capped at 2 by
|
||||
# --cpus=2, now removed for bitcoin/electrumx). -dbcache sized to
|
||||
# the IBD sweet spot - 4GB on full nodes, 1GB on pruned. Container
|
||||
# --memory=8g (config.rs::get_memory_limit) leaves headroom for
|
||||
# mempool + connections.
|
||||
- >-
|
||||
BITCOIND="$(command -v bitcoind || true)";
|
||||
if [ -z "$BITCOIND" ]; then
|
||||
BITCOIND="$(find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1)";
|
||||
fi;
|
||||
if [ -z "$BITCOIND" ]; then
|
||||
echo "bitcoind not found in image" >&2;
|
||||
exit 127;
|
||||
fi;
|
||||
if [ "${DISK_GB:-0}" -lt 1000 ]; then
|
||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 -rpcuser="${BITCOIN_RPC_USER}" -rpcpassword="${BITCOIN_RPC_PASS}";
|
||||
else
|
||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 -rpcuser="${BITCOIN_RPC_USER}" -rpcpassword="${BITCOIN_RPC_PASS}";
|
||||
fi
|
||||
derived_env:
|
||||
- key: DISK_GB
|
||||
template: "{{DISK_GB}}"
|
||||
secret_env:
|
||||
- key: BITCOIN_RPC_PASS
|
||||
secret_file: bitcoin-rpc-password
|
||||
data_uid: "100101:100101"
|
||||
|
||||
dependencies:
|
||||
- storage: 500Gi # Minimum disk space for mainnet
|
||||
|
||||
- storage: 500Gi
|
||||
|
||||
resources:
|
||||
cpu_limit: 2
|
||||
memory_limit: 2Gi
|
||||
cpu_limit: 0
|
||||
memory_limit: 4Gi
|
||||
disk_limit: 500Gi
|
||||
|
||||
|
||||
security:
|
||||
capabilities: [] # No special capabilities needed
|
||||
readonly_root: true
|
||||
no_new_privileges: true
|
||||
user: 1000
|
||||
seccomp_profile: default
|
||||
capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE]
|
||||
readonly_root: false
|
||||
network_policy: isolated
|
||||
apparmor_profile: bitcoin-core
|
||||
|
||||
|
||||
ports:
|
||||
- host: 8332
|
||||
container: 8332
|
||||
protocol: tcp # RPC
|
||||
protocol: tcp
|
||||
- host: 8333
|
||||
container: 8333
|
||||
protocol: tcp # P2P
|
||||
|
||||
protocol: tcp
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/bitcoin
|
||||
target: /home/bitcoin/.bitcoin
|
||||
options: [rw]
|
||||
|
||||
|
||||
environment:
|
||||
- NETWORK=mainnet
|
||||
- RPC_USER=${BITCOIN_RPC_USER}
|
||||
- RPC_PASSWORD=${BITCOIN_RPC_PASSWORD}
|
||||
- PRUNE=0 # Full node (set to 550 for pruned)
|
||||
|
||||
- BITCOIN_RPC_USER=archipelago
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://localhost:8332
|
||||
path: /
|
||||
type: tcp
|
||||
endpoint: localhost:8332
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
|
||||
bitcoin_integration:
|
||||
rpc_access: admin
|
||||
sync_required: true
|
||||
testnet_support: true
|
||||
testnet_support: false
|
||||
pruning_support: true
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
app:
|
||||
id: bitcoin-knots
|
||||
name: Bitcoin Knots
|
||||
version: 28.1.0
|
||||
description: Full Bitcoin Knots node with dynamic prune/full-mode startup based on host disk.
|
||||
|
||||
container_name: bitcoin-knots
|
||||
|
||||
container:
|
||||
image: 146.59.87.168:3000/lfg2025/bitcoin-knots:latest
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
entrypoint: ["sh", "-lc"]
|
||||
custom_args:
|
||||
# Sync-speed flags: -par=0 uses every core (was capped at 2 by
|
||||
# --cpus=2, now removed for bitcoin/electrumx). -dbcache sized to
|
||||
# the IBD sweet spot - 4GB on full nodes, 1GB on pruned. Container
|
||||
# --memory=8g (config.rs::get_memory_limit) leaves headroom for
|
||||
# mempool + connections.
|
||||
- >-
|
||||
BITCOIND="$(command -v bitcoind || true)";
|
||||
if [ -z "$BITCOIND" ]; then
|
||||
BITCOIND="$(find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1)";
|
||||
fi;
|
||||
if [ -z "$BITCOIND" ]; then
|
||||
echo "bitcoind not found in image" >&2;
|
||||
exit 127;
|
||||
fi;
|
||||
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="$RPC_USER" -rpcpassword="$RPC_PASS";
|
||||
fi
|
||||
derived_env:
|
||||
- key: DISK_GB
|
||||
template: "{{DISK_GB}}"
|
||||
secret_env:
|
||||
- key: BITCOIN_RPC_PASS
|
||||
secret_file: bitcoin-rpc-password
|
||||
data_uid: "100101:100101"
|
||||
|
||||
dependencies:
|
||||
- storage: 500Gi
|
||||
|
||||
resources:
|
||||
cpu_limit: 0
|
||||
memory_limit: 8Gi
|
||||
disk_limit: 500Gi
|
||||
|
||||
security:
|
||||
capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE]
|
||||
readonly_root: false
|
||||
network_policy: isolated
|
||||
|
||||
ports:
|
||||
- host: 8332
|
||||
container: 8332
|
||||
protocol: tcp
|
||||
- host: 8333
|
||||
container: 8333
|
||||
protocol: tcp
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/bitcoin
|
||||
target: /home/bitcoin/.bitcoin
|
||||
options: [rw]
|
||||
|
||||
environment:
|
||||
- BITCOIN_RPC_USER=archipelago
|
||||
|
||||
health_check:
|
||||
type: tcp
|
||||
endpoint: localhost:8332
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
bitcoin_integration:
|
||||
rpc_access: admin
|
||||
sync_required: true
|
||||
testnet_support: false
|
||||
pruning_support: true
|
||||
@@ -0,0 +1,56 @@
|
||||
app:
|
||||
id: bitcoin-ui
|
||||
name: Bitcoin UI
|
||||
version: 1.0.0
|
||||
description: |
|
||||
Archipelago-native HTTP proxy + static site for interacting with the
|
||||
Bitcoin Core / Bitcoin Knots JSON-RPC. Runs nginx inside a container
|
||||
and reverse-proxies /bitcoin-rpc/ to 127.0.0.1:8332 on the host. The
|
||||
upstream Authorization header is substituted from
|
||||
/var/lib/archipelago/secrets/bitcoin-rpc-password by the prod
|
||||
orchestrator's pre-start hook, rendered into an nginx.conf that is
|
||||
bind-mounted read-only at container start.
|
||||
|
||||
container:
|
||||
build:
|
||||
context: /opt/archipelago/docker/bitcoin-ui
|
||||
dockerfile: Dockerfile
|
||||
tag: localhost/bitcoin-ui:local
|
||||
|
||||
dependencies:
|
||||
- app_id: bitcoin-core
|
||||
|
||||
resources:
|
||||
memory_limit: 128Mi
|
||||
|
||||
security:
|
||||
readonly_root: false
|
||||
network_policy: host
|
||||
|
||||
# Host networking: nginx listens on 8334 directly on the host IP, and
|
||||
# proxies to 127.0.0.1:8332 which is where the bitcoin backend binds
|
||||
# its RPC. `ports:` is intentionally empty because host networking
|
||||
# bypasses port mapping.
|
||||
ports: []
|
||||
|
||||
volumes:
|
||||
# Bind-mount the rendered nginx.conf read-only. The prod orchestrator
|
||||
# renders /var/lib/archipelago/bitcoin-ui/nginx.conf on every install
|
||||
# and every reconcile pass, substituting the base64 RPC auth from
|
||||
# the plaintext password secret. If the rendered bytes change (the
|
||||
# password rotated, or the template was updated by OTA), the
|
||||
# reconciler restarts this container so nginx re-reads the config.
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/bitcoin-ui/nginx.conf
|
||||
target: /etc/nginx/conf.d/default.conf
|
||||
options: [ro]
|
||||
|
||||
environment: []
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://127.0.0.1:8334
|
||||
path: /
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
@@ -1,66 +1,81 @@
|
||||
app:
|
||||
id: btcpay-server
|
||||
name: BTCPay Server
|
||||
version: 1.12.0
|
||||
version: 2.3.9
|
||||
description: Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries.
|
||||
|
||||
|
||||
container:
|
||||
image: btcpayserver/btcpayserver:1.12.0
|
||||
image_signature: cosign://...
|
||||
pull_policy: verify-signature
|
||||
|
||||
image: docker.io/btcpayserver/btcpayserver:2.3.9
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
secret_env:
|
||||
- key: BTCPAY_BTCRPCPASSWORD
|
||||
secret_file: bitcoin-rpc-password
|
||||
- key: BTCPAY_DB_PASS
|
||||
secret_file: btcpay-db-password
|
||||
derived_env:
|
||||
- key: BTCPAY_HOST
|
||||
template: "{{HOST_IP}}:23000"
|
||||
|
||||
dependencies:
|
||||
- app_id: bitcoin-core
|
||||
version: ">=26.0"
|
||||
- app_id: lnd
|
||||
version: ">=0.18.0"
|
||||
|
||||
- app_id: archy-btcpay-db
|
||||
version: ">=15.17"
|
||||
- app_id: archy-nbxplorer
|
||||
version: ">=2.6.0"
|
||||
|
||||
resources:
|
||||
cpu_limit: 2
|
||||
memory_limit: 2Gi
|
||||
disk_limit: 20Gi
|
||||
|
||||
|
||||
security:
|
||||
capabilities: [NET_BIND_SERVICE]
|
||||
readonly_root: true
|
||||
no_new_privileges: true
|
||||
user: 1000
|
||||
seccomp_profile: default
|
||||
capabilities: []
|
||||
readonly_root: false
|
||||
network_policy: isolated
|
||||
apparmor_profile: btcpay
|
||||
|
||||
|
||||
ports:
|
||||
- host: 80
|
||||
container: 80
|
||||
- host: 23000
|
||||
container: 49392
|
||||
protocol: tcp
|
||||
- host: 443
|
||||
container: 443
|
||||
protocol: tcp
|
||||
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/btcpay
|
||||
target: /datadir
|
||||
options: [rw]
|
||||
|
||||
|
||||
environment:
|
||||
- BTCPAY_NETWORK=mainnet
|
||||
- BTCPAY_CHAIN=btc
|
||||
- BTCPAY_BTCEXPLORERURL=http://bitcoin-core:8332
|
||||
- BTCPAY_LIGHTNING=type=lnd-rest;server=http://lnd:8080;allowinsecure=true
|
||||
|
||||
- ASPNETCORE_URLS=http://0.0.0.0:49392
|
||||
- BTCPAY_PROTOCOL=http
|
||||
- BTCPAY_CHAINS=btc
|
||||
- BTCPAY_BTCEXPLORERURL=http://archy-nbxplorer:32838
|
||||
- BTCPAY_BTCRPCURL=http://bitcoin-knots:8332
|
||||
- BTCPAY_BTCRPCUSER=archipelago
|
||||
- BTCPAY_POSTGRES=Username=btcpay;Password=${BTCPAY_DB_PASS};Host=archy-btcpay-db;Port=5432;Database=btcpay
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://localhost
|
||||
path: /health
|
||||
endpoint: http://localhost:49392
|
||||
path: /
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
|
||||
bitcoin_integration:
|
||||
rpc_access: read-only
|
||||
sync_required: true
|
||||
|
||||
|
||||
lightning_integration:
|
||||
payment_processing: true
|
||||
payment_processing: false
|
||||
invoice_management: true
|
||||
|
||||
interfaces:
|
||||
main:
|
||||
name: Web UI
|
||||
description: BTCPay Server dashboard
|
||||
type: ui
|
||||
port: 23000
|
||||
protocol: http
|
||||
path: /
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
app:
|
||||
id: electrs-ui
|
||||
name: Electrs UI
|
||||
version: 1.0.0
|
||||
description: |
|
||||
Archipelago-native HTTP frontend for electrs/electrumx status. Runs
|
||||
nginx inside a container, serves static assets, and proxies
|
||||
/electrs-status to the archipelago backend on 127.0.0.1:5678.
|
||||
|
||||
container:
|
||||
build:
|
||||
context: /opt/archipelago/docker/electrs-ui
|
||||
dockerfile: Dockerfile
|
||||
tag: localhost/electrs-ui:local
|
||||
|
||||
dependencies: []
|
||||
|
||||
resources:
|
||||
memory_limit: 64Mi
|
||||
|
||||
security:
|
||||
readonly_root: false
|
||||
network_policy: host
|
||||
|
||||
# Host networking: nginx listens on 50002 directly on the host IP.
|
||||
ports: []
|
||||
|
||||
volumes: []
|
||||
|
||||
environment: []
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://127.0.0.1:50002
|
||||
path: /
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
@@ -0,0 +1,64 @@
|
||||
app:
|
||||
id: electrumx
|
||||
name: ElectrumX
|
||||
version: 1.18.0
|
||||
description: Electrum server indexing Bitcoin chain data for lightweight wallet queries.
|
||||
|
||||
container:
|
||||
image: git.tx1138.com/lfg2025/electrumx:v1.18.0
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
data_uid: "1000:1000"
|
||||
entrypoint: ["sh", "-lc"]
|
||||
custom_args:
|
||||
- >-
|
||||
export DAEMON_URL="http://archipelago:$(printenv BITCOIN_RPC_PASS)@bitcoin-knots:8332/";
|
||||
exec electrumx_server
|
||||
secret_env:
|
||||
- key: BITCOIN_RPC_PASS
|
||||
secret_file: bitcoin-rpc-password
|
||||
|
||||
dependencies:
|
||||
- app_id: bitcoin-knots
|
||||
version: ">=26.0"
|
||||
- storage: 50Gi
|
||||
|
||||
resources:
|
||||
cpu_limit: 0
|
||||
memory_limit: 4Gi
|
||||
disk_limit: 50Gi
|
||||
|
||||
security:
|
||||
capabilities: [DAC_OVERRIDE]
|
||||
readonly_root: false
|
||||
network_policy: isolated
|
||||
|
||||
ports:
|
||||
- host: 50001
|
||||
container: 50001
|
||||
protocol: tcp
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/electrumx
|
||||
target: /data
|
||||
options: [rw]
|
||||
|
||||
environment:
|
||||
- COIN=Bitcoin
|
||||
- DB_DIRECTORY=/data
|
||||
- SERVICES=tcp://:50001,rpc://0.0.0.0:8000
|
||||
- CACHE_MB=3072
|
||||
- MAX_SEND=10000000
|
||||
|
||||
health_check:
|
||||
type: tcp
|
||||
endpoint: localhost:50001
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
bitcoin_integration:
|
||||
rpc_access: read-only
|
||||
sync_required: true
|
||||
pruning_support: false
|
||||
@@ -1,6 +0,0 @@
|
||||
node_modules
|
||||
dist
|
||||
*.log
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
@@ -1,37 +0,0 @@
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
RUN npm ci --only=production
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy built application
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/package.json ./
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup -g 1000 appuser && \
|
||||
adduser -D -u 1000 -G appuser appuser && \
|
||||
mkdir -p /app/data && \
|
||||
chown -R appuser:appuser /app
|
||||
|
||||
USER appuser
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
ENV ENDURAIN_DATA_DIR=/app/data
|
||||
|
||||
CMD ["node", "dist/index.js"]
|
||||
@@ -1,50 +0,0 @@
|
||||
app:
|
||||
id: endurain
|
||||
name: Endurain
|
||||
version: 1.0.0
|
||||
description: Endurain application platform. Custom application runtime.
|
||||
|
||||
container:
|
||||
image: archipelago/endurain:1.0.0
|
||||
image_signature: cosign://...
|
||||
pull_policy: if-not-present
|
||||
|
||||
dependencies:
|
||||
- storage: 2Gi
|
||||
|
||||
resources:
|
||||
cpu_limit: 2
|
||||
memory_limit: 1Gi
|
||||
disk_limit: 2Gi
|
||||
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: true
|
||||
no_new_privileges: true
|
||||
user: 1000
|
||||
seccomp_profile: default
|
||||
network_policy: isolated
|
||||
apparmor_profile: endurain
|
||||
|
||||
ports:
|
||||
- host: 8085
|
||||
container: 8080
|
||||
protocol: tcp # Web UI
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/endurain
|
||||
target: /app/data
|
||||
options: [rw]
|
||||
|
||||
environment:
|
||||
- ENDURAIN_ENV=production
|
||||
- ENDURAIN_DATA_DIR=/app/data
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://localhost:8085
|
||||
path: /health
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
Generated
-1161
File diff suppressed because it is too large
Load Diff
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"name": "endurain",
|
||||
"version": "1.0.0",
|
||||
"description": "Endurain application platform",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "ts-node src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.10.0",
|
||||
"typescript": "^5.3.3",
|
||||
"ts-node": "^10.9.2"
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import express from 'express';
|
||||
|
||||
const app = express();
|
||||
const port = 8080;
|
||||
|
||||
// Middleware
|
||||
app.use(express.json());
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok', service: 'endurain', version: '1.0.0' });
|
||||
});
|
||||
|
||||
// API endpoints
|
||||
app.get('/api/info', (req, res) => {
|
||||
res.json({
|
||||
name: 'Endurain',
|
||||
version: '1.0.0',
|
||||
status: 'running'
|
||||
});
|
||||
});
|
||||
|
||||
// Start server
|
||||
app.listen(port, '0.0.0.0', () => {
|
||||
console.log(`Endurain listening on port ${port}`);
|
||||
console.log(`Data directory: ${process.env.ENDURAIN_DATA_DIR || '/app/data'}`);
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2020"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
app:
|
||||
id: fedimint-gateway
|
||||
name: Fedimint Gateway
|
||||
version: 0.10.0
|
||||
description: Fedimint gateway service with automatic LND-or-LDK backend selection.
|
||||
|
||||
container:
|
||||
image: git.tx1138.com/lfg2025/gatewayd:v0.10.0
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
entrypoint: ["sh", "-lc"]
|
||||
custom_args:
|
||||
- >-
|
||||
if [ -f /lnd/tls.cert ] && [ -f /lnd/data/chain/bitcoin/mainnet/admin.macaroon ]; then
|
||||
exec gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash "$FEDI_HASH" --network bitcoin --bitcoind-url http://host.archipelago:8332 --bitcoind-username "$FM_BITCOIND_USERNAME" --bitcoind-password "$FM_BITCOIND_PASSWORD" lnd --lnd-rpc-host lnd:10009 --lnd-tls-cert /lnd/tls.cert --lnd-macaroon /lnd/data/chain/bitcoin/mainnet/admin.macaroon;
|
||||
else
|
||||
exec gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash "$FEDI_HASH" --network bitcoin --bitcoind-url http://host.archipelago:8332 --bitcoind-username "$FM_BITCOIND_USERNAME" --bitcoind-password "$FM_BITCOIND_PASSWORD" ldk --ldk-lightning-port 9737 --ldk-alias archipelago-gateway;
|
||||
fi
|
||||
secret_env:
|
||||
- key: FM_BITCOIND_PASSWORD
|
||||
secret_file: bitcoin-rpc-password
|
||||
- key: FEDI_HASH
|
||||
secret_file: fedimint-gateway-hash
|
||||
data_uid: "1000:1000"
|
||||
|
||||
dependencies:
|
||||
- app_id: bitcoin-core
|
||||
version: ">=26.0"
|
||||
- app_id: fedimint
|
||||
version: ">=0.10.0"
|
||||
|
||||
resources:
|
||||
cpu_limit: 2
|
||||
memory_limit: 2Gi
|
||||
disk_limit: 10Gi
|
||||
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: true
|
||||
network_policy: isolated
|
||||
|
||||
ports:
|
||||
- host: 8176
|
||||
container: 8176
|
||||
protocol: tcp
|
||||
- host: 9737
|
||||
container: 9737
|
||||
protocol: tcp
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/fedimint-gateway
|
||||
target: /data
|
||||
options: [rw]
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/lnd
|
||||
target: /lnd
|
||||
options: [ro]
|
||||
|
||||
environment:
|
||||
- FM_BITCOIND_USERNAME=archipelago
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://localhost:8176
|
||||
path: /
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
bitcoin_integration:
|
||||
rpc_access: admin
|
||||
sync_required: true
|
||||
+30
-24
@@ -3,56 +3,62 @@ app:
|
||||
name: Fedimint
|
||||
version: 0.10.0
|
||||
description: Federated Bitcoin minting service with built-in Guardian UI. Privacy-preserving Bitcoin custody.
|
||||
|
||||
|
||||
container:
|
||||
image: fedimint/fedimintd:v0.10.0
|
||||
image_signature: cosign://...
|
||||
image: git.tx1138.com/lfg2025/fedimintd:v0.10.0
|
||||
pull_policy: if-not-present
|
||||
|
||||
network: archy-net
|
||||
derived_env:
|
||||
- key: FM_P2P_URL
|
||||
template: fedimint://{{HOST_MDNS}}:8173
|
||||
- key: FM_API_URL
|
||||
template: ws://{{HOST_MDNS}}:8174
|
||||
secret_env:
|
||||
- key: FM_BITCOIND_PASSWORD
|
||||
secret_file: bitcoin-rpc-password
|
||||
data_uid: "1000:1000"
|
||||
|
||||
dependencies:
|
||||
- app_id: bitcoin-core
|
||||
version: ">=24.0"
|
||||
version: ">=26.0"
|
||||
- storage: 20Gi
|
||||
|
||||
|
||||
resources:
|
||||
cpu_limit: 4
|
||||
memory_limit: 4Gi
|
||||
disk_limit: 20Gi
|
||||
|
||||
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: true
|
||||
no_new_privileges: true
|
||||
user: 1000
|
||||
seccomp_profile: default
|
||||
network_policy: isolated
|
||||
apparmor_profile: fedimint
|
||||
|
||||
|
||||
ports:
|
||||
- host: 8173
|
||||
container: 8173
|
||||
protocol: tcp # P2P
|
||||
protocol: tcp
|
||||
- host: 8174
|
||||
container: 8174
|
||||
protocol: tcp # API
|
||||
protocol: tcp
|
||||
- host: 8175
|
||||
container: 8175
|
||||
protocol: tcp # Built-in Guardian UI
|
||||
|
||||
protocol: tcp
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/fedimint
|
||||
target: /fedimint
|
||||
target: /data
|
||||
options: [rw]
|
||||
|
||||
|
||||
environment:
|
||||
- FM_DATA_DIR=/fedimint
|
||||
- FM_BITCOIND_URL=http://bitcoin-core:8332
|
||||
- FM_BITCOIND_USERNAME=${BITCOIN_RPC_USER}
|
||||
- FM_BITCOIND_PASSWORD=${BITCOIN_RPC_PASSWORD}
|
||||
- FM_DATA_DIR=/data
|
||||
- FM_BITCOIND_URL=http://host.archipelago:8332
|
||||
- FM_BITCOIND_USERNAME=archipelago
|
||||
- FM_BITCOIN_NETWORK=bitcoin
|
||||
- FM_BIND_P2P=0.0.0.0:8173
|
||||
- FM_BIND_API=0.0.0.0:8174
|
||||
- FM_BIND_UI=0.0.0.0:8175
|
||||
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://localhost:8175
|
||||
@@ -60,7 +66,7 @@ app:
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
|
||||
bitcoin_integration:
|
||||
rpc_access: admin
|
||||
sync_required: true
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
app:
|
||||
id: filebrowser
|
||||
name: File Browser
|
||||
version: 2.27.0
|
||||
description: Baseline Archipelago file manager service.
|
||||
|
||||
container:
|
||||
image: git.tx1138.com/lfg2025/filebrowser:v2.27.0
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
custom_args: ["--config", "/data/.filebrowser.json"]
|
||||
data_uid: "100000:100000"
|
||||
|
||||
dependencies:
|
||||
- storage: 10Gi
|
||||
|
||||
resources:
|
||||
memory_limit: 256Mi
|
||||
disk_limit: 10Gi
|
||||
|
||||
security:
|
||||
capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE, NET_BIND_SERVICE]
|
||||
readonly_root: false
|
||||
network_policy: isolated
|
||||
|
||||
ports:
|
||||
- host: 8083
|
||||
container: 80
|
||||
protocol: tcp
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/filebrowser
|
||||
target: /srv
|
||||
options: [rw]
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/filebrowser-data
|
||||
target: /data
|
||||
options: [rw]
|
||||
|
||||
environment: []
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://localhost:80
|
||||
path: /health
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
bitcoin_integration:
|
||||
rpc_access: none
|
||||
sync_required: false
|
||||
@@ -29,9 +29,8 @@ environment:
|
||||
GITEA__repository__ENABLE_PUSH_CREATE_USER: "true"
|
||||
GITEA__repository__ENABLE_PUSH_CREATE_ORG: "true"
|
||||
|
||||
# Gitea hardcodes X-Frame-Options: SAMEORIGIN which blocks iframe embedding.
|
||||
# Container binds to internal_port (3001), nginx proxies public port (3000)
|
||||
# stripping the X-Frame-Options header so the app works in Archipelago's iframe.
|
||||
# Gitea hardcodes X-Frame-Options: SAMEORIGIN, so Archipelago opens it in a
|
||||
# new tab on host port 3001 instead of embedding it in an iframe.
|
||||
nginx_proxy:
|
||||
listen: 3000
|
||||
proxy_pass: "http://127.0.0.1:3001"
|
||||
|
||||
@@ -8,6 +8,7 @@ app:
|
||||
image: grafana/grafana:10.2.0
|
||||
image_signature: cosign://...
|
||||
pull_policy: if-not-present
|
||||
data_uid: "472:472"
|
||||
|
||||
dependencies:
|
||||
- storage: 5Gi
|
||||
@@ -27,7 +28,7 @@ app:
|
||||
apparmor_profile: grafana
|
||||
|
||||
ports:
|
||||
- host: 3001
|
||||
- host: 3000
|
||||
container: 3000
|
||||
protocol: tcp # Web UI
|
||||
|
||||
@@ -40,12 +41,12 @@ app:
|
||||
environment:
|
||||
- GF_SECURITY_ADMIN_USER=admin
|
||||
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD}
|
||||
- GF_SERVER_ROOT_URL=http://localhost:3001
|
||||
- GF_SERVER_ROOT_URL=http://localhost:3000
|
||||
- GF_INSTALL_PLUGINS=
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://localhost:3001
|
||||
endpoint: http://localhost:3000
|
||||
path: /api/health
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
|
||||
@@ -53,7 +53,7 @@ app:
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://localhost:8123
|
||||
path: /api/
|
||||
path: /
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
@@ -6,8 +6,9 @@ app:
|
||||
category: media
|
||||
|
||||
container:
|
||||
image: git.tx1138.com/lfg2025/indeedhub:latest
|
||||
image: 146.59.87.168:3000/lfg2025/indeedhub:latest
|
||||
pull_policy: always # Pull from registry; falls back to local build
|
||||
network: indeedhub-net
|
||||
|
||||
dependencies:
|
||||
- storage: 1Gi
|
||||
@@ -27,9 +28,9 @@ app:
|
||||
apparmor_profile: default
|
||||
|
||||
ports:
|
||||
- host: 7777
|
||||
container: 3000
|
||||
protocol: tcp # Web UI (Next.js)
|
||||
- host: 7778
|
||||
container: 7777
|
||||
protocol: tcp # Web UI. Port 7777 on the host is reserved for Nostr relay.
|
||||
|
||||
volumes:
|
||||
- type: tmpfs
|
||||
@@ -38,6 +39,12 @@ app:
|
||||
- type: tmpfs
|
||||
target: /app/.next/cache
|
||||
options: [rw,noexec,nosuid,size=128m]
|
||||
- type: tmpfs
|
||||
target: /run
|
||||
options: [rw,nosuid,nodev,size=16m]
|
||||
- type: tmpfs
|
||||
target: /var/cache/nginx
|
||||
options: [rw,nosuid,nodev,size=32m]
|
||||
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
@@ -57,7 +64,7 @@ app:
|
||||
name: Web UI
|
||||
description: Stream Bitcoin documentaries with Nostr identity
|
||||
type: ui
|
||||
port: 7777
|
||||
port: 7778
|
||||
protocol: http
|
||||
path: /
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
app:
|
||||
id: lnd-ui
|
||||
name: LND UI
|
||||
version: 1.0.0
|
||||
description: |
|
||||
Archipelago-native HTTP frontend for LND. Runs nginx inside a
|
||||
container and serves static assets. LND connection info is fetched
|
||||
via an absolute URL that the host nginx routes to the archipelago
|
||||
backend on 127.0.0.1:5678, so no upstream auth is baked in.
|
||||
|
||||
container:
|
||||
build:
|
||||
context: /opt/archipelago/docker/lnd-ui
|
||||
dockerfile: Dockerfile
|
||||
tag: localhost/lnd-ui:local
|
||||
|
||||
dependencies:
|
||||
- app_id: lnd
|
||||
|
||||
resources:
|
||||
memory_limit: 64Mi
|
||||
|
||||
security:
|
||||
readonly_root: false
|
||||
network_policy: bridge
|
||||
|
||||
# Bridge networking via archy-net. Container nginx listens on 80;
|
||||
# host nginx proxies /app/lnd/ -> 127.0.0.1:18083 -> container:80.
|
||||
ports:
|
||||
- host: 18083
|
||||
container: 80
|
||||
protocol: tcp
|
||||
|
||||
volumes: []
|
||||
|
||||
environment: []
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://127.0.0.1:18083
|
||||
path: /
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
+28
-30
@@ -1,67 +1,65 @@
|
||||
app:
|
||||
id: lnd
|
||||
name: Lightning Network Daemon
|
||||
version: 0.18.0
|
||||
version: 0.18.4
|
||||
description: Lightning Network implementation by Lightning Labs. Enables instant, low-cost Bitcoin payments.
|
||||
|
||||
|
||||
container:
|
||||
image: lightninglabs/lnd:v0.18.0
|
||||
image_signature: cosign://...
|
||||
pull_policy: verify-signature
|
||||
|
||||
image: git.tx1138.com/lfg2025/lnd:v0.18.4-beta
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
secret_env:
|
||||
- key: BITCOIND_RPCPASS
|
||||
secret_file: bitcoin-rpc-password
|
||||
data_uid: "100000:100000"
|
||||
|
||||
dependencies:
|
||||
- app_id: bitcoin-core
|
||||
version: ">=26.0"
|
||||
|
||||
|
||||
resources:
|
||||
cpu_limit: 2
|
||||
memory_limit: 1Gi
|
||||
disk_limit: 10Gi
|
||||
|
||||
|
||||
security:
|
||||
capabilities: [NET_BIND_SERVICE]
|
||||
readonly_root: true
|
||||
no_new_privileges: true
|
||||
user: 1000
|
||||
seccomp_profile: default
|
||||
capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE, NET_RAW]
|
||||
readonly_root: false
|
||||
network_policy: isolated
|
||||
apparmor_profile: lnd
|
||||
|
||||
|
||||
ports:
|
||||
- host: 9735
|
||||
container: 9735
|
||||
protocol: tcp # P2P
|
||||
protocol: tcp
|
||||
- host: 10009
|
||||
container: 10009
|
||||
protocol: tcp # gRPC
|
||||
- host: 8080
|
||||
protocol: tcp
|
||||
- host: 18080
|
||||
container: 8080
|
||||
protocol: tcp # REST
|
||||
|
||||
protocol: tcp
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/lnd
|
||||
target: /root/.lnd
|
||||
options: [rw]
|
||||
|
||||
|
||||
environment:
|
||||
- BITCOIND_HOST=bitcoin-core
|
||||
- BITCOIND_RPCUSER=${BITCOIN_RPC_USER}
|
||||
- BITCOIND_RPCPASS=${BITCOIN_RPC_PASSWORD}
|
||||
- BITCOIND_HOST=bitcoin-knots
|
||||
- BITCOIND_RPCUSER=archipelago
|
||||
- NETWORK=mainnet
|
||||
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://localhost:8080
|
||||
path: /v1/getinfo
|
||||
type: tcp
|
||||
endpoint: localhost:10009
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
|
||||
bitcoin_integration:
|
||||
rpc_access: admin
|
||||
sync_required: true
|
||||
|
||||
|
||||
lightning_integration:
|
||||
channel_management: true
|
||||
payment_routing: true
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
app:
|
||||
id: mempool-api
|
||||
name: Mempool API
|
||||
version: 3.0.0
|
||||
description: Backend API for mempool explorer.
|
||||
|
||||
container:
|
||||
image: git.tx1138.com/lfg2025/mempool-backend:v3.0.0
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
secret_env:
|
||||
- key: CORE_RPC_PASSWORD
|
||||
secret_file: bitcoin-rpc-password
|
||||
- key: DATABASE_PASSWORD
|
||||
secret_file: mempool-db-password
|
||||
|
||||
dependencies:
|
||||
- app_id: bitcoin-knots
|
||||
version: ">=26.0"
|
||||
- app_id: electrumx
|
||||
version: ">=1.18.0"
|
||||
- app_id: archy-mempool-db
|
||||
version: ">=11.4.10"
|
||||
|
||||
resources:
|
||||
memory_limit: 2Gi
|
||||
disk_limit: 20Gi
|
||||
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: false
|
||||
network_policy: isolated
|
||||
|
||||
ports:
|
||||
- host: 8999
|
||||
container: 8999
|
||||
protocol: tcp
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/mempool
|
||||
target: /data
|
||||
options: [rw]
|
||||
|
||||
environment:
|
||||
- MEMPOOL_BACKEND=electrum
|
||||
- ELECTRUM_HOST=electrumx
|
||||
- ELECTRUM_PORT=50001
|
||||
- ELECTRUM_TLS_ENABLED=false
|
||||
- CORE_RPC_HOST=bitcoin-knots
|
||||
- CORE_RPC_PORT=8332
|
||||
- CORE_RPC_USERNAME=archipelago
|
||||
- DATABASE_ENABLED=true
|
||||
- DATABASE_HOST=archy-mempool-db
|
||||
- DATABASE_DATABASE=mempool
|
||||
- DATABASE_USERNAME=mempool
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://localhost:8999
|
||||
path: /api/v1/backend-info
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
bitcoin_integration:
|
||||
rpc_access: read-only
|
||||
sync_required: true
|
||||
pruning_support: false
|
||||
@@ -8,6 +8,7 @@ app:
|
||||
image: scsibug/nostr-rs-relay:0.8.9
|
||||
image_signature: cosign://...
|
||||
pull_policy: verify-signature
|
||||
data_uid: "1000:1000"
|
||||
|
||||
dependencies:
|
||||
- storage: 10Gi # For event storage
|
||||
@@ -34,7 +35,7 @@ app:
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/nostr-relay
|
||||
target: /app/db
|
||||
target: /usr/src/app/db
|
||||
options: [rw]
|
||||
|
||||
environment:
|
||||
@@ -45,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,5 +0,0 @@
|
||||
# Ollama - uses official image
|
||||
FROM ollama/ollama:latest
|
||||
|
||||
# Default configuration is in the image
|
||||
# No additional setup needed
|
||||
@@ -1,50 +0,0 @@
|
||||
app:
|
||||
id: ollama
|
||||
name: Ollama
|
||||
version: 0.1.0
|
||||
description: Run large language models locally. Privacy-preserving AI on your node.
|
||||
|
||||
container:
|
||||
image: ollama/ollama:0.6.2
|
||||
image_signature: cosign://...
|
||||
pull_policy: if-not-present
|
||||
|
||||
dependencies:
|
||||
- storage: 50Gi # Models can be large
|
||||
|
||||
resources:
|
||||
cpu_limit: 4
|
||||
memory_limit: 8Gi # LLMs need lots of RAM
|
||||
disk_limit: 50Gi
|
||||
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: false # Ollama needs write access for models
|
||||
no_new_privileges: true
|
||||
user: 1000
|
||||
seccomp_profile: default
|
||||
network_policy: isolated
|
||||
apparmor_profile: ollama
|
||||
|
||||
ports:
|
||||
- host: 11434
|
||||
container: 11434
|
||||
protocol: tcp # API
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/ollama
|
||||
target: /root/.ollama
|
||||
options: [rw]
|
||||
|
||||
environment:
|
||||
- OLLAMA_HOST=0.0.0.0:11434
|
||||
- OLLAMA_KEEP_ALIVE=24h
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://localhost:11434
|
||||
path: /api/tags
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
@@ -1,5 +0,0 @@
|
||||
# Penpot - uses official image
|
||||
FROM penpot/penpot:latest
|
||||
|
||||
# Default configuration is in the image
|
||||
# No additional setup needed
|
||||
@@ -1,51 +0,0 @@
|
||||
app:
|
||||
id: penpot
|
||||
name: Penpot
|
||||
version: 2.0.0
|
||||
description: Open-source design and prototyping platform. Design tools for teams.
|
||||
|
||||
container:
|
||||
image: penpotapp/frontend:2.13.3
|
||||
image_signature: cosign://...
|
||||
pull_policy: if-not-present
|
||||
|
||||
dependencies:
|
||||
- storage: 10Gi
|
||||
|
||||
resources:
|
||||
cpu_limit: 4
|
||||
memory_limit: 4Gi
|
||||
disk_limit: 10Gi
|
||||
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: true
|
||||
no_new_privileges: true
|
||||
user: 1000
|
||||
seccomp_profile: default
|
||||
network_policy: isolated
|
||||
apparmor_profile: penpot
|
||||
|
||||
ports:
|
||||
- host: 8089
|
||||
container: 80
|
||||
protocol: tcp # Web UI
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/penpot
|
||||
target: /app/data
|
||||
options: [rw]
|
||||
|
||||
environment:
|
||||
- PENPOT_PUBLIC_URI=http://localhost:8089
|
||||
- PENPOT_DATABASE_URI=postgresql://penpot:penpot@penpot-db:5432/penpot
|
||||
- PENPOT_REDIS_URI=redis://penpot-redis:6379
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://localhost:8089
|
||||
path: /api/health
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
@@ -1,12 +1,11 @@
|
||||
app:
|
||||
id: searxng
|
||||
name: SearXNG
|
||||
version: 2024.1.0
|
||||
version: 1.0.0
|
||||
description: Privacy-respecting metasearch engine. Search the web without tracking.
|
||||
|
||||
container:
|
||||
image: searxng/searxng:2024.1.0
|
||||
image_signature: cosign://...
|
||||
image: 146.59.87.168:3000/lfg2025/searxng:latest
|
||||
pull_policy: if-not-present
|
||||
|
||||
dependencies:
|
||||
@@ -43,7 +42,7 @@ app:
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://localhost:8888
|
||||
endpoint: http://localhost:8080
|
||||
path: /
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
|
||||
Generated
+2
-1
@@ -80,13 +80,14 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.7.33-alpha"
|
||||
version = "1.7.69-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
"archipelago-performance",
|
||||
"archipelago-security",
|
||||
"argon2",
|
||||
"async-trait",
|
||||
"base64 0.21.7",
|
||||
"bcrypt",
|
||||
"bip39",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.7.33-alpha"
|
||||
version = "1.7.70-alpha"
|
||||
edition = "2021"
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
authors = ["Archipelago Team"]
|
||||
@@ -103,6 +103,9 @@ mdns-sd = "0.18"
|
||||
# Systemd watchdog notification
|
||||
sd-notify = "0.4"
|
||||
|
||||
# Trait objects for async methods (container orchestrator trait, Step 4)
|
||||
async-trait = "0.1"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = "0.4"
|
||||
tempfile = "3.10"
|
||||
|
||||
@@ -10,6 +10,7 @@ mod websocket;
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use crate::blobs::BlobStore;
|
||||
use crate::config::Config;
|
||||
use crate::container::{ContainerOrchestrator, DevContainerOrchestrator};
|
||||
use crate::monitoring::MetricsStore;
|
||||
use crate::session::{self, SessionStore};
|
||||
use crate::state::StateManager;
|
||||
@@ -54,6 +55,8 @@ impl ApiHandler {
|
||||
config: Config,
|
||||
state_manager: Arc<StateManager>,
|
||||
metrics_store: Arc<MetricsStore>,
|
||||
orchestrator: Option<Arc<dyn ContainerOrchestrator>>,
|
||||
dev_orchestrator: Option<Arc<DevContainerOrchestrator>>,
|
||||
) -> Result<Self> {
|
||||
let session_store = SessionStore::new().await;
|
||||
let rpc_handler = Arc::new(
|
||||
@@ -62,6 +65,8 @@ impl ApiHandler {
|
||||
state_manager.clone(),
|
||||
metrics_store.clone(),
|
||||
session_store.clone(),
|
||||
orchestrator,
|
||||
dev_orchestrator,
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
@@ -115,14 +120,40 @@ impl ApiHandler {
|
||||
|
||||
/// Server-side fetch of the upstream app catalog so the browser can
|
||||
/// load it without fighting CORS (git.tx1138.com emits no ACAO) or
|
||||
/// CSP (the fallback IP-port URL isn't in `connect-src`). Tries the
|
||||
/// upstream URLs in the same order the frontend used, returns the
|
||||
/// first 2xx response. 15s total timeout.
|
||||
async fn handle_app_catalog_proxy() -> Result<Response<hyper::Body>> {
|
||||
const UPSTREAMS: &[&str] = &[
|
||||
"http://23.182.128.160:3000/lfg2025/app-catalog/raw/branch/main/catalog.json",
|
||||
"https://git.tx1138.com/lfg2025/app-catalog/raw/branch/main/catalog.json",
|
||||
];
|
||||
/// CSP (the fallback IP-port URL isn't in `connect-src`). The upstream
|
||||
/// list is derived from the operator's configured container registries
|
||||
/// so switching mirrors in Settings changes the App Store source too —
|
||||
/// each active registry contributes one Gitea `raw/branch/main/catalog.json`
|
||||
/// URL (http or https per `tls_verify`), tried in priority order.
|
||||
/// If registry config can't be loaded, falls back to the legacy
|
||||
/// hardcoded pair so the App Store still renders on nodes that haven't
|
||||
/// persisted a registry config yet. 15s total timeout.
|
||||
async fn handle_app_catalog_proxy(&self) -> Result<Response<hyper::Body>> {
|
||||
let mut upstreams: Vec<String> = Vec::new();
|
||||
if let Ok(config) = crate::container::registry::load_registries(&self.config.data_dir).await
|
||||
{
|
||||
for reg in config.active_registries() {
|
||||
let scheme = if reg.tls_verify { "https" } else { "http" };
|
||||
// Gitea raw URL: <scheme>://<host>/<namespace>/app-catalog/raw/branch/main/catalog.json.
|
||||
// reg.url already includes the namespace (e.g. "host/lfg2025"),
|
||||
// so we just tack on the repo + raw path.
|
||||
upstreams.push(format!(
|
||||
"{}://{}/app-catalog/raw/branch/main/catalog.json",
|
||||
scheme, reg.url
|
||||
));
|
||||
}
|
||||
}
|
||||
if upstreams.is_empty() {
|
||||
upstreams.push(
|
||||
"http://146.59.87.168:3000/lfg2025/app-catalog/raw/branch/main/catalog.json"
|
||||
.to_string(),
|
||||
);
|
||||
upstreams.push(
|
||||
"https://git.tx1138.com/lfg2025/app-catalog/raw/branch/main/catalog.json"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let client = match reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.build()
|
||||
@@ -136,8 +167,8 @@ impl ApiHandler {
|
||||
));
|
||||
}
|
||||
};
|
||||
for url in UPSTREAMS {
|
||||
match client.get(*url).send().await {
|
||||
for url in &upstreams {
|
||||
match client.get(url).send().await {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
if let Ok(bytes) = resp.bytes().await {
|
||||
return Ok(Response::builder()
|
||||
@@ -289,7 +320,7 @@ impl ApiHandler {
|
||||
|
||||
match (method, path.as_str()) {
|
||||
// RPC — auth is handled inside rpc handler per-method
|
||||
(Method::POST, "/rpc/v1") => self.rpc_handler.handle(req_with_bytes).await,
|
||||
(Method::POST, "/rpc/v1") => self.rpc_handler.clone().handle(req_with_bytes).await,
|
||||
|
||||
// Health — unauthenticated, returns JSON with service status
|
||||
(Method::GET, "/health") => {
|
||||
@@ -398,6 +429,7 @@ impl ApiHandler {
|
||||
|
||||
// Electrs status — unauthenticated (read-only sync status)
|
||||
(Method::GET, "/electrs-status") => Self::handle_electrs_status().await,
|
||||
(Method::GET, "/bitcoin-status") => Self::handle_bitcoin_status().await,
|
||||
|
||||
// App-catalog proxy — fetches catalog.json from the configured
|
||||
// upstream URLs server-side so the browser doesn't hit CORS
|
||||
@@ -408,7 +440,7 @@ impl ApiHandler {
|
||||
if !self.is_authenticated(&headers).await {
|
||||
return Ok(Self::unauthorized());
|
||||
}
|
||||
Self::handle_app_catalog_proxy().await
|
||||
self.handle_app_catalog_proxy().await
|
||||
}
|
||||
|
||||
// LND connect info — nginx validates session cookie (presence check),
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
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;
|
||||
use anyhow::Result;
|
||||
use hyper::{Response, StatusCode};
|
||||
@@ -76,11 +78,23 @@ impl ApiHandler {
|
||||
pub(super) async fn handle_electrs_status() -> Result<Response<hyper::Body>> {
|
||||
let status = electrs_status::get_electrs_sync_status().await;
|
||||
let body = serde_json::to_vec(&status).unwrap_or_default();
|
||||
Ok(build_response(
|
||||
StatusCode::OK,
|
||||
"application/json",
|
||||
hyper::Body::from(body),
|
||||
))
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Cache-Control", "no-store")
|
||||
.body(hyper::Body::from(body))
|
||||
.unwrap_or_else(|_| Response::new(hyper::Body::from("{}"))))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_bitcoin_status() -> Result<Response<hyper::Body>> {
|
||||
let status = bitcoin_status::get_bitcoin_status().await;
|
||||
let body = serde_json::to_vec(&status).unwrap_or_default();
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Cache-Control", "no-store")
|
||||
.body(hyper::Body::from(body))
|
||||
.unwrap_or_else(|_| Response::new(hyper::Body::from("{}"))))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_lnd_connect_info(
|
||||
@@ -110,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();
|
||||
|
||||
@@ -3,6 +3,55 @@ use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use zeroize::Zeroize;
|
||||
|
||||
/// Retry configuration for [`bitcoin_rpc_post_with_retry`].
|
||||
///
|
||||
/// Exposed as a struct (rather than hard-coded constants inside the function)
|
||||
/// so tests can dial down timeouts to keep the suite fast while still
|
||||
/// exercising real retry/backoff behavior.
|
||||
#[derive(Debug, Clone)]
|
||||
struct RetryConfig {
|
||||
max_attempts: u32,
|
||||
attempt_timeout: std::time::Duration,
|
||||
/// Length must equal `max_attempts - 1` (one backoff between each
|
||||
/// successive attempt). The last attempt is not followed by a backoff.
|
||||
backoffs: Vec<std::time::Duration>,
|
||||
}
|
||||
|
||||
impl RetryConfig {
|
||||
/// Production retry policy: 3 attempts, 15s each, 500ms + 1500ms backoffs.
|
||||
/// Total worst-case wall time: 3 * 15 + 0.5 + 1.5 = 47s.
|
||||
fn production() -> Self {
|
||||
Self {
|
||||
max_attempts: BITCOIN_RPC_MAX_ATTEMPTS,
|
||||
attempt_timeout: BITCOIN_RPC_ATTEMPT_TIMEOUT,
|
||||
backoffs: BITCOIN_RPC_BACKOFFS.to_vec(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Max retry attempts for a single bitcoin_rpc_call invocation.
|
||||
/// First attempt + 2 retries = 3 total.
|
||||
const BITCOIN_RPC_MAX_ATTEMPTS: u32 = 3;
|
||||
|
||||
/// Per-attempt deadline. Must be >= the reqwest client's own timeout (we
|
||||
/// build it at 15s in handle_bitcoin_getinfo) — this is the outer safety net.
|
||||
const BITCOIN_RPC_ATTEMPT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
|
||||
|
||||
/// Backoff between attempts. Index 0 = after first failure, 1 = after second, etc.
|
||||
/// Chosen to absorb bitcoind's typical block-validation stall (2-5s) without
|
||||
/// adding noticeable latency on the happy path (first attempt succeeds in ~30ms).
|
||||
const BITCOIN_RPC_BACKOFFS: [std::time::Duration; 2] = [
|
||||
std::time::Duration::from_millis(500),
|
||||
std::time::Duration::from_millis(1500),
|
||||
];
|
||||
|
||||
/// Classify a reqwest error as transient (retryable) or fatal.
|
||||
/// Transient: timeout, connect refused, request/response body IO errors.
|
||||
/// Fatal: TLS errors, URL parse errors, redirect loops, builder errors.
|
||||
fn is_transient_transport_error(e: &reqwest::Error) -> bool {
|
||||
e.is_timeout() || e.is_connect() || e.is_request() || e.is_body()
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct BitcoinInfo {
|
||||
block_height: u64,
|
||||
@@ -37,8 +86,15 @@ struct MempoolInfo {
|
||||
|
||||
impl RpcHandler {
|
||||
pub(super) async fn handle_bitcoin_getinfo(&self) -> Result<serde_json::Value> {
|
||||
// Per-attempt timeout (see bitcoin_rpc_call for retry semantics).
|
||||
// 15s is enough room for bitcoind to answer getblockchaininfo even
|
||||
// during block validation; bitcoin_rpc_call wraps each attempt in a
|
||||
// separate tokio::time::timeout too, so this is belt-and-suspenders.
|
||||
// connect_timeout is tighter so a dead bitcoind doesn't steal the
|
||||
// whole attempt budget on TCP connect alone.
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.connect_timeout(std::time::Duration::from_secs(3))
|
||||
.build()
|
||||
.context("Failed to create HTTP client")?;
|
||||
|
||||
@@ -68,6 +124,19 @@ impl RpcHandler {
|
||||
Ok(serde_json::to_value(info)?)
|
||||
}
|
||||
|
||||
/// Call a Bitcoin Core JSON-RPC method.
|
||||
///
|
||||
/// Retries up to [`BITCOIN_RPC_MAX_ATTEMPTS`] times on transient
|
||||
/// transport errors (timeout / connection refused / send/recv IO).
|
||||
/// Does **not** retry when bitcoind responds with a well-formed
|
||||
/// `{"error": ...}` body — those are real RPC errors and surfacing
|
||||
/// them quickly is the right behavior.
|
||||
///
|
||||
/// Motivation: on a syncing pruned node, bitcoind's RPC thread can block
|
||||
/// for 5-10 seconds during block validation. A single 10s timeout means
|
||||
/// ~30% of UI calls error out even though the node is perfectly healthy.
|
||||
/// With retry + backoff, the UI sees a uniform slow-but-successful
|
||||
/// response instead of intermittent failures.
|
||||
async fn bitcoin_rpc_call<T: serde::de::DeserializeOwned>(
|
||||
&self,
|
||||
client: &reqwest::Client,
|
||||
@@ -75,33 +144,15 @@ impl RpcHandler {
|
||||
params: &[serde_json::Value],
|
||||
) -> Result<T> {
|
||||
let (rpc_user, rpc_pass) = crate::bitcoin_rpc::bitcoin_rpc_credentials().await;
|
||||
let body = serde_json::json!({
|
||||
"jsonrpc": "1.0",
|
||||
"id": "archy",
|
||||
"method": method,
|
||||
"params": params,
|
||||
});
|
||||
|
||||
let resp = client
|
||||
.post(crate::constants::BITCOIN_RPC_URL)
|
||||
.basic_auth(&rpc_user, Some(&rpc_pass))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.context("Bitcoin RPC connection failed")?;
|
||||
|
||||
let rpc_resp: BitcoinRpcResponse<T> = resp
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse Bitcoin RPC response")?;
|
||||
|
||||
if let Some(err) = rpc_resp.error {
|
||||
anyhow::bail!("Bitcoin RPC error: {}", err);
|
||||
}
|
||||
|
||||
rpc_resp
|
||||
.result
|
||||
.ok_or_else(|| anyhow::anyhow!("Bitcoin RPC returned null result"))
|
||||
bitcoin_rpc_post_with_retry(
|
||||
client,
|
||||
crate::constants::BITCOIN_RPC_URL,
|
||||
&rpc_user,
|
||||
&rpc_pass,
|
||||
method,
|
||||
params,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Initialize a Bitcoin Core descriptor wallet with keys derived from the master seed.
|
||||
@@ -243,3 +294,351 @@ impl RpcHandler {
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// Free-function counterpart to `RpcHandler::bitcoin_rpc_call`.
|
||||
///
|
||||
/// Takes the URL + credentials as parameters so it can be exercised by unit
|
||||
/// tests against a mock HTTP server without constructing a full `RpcHandler`.
|
||||
///
|
||||
/// Production callers go through `RpcHandler::bitcoin_rpc_call`, which loads
|
||||
/// credentials from the secrets file and points at `BITCOIN_RPC_URL`.
|
||||
async fn bitcoin_rpc_post_with_retry<T: serde::de::DeserializeOwned>(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
rpc_user: &str,
|
||||
rpc_pass: &str,
|
||||
method: &str,
|
||||
params: &[serde_json::Value],
|
||||
) -> Result<T> {
|
||||
bitcoin_rpc_post_with_retry_cfg(
|
||||
client,
|
||||
url,
|
||||
rpc_user,
|
||||
rpc_pass,
|
||||
method,
|
||||
params,
|
||||
&RetryConfig::production(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Inner implementation with configurable retry policy (for tests).
|
||||
async fn bitcoin_rpc_post_with_retry_cfg<T: serde::de::DeserializeOwned>(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
rpc_user: &str,
|
||||
rpc_pass: &str,
|
||||
method: &str,
|
||||
params: &[serde_json::Value],
|
||||
cfg: &RetryConfig,
|
||||
) -> Result<T> {
|
||||
debug_assert_eq!(
|
||||
cfg.backoffs.len(),
|
||||
(cfg.max_attempts - 1) as usize,
|
||||
"RetryConfig: backoffs.len() must equal max_attempts - 1"
|
||||
);
|
||||
|
||||
let body = serde_json::json!({
|
||||
"jsonrpc": "1.0",
|
||||
"id": "archy",
|
||||
"method": method,
|
||||
"params": params,
|
||||
});
|
||||
|
||||
let mut last_err: Option<anyhow::Error> = None;
|
||||
for attempt in 0..cfg.max_attempts {
|
||||
if attempt > 0 {
|
||||
let backoff = cfg
|
||||
.backoffs
|
||||
.get(attempt as usize - 1)
|
||||
.copied()
|
||||
.unwrap_or_else(|| std::time::Duration::from_secs(2));
|
||||
tracing::warn!(
|
||||
"bitcoin_rpc({}): attempt {} failed, backing off {:?}",
|
||||
method,
|
||||
attempt,
|
||||
backoff
|
||||
);
|
||||
tokio::time::sleep(backoff).await;
|
||||
}
|
||||
|
||||
// Per-attempt hard deadline. Independent of reqwest's built-in timeout
|
||||
// so we always cap total time even if reqwest blocks on something
|
||||
// weird (e.g., DNS starvation).
|
||||
let fut = client
|
||||
.post(url)
|
||||
.basic_auth(rpc_user, Some(rpc_pass))
|
||||
.json(&body)
|
||||
.send();
|
||||
|
||||
let send_result = match tokio::time::timeout(cfg.attempt_timeout, fut).await {
|
||||
Err(_elapsed) => {
|
||||
last_err = Some(anyhow::anyhow!(
|
||||
"Bitcoin RPC send timed out after {:?}",
|
||||
cfg.attempt_timeout
|
||||
));
|
||||
continue; // transient: retry
|
||||
}
|
||||
Ok(r) => r,
|
||||
};
|
||||
|
||||
let resp = match send_result {
|
||||
Ok(r) => r,
|
||||
Err(e) if is_transient_transport_error(&e) => {
|
||||
last_err = Some(anyhow::Error::from(e).context("Bitcoin RPC connection failed"));
|
||||
continue; // transient: retry
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(anyhow::Error::from(e).context("Bitcoin RPC connection failed"));
|
||||
}
|
||||
};
|
||||
|
||||
let rpc_resp: BitcoinRpcResponse<T> = resp
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse Bitcoin RPC response")?;
|
||||
|
||||
if let Some(err) = rpc_resp.error {
|
||||
// RPC-level error: this is a real bitcoind response, not transient.
|
||||
anyhow::bail!("Bitcoin RPC error: {}", err);
|
||||
}
|
||||
|
||||
return rpc_resp
|
||||
.result
|
||||
.ok_or_else(|| anyhow::anyhow!("Bitcoin RPC returned null result"));
|
||||
}
|
||||
|
||||
Err(last_err
|
||||
.unwrap_or_else(|| anyhow::anyhow!("Bitcoin RPC exhausted retries with no error captured")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use hyper::service::{make_service_fn, service_fn};
|
||||
use hyper::{Body, Request, Response, Server, StatusCode};
|
||||
use std::convert::Infallible;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Spin up a mock bitcoind HTTP server that behaves according to `handler`.
|
||||
/// Returns the bound URL and a JoinHandle (dropped = server shutdown via the
|
||||
/// oneshot cancel channel).
|
||||
async fn spawn_mock<F, Fut>(
|
||||
handler: F,
|
||||
) -> (
|
||||
String,
|
||||
tokio::task::JoinHandle<()>,
|
||||
tokio::sync::oneshot::Sender<()>,
|
||||
)
|
||||
where
|
||||
F: Fn(Request<Body>) -> Fut + Send + Sync + Clone + 'static,
|
||||
Fut: std::future::Future<Output = Response<Body>> + Send + 'static,
|
||||
{
|
||||
let addr = SocketAddr::from(([127, 0, 0, 1], 0));
|
||||
let make_svc = make_service_fn(move |_| {
|
||||
let handler = handler.clone();
|
||||
async move {
|
||||
Ok::<_, Infallible>(service_fn(move |req| {
|
||||
let handler = handler.clone();
|
||||
async move { Ok::<_, Infallible>(handler(req).await) }
|
||||
}))
|
||||
}
|
||||
});
|
||||
let server = Server::bind(&addr).serve(make_svc);
|
||||
let url = format!("http://{}", server.local_addr());
|
||||
let (tx, rx) = tokio::sync::oneshot::channel::<()>();
|
||||
let handle = tokio::spawn(async move {
|
||||
let graceful = server.with_graceful_shutdown(async {
|
||||
let _ = rx.await;
|
||||
});
|
||||
let _ = graceful.await;
|
||||
});
|
||||
(url, handle, tx)
|
||||
}
|
||||
|
||||
/// Reply body bitcoind would send for a successful getblockcount.
|
||||
fn ok_reply() -> Body {
|
||||
Body::from(r#"{"result":42,"error":null,"id":"archy"}"#)
|
||||
}
|
||||
|
||||
fn err_reply() -> Body {
|
||||
Body::from(r#"{"result":null,"error":{"code":-8,"message":"nope"},"id":"archy"}"#)
|
||||
}
|
||||
|
||||
/// Succeeds on first attempt — should not retry.
|
||||
#[tokio::test]
|
||||
async fn happy_path_first_attempt() {
|
||||
let count = Arc::new(AtomicU32::new(0));
|
||||
let c = count.clone();
|
||||
let (url, _h, _tx) = spawn_mock(move |_req| {
|
||||
let c = c.clone();
|
||||
async move {
|
||||
c.fetch_add(1, Ordering::SeqCst);
|
||||
Response::new(ok_reply())
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
let client = reqwest::Client::builder().build().unwrap();
|
||||
let v: u64 =
|
||||
bitcoin_rpc_post_with_retry(&client, &url, "user", "pass", "getblockcount", &[])
|
||||
.await
|
||||
.expect("should succeed");
|
||||
assert_eq!(v, 42);
|
||||
assert_eq!(count.load(Ordering::SeqCst), 1, "should not have retried");
|
||||
}
|
||||
|
||||
/// HTTP 503 with non-JSON body: produces a JSON-parse error which is NOT
|
||||
/// classified as transient. Must fail after first attempt.
|
||||
/// This guards against the tempting mistake of blanket-retrying every
|
||||
/// non-2xx response — which would mask real bitcoind misconfig.
|
||||
#[tokio::test]
|
||||
async fn does_not_retry_parse_errors() {
|
||||
let count = Arc::new(AtomicU32::new(0));
|
||||
let c = count.clone();
|
||||
let (url, _h, _tx) = spawn_mock(move |_req| {
|
||||
let c = c.clone();
|
||||
async move {
|
||||
c.fetch_add(1, Ordering::SeqCst);
|
||||
Response::builder()
|
||||
.status(StatusCode::SERVICE_UNAVAILABLE)
|
||||
.body(Body::from("busy"))
|
||||
.unwrap()
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
let client = reqwest::Client::builder().build().unwrap();
|
||||
let result: Result<u64> =
|
||||
bitcoin_rpc_post_with_retry(&client, &url, "user", "pass", "getblockcount", &[]).await;
|
||||
assert!(result.is_err(), "non-JSON response should error out");
|
||||
assert_eq!(
|
||||
count.load(Ordering::SeqCst),
|
||||
1,
|
||||
"parse errors are not retryable"
|
||||
);
|
||||
}
|
||||
|
||||
/// Connect-refused (port closed) is the canonical transient transport
|
||||
/// error. Must exhaust BITCOIN_RPC_MAX_ATTEMPTS and the total elapsed
|
||||
/// time must include at least the sum of the backoffs.
|
||||
#[tokio::test]
|
||||
async fn retries_exhausted_on_persistent_connect_refused() {
|
||||
// Bind a port then immediately drop the listener so the port is closed.
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let closed_url = format!("http://{}", listener.local_addr().unwrap());
|
||||
drop(listener);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.connect_timeout(std::time::Duration::from_millis(500))
|
||||
.build()
|
||||
.unwrap();
|
||||
let start = std::time::Instant::now();
|
||||
let result: Result<u64> =
|
||||
bitcoin_rpc_post_with_retry(&client, &closed_url, "user", "pass", "getblockcount", &[])
|
||||
.await;
|
||||
let elapsed = start.elapsed();
|
||||
assert!(result.is_err(), "connect-refused should exhaust retries");
|
||||
let min_backoff: std::time::Duration = BITCOIN_RPC_BACKOFFS.iter().sum();
|
||||
assert!(
|
||||
elapsed >= min_backoff,
|
||||
"should have backed off between retries (elapsed={:?}, expected at least {:?})",
|
||||
elapsed,
|
||||
min_backoff
|
||||
);
|
||||
}
|
||||
|
||||
/// The motivating scenario: first attempt times out (bitcoind busy),
|
||||
/// subsequent attempt succeeds. Uses a short test-only RetryConfig so
|
||||
/// the test runs in <1s instead of 15s.
|
||||
#[tokio::test]
|
||||
async fn retries_on_timeout_then_succeeds() {
|
||||
let count = Arc::new(AtomicU32::new(0));
|
||||
let c = count.clone();
|
||||
// Mock server: first request hangs for 500ms, subsequent requests reply OK.
|
||||
let (url, _h, _tx) = spawn_mock(move |_req| {
|
||||
let c = c.clone();
|
||||
async move {
|
||||
let n = c.fetch_add(1, Ordering::SeqCst);
|
||||
if n == 0 {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||
}
|
||||
Response::new(ok_reply())
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
let client = reqwest::Client::builder().build().unwrap();
|
||||
// Attempt timeout 100ms < server's 500ms sleep => first attempt times out.
|
||||
// Backoff 20ms between attempts.
|
||||
let cfg = RetryConfig {
|
||||
max_attempts: 3,
|
||||
attempt_timeout: std::time::Duration::from_millis(100),
|
||||
backoffs: vec![
|
||||
std::time::Duration::from_millis(20),
|
||||
std::time::Duration::from_millis(20),
|
||||
],
|
||||
};
|
||||
let v: u64 = bitcoin_rpc_post_with_retry_cfg(
|
||||
&client,
|
||||
&url,
|
||||
"user",
|
||||
"pass",
|
||||
"getblockcount",
|
||||
&[],
|
||||
&cfg,
|
||||
)
|
||||
.await
|
||||
.expect("second attempt should succeed");
|
||||
assert_eq!(v, 42);
|
||||
assert!(
|
||||
count.load(Ordering::SeqCst) >= 2,
|
||||
"expected at least 2 attempts (got {})",
|
||||
count.load(Ordering::SeqCst)
|
||||
);
|
||||
}
|
||||
|
||||
/// bitcoind returned a well-formed `{"error": ...}` body. Must NOT retry.
|
||||
#[tokio::test]
|
||||
async fn does_not_retry_on_rpc_level_error() {
|
||||
let count = Arc::new(AtomicU32::new(0));
|
||||
let c = count.clone();
|
||||
let (url, _h, _tx) = spawn_mock(move |_req| {
|
||||
let c = c.clone();
|
||||
async move {
|
||||
c.fetch_add(1, Ordering::SeqCst);
|
||||
Response::new(err_reply())
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
let client = reqwest::Client::builder().build().unwrap();
|
||||
let result: Result<u64> =
|
||||
bitcoin_rpc_post_with_retry(&client, &url, "user", "pass", "getblockcount", &[]).await;
|
||||
assert!(result.is_err());
|
||||
assert_eq!(
|
||||
count.load(Ordering::SeqCst),
|
||||
1,
|
||||
"RPC-level errors are not transient"
|
||||
);
|
||||
}
|
||||
|
||||
/// Sanity: retry budget invariants. Chosen to catch regressions where
|
||||
/// someone bumps these constants without realizing the total worst-case
|
||||
/// wall time implications.
|
||||
#[test]
|
||||
fn retry_budget_invariants() {
|
||||
assert_eq!(BITCOIN_RPC_MAX_ATTEMPTS, 3);
|
||||
assert_eq!(
|
||||
BITCOIN_RPC_BACKOFFS.len(),
|
||||
(BITCOIN_RPC_MAX_ATTEMPTS - 1) as usize
|
||||
);
|
||||
// Total wall-time ceiling:
|
||||
// 3 attempts * 15s + (0.5s + 1.5s) backoff = 47s
|
||||
let total: std::time::Duration = BITCOIN_RPC_ATTEMPT_TIMEOUT * BITCOIN_RPC_MAX_ATTEMPTS
|
||||
+ BITCOIN_RPC_BACKOFFS.iter().sum::<std::time::Duration>();
|
||||
assert!(total < std::time::Duration::from_secs(60));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
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(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let orchestrator = self.orchestrator.as_ref().ok_or_else(|| {
|
||||
anyhow::anyhow!("Container orchestrator not available (dev mode required)")
|
||||
// The `container-install { manifest_path }` RPC is a dev-mode convenience
|
||||
// that points at an arbitrary YAML on disk. Production install happens via
|
||||
// the reconciler (BootReconciler, Step 5) and via the unified
|
||||
// ContainerOrchestrator::install(app_id) trait call, which can be exposed
|
||||
// through a separate `container-install-by-id` RPC when needed.
|
||||
let dev = self.dev_orchestrator.as_ref().ok_or_else(|| {
|
||||
anyhow::anyhow!("container-install with manifest_path is only available in dev mode")
|
||||
})?;
|
||||
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
@@ -45,7 +55,7 @@ impl RpcHandler {
|
||||
let manifest: archipelago_container::AppManifest =
|
||||
serde_yaml::from_str(&manifest_content).context("Failed to parse manifest")?;
|
||||
|
||||
let container_name = orchestrator
|
||||
let container_name = dev
|
||||
.install_container(&manifest, manifest_path)
|
||||
.await
|
||||
.context("Failed to install container")?;
|
||||
@@ -57,10 +67,6 @@ impl RpcHandler {
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let orchestrator = self.orchestrator.as_ref().ok_or_else(|| {
|
||||
anyhow::anyhow!("Container orchestrator not available (dev mode required)")
|
||||
})?;
|
||||
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let app_id = params
|
||||
.get("app_id")
|
||||
@@ -68,22 +74,24 @@ impl RpcHandler {
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
|
||||
validate_app_id(app_id)?;
|
||||
|
||||
orchestrator
|
||||
.start_container(app_id)
|
||||
.await
|
||||
.context("Failed to start container")?;
|
||||
// User explicitly started the app — clear the user-stopped marker so
|
||||
// crash recovery / health monitor won't second-guess it. Must happen
|
||||
// BEFORE the spawn (see runtime.rs:145-148 for the symmetric stop
|
||||
// side and the ordering contract crash recovery depends on).
|
||||
crate::crash_recovery::clear_user_stopped(&self.config.data_dir, app_id).await;
|
||||
|
||||
Ok(serde_json::json!({ "status": "started" }))
|
||||
// spawn_transitional returns as soon as the background task is
|
||||
// launched (<1s). The UI sees Starting… immediately via WebSocket.
|
||||
self.spawn_transitional(Op::Start, app_id.to_string())
|
||||
.await?;
|
||||
|
||||
Ok(serde_json::json!({ "status": "starting" }))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_container_stop(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let orchestrator = self.orchestrator.as_ref().ok_or_else(|| {
|
||||
anyhow::anyhow!("Container orchestrator not available (dev mode required)")
|
||||
})?;
|
||||
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let app_id = params
|
||||
.get("app_id")
|
||||
@@ -91,21 +99,51 @@ impl RpcHandler {
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
|
||||
validate_app_id(app_id)?;
|
||||
|
||||
orchestrator
|
||||
.stop_container(app_id)
|
||||
.await
|
||||
.context("Failed to stop container")?;
|
||||
// Mark as user-stopped BEFORE the spawn — ordering is load-bearing
|
||||
// (crash recovery / health monitor inspect this flag concurrently
|
||||
// with the in-flight stop; see runtime.rs:145-148 for the package
|
||||
// path that also writes this in the same order).
|
||||
crate::crash_recovery::mark_user_stopped(&self.config.data_dir, app_id).await;
|
||||
|
||||
Ok(serde_json::json!({ "status": "stopped" }))
|
||||
// podman stop -t 600 (bitcoin-core) / -t 330 (lnd) runs in the
|
||||
// background; the RPC returns now with "stopping".
|
||||
self.spawn_transitional(Op::Stop, app_id.to_string())
|
||||
.await?;
|
||||
|
||||
Ok(serde_json::json!({ "status": "stopping" }))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_container_restart(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let app_id = params
|
||||
.get("app_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
|
||||
validate_app_id(app_id)?;
|
||||
|
||||
// Restart does not mark user-stopped (the user wants the app to
|
||||
// keep running). Clear the marker as a defensive measure in case a
|
||||
// prior stop left it set and the restart is intended to revive the
|
||||
// normal running state.
|
||||
crate::crash_recovery::clear_user_stopped(&self.config.data_dir, app_id).await;
|
||||
|
||||
self.spawn_transitional(Op::Restart, app_id.to_string())
|
||||
.await?;
|
||||
|
||||
Ok(serde_json::json!({ "status": "restarting" }))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_container_remove(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let orchestrator = self.orchestrator.as_ref().ok_or_else(|| {
|
||||
anyhow::anyhow!("Container orchestrator not available (dev mode required)")
|
||||
})?;
|
||||
let orchestrator = self
|
||||
.orchestrator
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available"))?;
|
||||
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let app_id = params
|
||||
@@ -119,7 +157,7 @@ impl RpcHandler {
|
||||
.unwrap_or(false);
|
||||
|
||||
orchestrator
|
||||
.remove_container(app_id, preserve_data)
|
||||
.remove(app_id, preserve_data)
|
||||
.await
|
||||
.context("Failed to remove container")?;
|
||||
|
||||
@@ -137,12 +175,25 @@ impl RpcHandler {
|
||||
.package_data
|
||||
.iter()
|
||||
.map(|(id, pkg)| {
|
||||
// Keep this mapping in sync with the UI's
|
||||
// ContainerStatus.state union in
|
||||
// neode-ui/src/api/container-client.ts. The UI maps
|
||||
// transitional variants to single-button labels
|
||||
// (Stopping… / Starting… / Restarting…).
|
||||
let state = match &pkg.state {
|
||||
crate::data_model::PackageState::Running => "running",
|
||||
crate::data_model::PackageState::Stopped => "stopped",
|
||||
crate::data_model::PackageState::Exited => "exited",
|
||||
crate::data_model::PackageState::Starting => "created",
|
||||
_ => "unknown",
|
||||
crate::data_model::PackageState::Starting => "starting",
|
||||
crate::data_model::PackageState::Stopping => "stopping",
|
||||
crate::data_model::PackageState::Restarting => "restarting",
|
||||
crate::data_model::PackageState::Installing => "installing",
|
||||
crate::data_model::PackageState::Installed => "installed",
|
||||
crate::data_model::PackageState::Updating => "updating",
|
||||
crate::data_model::PackageState::Removing => "removing",
|
||||
crate::data_model::PackageState::CreatingBackup => "creating-backup",
|
||||
crate::data_model::PackageState::RestoringBackup => "restoring-backup",
|
||||
crate::data_model::PackageState::BackingUp => "backing-up",
|
||||
};
|
||||
let lan = pkg
|
||||
.installed
|
||||
@@ -163,9 +214,9 @@ impl RpcHandler {
|
||||
return Ok(serde_json::json!(containers));
|
||||
}
|
||||
|
||||
// Fallback: scanner hasn't run yet, query podman directly
|
||||
// Fallback: scanner hasn't run yet, query the orchestrator directly.
|
||||
if let Some(orchestrator) = &self.orchestrator {
|
||||
if let Ok(containers) = orchestrator.list_containers().await {
|
||||
if let Ok(containers) = orchestrator.list().await {
|
||||
if !containers.is_empty() {
|
||||
return Ok(serde_json::to_value(containers)?);
|
||||
}
|
||||
@@ -242,9 +293,10 @@ impl RpcHandler {
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let orchestrator = self.orchestrator.as_ref().ok_or_else(|| {
|
||||
anyhow::anyhow!("Container orchestrator not available (dev mode required)")
|
||||
})?;
|
||||
let orchestrator = self
|
||||
.orchestrator
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available"))?;
|
||||
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let app_id = params
|
||||
@@ -253,21 +305,36 @@ impl RpcHandler {
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
|
||||
validate_app_id(app_id)?;
|
||||
|
||||
let status = orchestrator
|
||||
.get_container_status(app_id)
|
||||
.await
|
||||
.context("Failed to get container status")?;
|
||||
let mut last_err: Option<anyhow::Error> = None;
|
||||
for candidate in status_app_id_candidates(app_id) {
|
||||
match orchestrator.status(&candidate).await {
|
||||
Ok(status) => return Ok(serde_json::to_value(status)?),
|
||||
Err(e) => last_err = Some(e),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(serde_json::to_value(status)?)
|
||||
// Fallback for alias drift: query podman directly by likely container
|
||||
// names so status checks stay useful during migration.
|
||||
for name in status_container_name_candidates(app_id) {
|
||||
if let Some(v) = inspect_container_state_value(&name).await {
|
||||
return Ok(v);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(e) = last_err {
|
||||
return Err(e.context("Failed to get container status"));
|
||||
}
|
||||
Err(anyhow::anyhow!("Failed to get container status"))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_container_logs(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let orchestrator = self.orchestrator.as_ref().ok_or_else(|| {
|
||||
anyhow::anyhow!("Container orchestrator not available (dev mode required)")
|
||||
})?;
|
||||
let orchestrator = self
|
||||
.orchestrator
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available"))?;
|
||||
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let app_id = params
|
||||
@@ -278,7 +345,7 @@ impl RpcHandler {
|
||||
let lines = params.get("lines").and_then(|v| v.as_u64()).unwrap_or(100) as u32;
|
||||
|
||||
let logs = orchestrator
|
||||
.get_container_logs(app_id, lines)
|
||||
.logs(app_id, lines)
|
||||
.await
|
||||
.context("Failed to get container logs")?;
|
||||
|
||||
@@ -291,12 +358,13 @@ impl RpcHandler {
|
||||
app_id: &str,
|
||||
lines: u32,
|
||||
) -> Result<serde_json::Value> {
|
||||
let orchestrator = self.orchestrator.as_ref().ok_or_else(|| {
|
||||
anyhow::anyhow!("Container orchestrator not available (dev mode required)")
|
||||
})?;
|
||||
let orchestrator = self
|
||||
.orchestrator
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available"))?;
|
||||
|
||||
let logs = orchestrator
|
||||
.get_container_logs(app_id, lines)
|
||||
.logs(app_id, lines)
|
||||
.await
|
||||
.context("Failed to get container logs")?;
|
||||
|
||||
@@ -307,47 +375,327 @@ impl RpcHandler {
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let orchestrator = self.orchestrator.as_ref().ok_or_else(|| {
|
||||
anyhow::anyhow!("Container orchestrator not available (dev mode required)")
|
||||
})?;
|
||||
let orchestrator = self
|
||||
.orchestrator
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available"))?;
|
||||
|
||||
// If app_id is provided, get health for that app
|
||||
// If app_id is provided, get health for that app.
|
||||
if let Some(params) = params {
|
||||
if let Some(app_id) = params.get("app_id").and_then(|v| v.as_str()) {
|
||||
let health = orchestrator
|
||||
.get_health_status(app_id)
|
||||
.await
|
||||
.context("Failed to get container health")?;
|
||||
return Ok(serde_json::json!({ app_id: health }));
|
||||
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 {
|
||||
Ok(health) => return Ok(serde_json::json!({ app_id: health })),
|
||||
Err(e) => last_err = Some(e),
|
||||
}
|
||||
}
|
||||
for name in status_container_name_candidates(app_id) {
|
||||
if let Some(health) = inspect_container_health_value(&name).await {
|
||||
return Ok(serde_json::json!({ app_id: health }));
|
||||
}
|
||||
}
|
||||
if let Some(e) = last_err {
|
||||
return Err(e.context("Failed to get container health"));
|
||||
}
|
||||
return Err(anyhow::anyhow!("Failed to get container health"));
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, get health for all containers
|
||||
// Otherwise, get health for all containers.
|
||||
let containers = orchestrator
|
||||
.list_containers()
|
||||
.list()
|
||||
.await
|
||||
.context("Failed to list containers")?;
|
||||
|
||||
let mut health_map = serde_json::Map::new();
|
||||
for container in containers {
|
||||
if let Some(app_id) = container.name.strip_prefix("archipelago-") {
|
||||
if let Some(app_id) = app_id.strip_suffix("-dev") {
|
||||
match orchestrator.get_health_status(app_id).await {
|
||||
Ok(health) => {
|
||||
health_map
|
||||
.insert(app_id.to_string(), serde_json::Value::String(health));
|
||||
}
|
||||
Err(_) => {
|
||||
health_map.insert(
|
||||
app_id.to_string(),
|
||||
serde_json::Value::String("unknown".to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
// Map the runtime container name back to the app_id the orchestrator
|
||||
// knows about. Dev orchestrator uses `archipelago-<id>-dev`; Prod
|
||||
// uses bare `<id>` (or `archy-<id>` for UIs — health() accepts the
|
||||
// app_id either way since UI_APP_IDS is centralised).
|
||||
let app_id_candidate = container
|
||||
.name
|
||||
.strip_prefix("archipelago-")
|
||||
.and_then(|s| s.strip_suffix("-dev"))
|
||||
.or_else(|| container.name.strip_prefix("archy-"))
|
||||
.unwrap_or(container.name.as_str());
|
||||
match orchestrator.health(app_id_candidate).await {
|
||||
Ok(health) => {
|
||||
health_map.insert(
|
||||
app_id_candidate.to_string(),
|
||||
serde_json::Value::String(health),
|
||||
);
|
||||
}
|
||||
Err(_) => {
|
||||
health_map.insert(
|
||||
app_id_candidate.to_string(),
|
||||
serde_json::Value::String("unknown".to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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> {
|
||||
let mut out = Vec::new();
|
||||
let mut push = |s: &str| {
|
||||
if !out.iter().any(|e: &String| e == s) {
|
||||
out.push(s.to_string());
|
||||
}
|
||||
};
|
||||
|
||||
match app_id {
|
||||
"bitcoin-knots" => {
|
||||
push("bitcoin-knots");
|
||||
push("bitcoin-core");
|
||||
push("bitcoin");
|
||||
}
|
||||
"bitcoin-core" | "bitcoin" => {
|
||||
push("bitcoin-core");
|
||||
push("bitcoin-knots");
|
||||
push("bitcoin");
|
||||
}
|
||||
"electrs" | "mempool-electrs" => {
|
||||
push("electrs");
|
||||
push("mempool-electrs");
|
||||
push("electrumx");
|
||||
}
|
||||
"mempool" | "mempool-web" => {
|
||||
push("mempool");
|
||||
push("archy-mempool-web");
|
||||
}
|
||||
"immich" => {
|
||||
push("immich");
|
||||
push("immich_server");
|
||||
}
|
||||
_ => push(app_id),
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
fn status_container_name_candidates(app_id: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut push = |s: &str| {
|
||||
if !out.iter().any(|e: &String| e == s) {
|
||||
out.push(s.to_string());
|
||||
}
|
||||
};
|
||||
|
||||
match app_id {
|
||||
"bitcoin-knots" | "bitcoin-core" | "bitcoin" => push("bitcoin-knots"),
|
||||
"bitcoin-ui" => push("archy-bitcoin-ui"),
|
||||
"lnd-ui" => push("archy-lnd-ui"),
|
||||
"electrs-ui" => push("archy-electrs-ui"),
|
||||
"electrs" | "mempool-electrs" => push("electrumx"),
|
||||
"mempool" | "mempool-web" | "archy-mempool-web" => push("mempool"),
|
||||
"immich" => push("immich_server"),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
push(app_id);
|
||||
if let Some(stripped) = app_id.strip_prefix("archy-") {
|
||||
push(stripped);
|
||||
} else {
|
||||
push(&format!("archy-{}", app_id));
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
async fn inspect_container_state_value(name: &str) -> Option<serde_json::Value> {
|
||||
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;
|
||||
}
|
||||
|
||||
let line = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
if line.is_empty() {
|
||||
return None;
|
||||
}
|
||||
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}")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,8 +231,7 @@ impl RpcHandler {
|
||||
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
||||
let fips_npub =
|
||||
crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||
|
||||
let path = format!("/content/{}", content_id);
|
||||
let (response, _transport) =
|
||||
@@ -287,10 +286,13 @@ impl RpcHandler {
|
||||
return Err(anyhow::anyhow!("Invalid v3 onion address"));
|
||||
}
|
||||
|
||||
let fips_npub =
|
||||
crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||
|
||||
debug!("Browsing peer content at {} (fips={})", onion, fips_npub.is_some());
|
||||
debug!(
|
||||
"Browsing peer content at {} (fips={})",
|
||||
onion,
|
||||
fips_npub.is_some()
|
||||
);
|
||||
|
||||
let (response, _transport) =
|
||||
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, "/content")
|
||||
@@ -348,8 +350,7 @@ impl RpcHandler {
|
||||
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
||||
let fips_npub =
|
||||
crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||
|
||||
let path = format!("/content/{}", content_id);
|
||||
let (response, _transport) =
|
||||
@@ -407,11 +408,15 @@ impl RpcHandler {
|
||||
return Err(anyhow::anyhow!("Invalid v3 onion address"));
|
||||
}
|
||||
|
||||
let fips_npub =
|
||||
crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||
|
||||
let path = format!("/content/{}/preview", content_id);
|
||||
debug!("Fetching content preview from {}{} (fips={})", onion, path, fips_npub.is_some());
|
||||
debug!(
|
||||
"Fetching content preview from {}{} (fips={})",
|
||||
onion,
|
||||
path,
|
||||
fips_npub.is_some()
|
||||
);
|
||||
|
||||
let (response, _transport) =
|
||||
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
use super::RpcHandler;
|
||||
use anyhow::Result;
|
||||
use std::sync::Arc;
|
||||
|
||||
impl RpcHandler {
|
||||
/// Route an RPC method name to its handler, returning the result value.
|
||||
pub(super) async fn dispatch(
|
||||
&self,
|
||||
self: &Arc<Self>,
|
||||
method: &str,
|
||||
params: Option<serde_json::Value>,
|
||||
session_token: &Option<String>,
|
||||
@@ -12,6 +13,7 @@ impl RpcHandler {
|
||||
match method {
|
||||
"echo" => self.handle_echo(params).await,
|
||||
"server.echo" => self.handle_echo(params).await,
|
||||
"server.get-state" => self.handle_server_get_state().await,
|
||||
"health" => self.handle_health().await,
|
||||
"auth.login" => self.handle_auth_login(params).await,
|
||||
"auth.logout" => self.handle_auth_logout().await,
|
||||
@@ -36,19 +38,23 @@ impl RpcHandler {
|
||||
"container-install" => self.handle_container_install(params).await,
|
||||
"container-start" => self.handle_container_start(params).await,
|
||||
"container-stop" => self.handle_container_stop(params).await,
|
||||
"container-restart" => self.handle_container_restart(params).await,
|
||||
"container-remove" => self.handle_container_remove(params).await,
|
||||
"container-list" => self.handle_container_list().await,
|
||||
"container-status" => self.handle_container_status(params).await,
|
||||
"container-logs" => self.handle_container_logs(params).await,
|
||||
"container-health" => self.handle_container_health(params).await,
|
||||
|
||||
// Package management (for docker-compose apps)
|
||||
"package.install" => self.handle_package_install(params).await,
|
||||
// Package management (for docker-compose apps).
|
||||
// install/uninstall/update return immediately with a
|
||||
// transitional status; the actual work runs in a background
|
||||
// tokio::spawn so the HTTP request doesn't block for minutes.
|
||||
"package.install" => self.clone().spawn_package_install(params).await,
|
||||
"package.start" => self.handle_package_start(params).await,
|
||||
"package.stop" => self.handle_package_stop(params).await,
|
||||
"package.restart" => self.handle_package_restart(params).await,
|
||||
"package.uninstall" => self.handle_package_uninstall(params).await,
|
||||
"package.update" => self.handle_package_update(params).await,
|
||||
"package.uninstall" => self.clone().spawn_package_uninstall(params).await,
|
||||
"package.update" => self.clone().spawn_package_update(params).await,
|
||||
"app.filebrowser-token" => self.handle_filebrowser_token().await,
|
||||
|
||||
// Bundled app management (for pre-loaded container images)
|
||||
@@ -525,6 +531,11 @@ impl RpcHandler {
|
||||
Ok(serde_json::json!({ "message": "Hello from Archipelago!" }))
|
||||
}
|
||||
|
||||
async fn handle_server_get_state(&self) -> Result<serde_json::Value> {
|
||||
let (data, rev) = self.state_manager.get_snapshot().await;
|
||||
Ok(serde_json::json!({ "data": data, "rev": rev }))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_health(&self) -> Result<serde_json::Value> {
|
||||
let recovery_complete = crate::crash_recovery::is_recovery_complete();
|
||||
let uptime = crate::crash_recovery::uptime_seconds();
|
||||
|
||||
@@ -403,7 +403,10 @@ impl RpcHandler {
|
||||
});
|
||||
let own_fips_npub = match own_fips_npub {
|
||||
Some(n) => Some(n),
|
||||
None => crate::fips::service::read_upstream_npub().await.ok().flatten(),
|
||||
None => crate::fips::service::read_upstream_npub()
|
||||
.await
|
||||
.ok()
|
||||
.flatten(),
|
||||
};
|
||||
|
||||
let state = federation::build_local_state(
|
||||
@@ -461,8 +464,7 @@ impl RpcHandler {
|
||||
// the entry causes sync loops where the node syncs with itself
|
||||
// forever. Drop it quietly — no useful recovery path.
|
||||
let (own_data, _) = self.state_manager.get_snapshot().await;
|
||||
let own_did_result =
|
||||
identity::did_key_from_pubkey_hex(&own_data.server_info.pubkey).ok();
|
||||
let own_did_result = identity::did_key_from_pubkey_hex(&own_data.server_info.pubkey).ok();
|
||||
let own_onion_trim = own_data
|
||||
.server_info
|
||||
.tor_address
|
||||
@@ -568,11 +570,7 @@ impl RpcHandler {
|
||||
let new_peer_did = did.to_string();
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
if let Err(e) = crate::federation::sync_with_peer_by_did(
|
||||
&data_dir,
|
||||
&new_peer_did,
|
||||
)
|
||||
.await
|
||||
if let Err(e) = crate::federation::sync_with_peer_by_did(&data_dir, &new_peer_did).await
|
||||
{
|
||||
tracing::debug!(
|
||||
peer_did = %new_peer_did,
|
||||
|
||||
@@ -98,21 +98,14 @@ impl RpcHandler {
|
||||
|
||||
// Anchor bootstrap window: poll the status every ~3s for up to
|
||||
// 20s. Bail as soon as the anchor is connected.
|
||||
let mut last_status: Option<fips::FipsStatus> = None;
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
|
||||
loop {
|
||||
let after = loop {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
|
||||
let s = fips::FipsStatus::query(&self.config.data_dir).await;
|
||||
if s.anchor_connected {
|
||||
last_status = Some(s);
|
||||
break;
|
||||
if s.anchor_connected || std::time::Instant::now() >= deadline {
|
||||
break s;
|
||||
}
|
||||
last_status = Some(s);
|
||||
if std::time::Instant::now() >= deadline {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let after = last_status.unwrap_or_else(|| before.clone());
|
||||
};
|
||||
|
||||
let recovered = after.anchor_connected && !before.anchor_connected;
|
||||
let likely_cause = if after.anchor_connected {
|
||||
@@ -169,8 +162,7 @@ impl RpcHandler {
|
||||
if !anchor.address.contains(':') {
|
||||
anyhow::bail!("address must be host:port (e.g. 192.168.1.116:8668)");
|
||||
}
|
||||
let list =
|
||||
fips::anchors::add(&self.config.data_dir, anchor.clone()).await?;
|
||||
let list = fips::anchors::add(&self.config.data_dir, anchor.clone()).await?;
|
||||
// Push just the newly-added anchor into the running daemon so
|
||||
// the user sees effect without waiting for the periodic apply.
|
||||
let results = fips::anchors::apply(&[anchor]).await;
|
||||
|
||||
@@ -742,24 +742,25 @@ impl RpcHandler {
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
|
||||
validate_identity_id(id)?;
|
||||
|
||||
let relay_urls: Vec<String> = if let Some(arr) = params.get("relays").and_then(|v| v.as_array()) {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.collect()
|
||||
} else if let Some(single) = params.get("relay").and_then(|v| v.as_str()) {
|
||||
vec![single.to_string()]
|
||||
} else {
|
||||
// Default: every enabled relay in the user's Manage Relays list.
|
||||
let statuses = crate::nostr_relays::list_relays(&self.config.data_dir)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
statuses
|
||||
.into_iter()
|
||||
.filter(|s| s.enabled)
|
||||
.map(|s| s.url)
|
||||
.collect()
|
||||
};
|
||||
let relay_urls: Vec<String> =
|
||||
if let Some(arr) = params.get("relays").and_then(|v| v.as_array()) {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.collect()
|
||||
} else if let Some(single) = params.get("relay").and_then(|v| v.as_str()) {
|
||||
vec![single.to_string()]
|
||||
} else {
|
||||
// Default: every enabled relay in the user's Manage Relays list.
|
||||
let statuses = crate::nostr_relays::list_relays(&self.config.data_dir)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
statuses
|
||||
.into_iter()
|
||||
.filter(|s| s.enabled)
|
||||
.map(|s| s.url)
|
||||
.collect()
|
||||
};
|
||||
|
||||
if relay_urls.is_empty() {
|
||||
anyhow::bail!("No enabled relays configured; add one under Manage Relays");
|
||||
|
||||
@@ -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::{LndAmount, LndBalanceResponse};
|
||||
use super::{read_lnd_admin_macaroon, LndAmount, LndBalanceResponse, LND_REST_BASE_URL};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct LndInfo {
|
||||
@@ -34,11 +34,7 @@ struct LndChannelBalanceResponse {
|
||||
|
||||
impl RpcHandler {
|
||||
pub(in crate::api::rpc) async fn handle_lnd_getinfo(&self) -> Result<serde_json::Value> {
|
||||
let macaroon_path = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
|
||||
|
||||
let macaroon_bytes = tokio::fs::read(macaroon_path)
|
||||
.await
|
||||
.context("Failed to read LND admin macaroon — is LND installed?")?;
|
||||
let macaroon_bytes = read_lnd_admin_macaroon().await?;
|
||||
let macaroon_hex = hex::encode(&macaroon_bytes);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
@@ -48,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
|
||||
@@ -58,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
|
||||
@@ -74,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
|
||||
@@ -114,7 +110,6 @@ impl RpcHandler {
|
||||
/// for building lndconnect:// URIs in the frontend.
|
||||
pub(crate) async fn handle_lnd_connect_info(&self) -> Result<serde_json::Value> {
|
||||
let cert_path = "/var/lib/archipelago/lnd/tls.cert";
|
||||
let macaroon_path = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
|
||||
|
||||
// Read and encode TLS cert (PEM -> DER -> base64url)
|
||||
let cert_pem = tokio::fs::read_to_string(cert_path)
|
||||
@@ -130,9 +125,7 @@ impl RpcHandler {
|
||||
let cert_b64url = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&cert_der);
|
||||
|
||||
// Read and encode macaroon (binary -> base64url)
|
||||
let macaroon_bytes = tokio::fs::read(macaroon_path)
|
||||
.await
|
||||
.context("Failed to read LND admin macaroon")?;
|
||||
let macaroon_bytes = read_lnd_admin_macaroon().await?;
|
||||
let macaroon_b64url =
|
||||
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&macaroon_bytes);
|
||||
|
||||
@@ -173,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,
|
||||
}))
|
||||
}
|
||||
@@ -183,10 +176,7 @@ impl RpcHandler {
|
||||
pub(in crate::api::rpc) async fn handle_lnd_export_channel_backup(
|
||||
&self,
|
||||
) -> Result<serde_json::Value> {
|
||||
let macaroon_path = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
|
||||
let macaroon_bytes = tokio::fs::read(macaroon_path)
|
||||
.await
|
||||
.context("Failed to read LND admin macaroon")?;
|
||||
let macaroon_bytes = read_lnd_admin_macaroon().await?;
|
||||
let macaroon_hex = hex::encode(&macaroon_bytes);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
@@ -196,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
|
||||
|
||||
@@ -4,7 +4,12 @@ mod payments;
|
||||
mod wallet;
|
||||
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use anyhow::{Context, Result};
|
||||
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)]
|
||||
@@ -17,15 +22,45 @@ pub(super) struct LndAmount {
|
||||
pub sat: Option<String>,
|
||||
}
|
||||
|
||||
/// Read LND's admin macaroon from disk.
|
||||
///
|
||||
/// The macaroon lives inside LND's container data dir and is owned by a
|
||||
/// rootless-podman subordinate UID (typically 100000), mode 640. The
|
||||
/// archipelago server runs as UID 1000 and therefore cannot read it
|
||||
/// directly. We first try a plain read (works if an operator has relaxed
|
||||
/// permissions), then fall back to `sudo cat` — mirroring the pattern
|
||||
/// already used for Tor hidden-service hostnames.
|
||||
pub(crate) async fn read_lnd_admin_macaroon() -> Result<Vec<u8>> {
|
||||
match tokio::fs::read(LND_ADMIN_MACAROON_PATH).await {
|
||||
Ok(bytes) => Ok(bytes),
|
||||
Err(direct_err) => {
|
||||
let output = tokio::process::Command::new("sudo")
|
||||
.args(["-n", "cat", LND_ADMIN_MACAROON_PATH])
|
||||
.output()
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"Failed to read LND admin macaroon (direct: {direct_err}); sudo fallback also failed"
|
||||
)
|
||||
})?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow!(
|
||||
"Failed to read LND admin macaroon — is LND installed? (direct: {direct_err}; sudo: {})",
|
||||
stderr.trim()
|
||||
));
|
||||
}
|
||||
Ok(output.stdout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
/// Helper: create an authenticated LND REST client.
|
||||
/// Returns an HTTP client configured for LND's self-signed TLS and the
|
||||
/// hex-encoded admin macaroon for request headers.
|
||||
pub(crate) async fn lnd_client(&self) -> Result<(reqwest::Client, String)> {
|
||||
let macaroon_path = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
|
||||
let macaroon_bytes = tokio::fs::read(macaroon_path)
|
||||
.await
|
||||
.context("Failed to read LND admin macaroon — is LND installed?")?;
|
||||
let macaroon_bytes = read_lnd_admin_macaroon().await?;
|
||||
let macaroon_hex = hex::encode(&macaroon_bytes);
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -761,7 +761,9 @@ impl RpcHandler {
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Read body failed: {}", e))?;
|
||||
|
||||
let meta = blob_store.put(&bytes, &mime, filename_hint, None, false).await?;
|
||||
let meta = blob_store
|
||||
.put(&bytes, &mime, filename_hint, None, false)
|
||||
.await?;
|
||||
if meta.cid != cid {
|
||||
anyhow::bail!("CID mismatch: expected {}, got {}", cid, meta.cid);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -31,6 +31,7 @@ mod streaming;
|
||||
mod system;
|
||||
mod tor;
|
||||
mod totp;
|
||||
mod transitional;
|
||||
mod transport;
|
||||
mod update;
|
||||
mod vpn;
|
||||
@@ -39,7 +40,7 @@ mod webhooks;
|
||||
|
||||
use crate::auth::AuthManager;
|
||||
use crate::config::Config;
|
||||
use crate::container::DevContainerOrchestrator;
|
||||
use crate::container::{ContainerOrchestrator, DevContainerOrchestrator};
|
||||
use crate::monitoring::MetricsStore;
|
||||
use crate::port_allocator::PortAllocator;
|
||||
use crate::rate_limit::{EndpointRateLimiter, LoginRateLimiter};
|
||||
@@ -62,7 +63,14 @@ pub(crate) const DEV_DEFAULT_PASSWORD: &str = "password123";
|
||||
pub struct RpcHandler {
|
||||
config: Config,
|
||||
auth_manager: AuthManager,
|
||||
orchestrator: Option<Arc<DevContainerOrchestrator>>,
|
||||
/// Shared lifecycle orchestrator (Dev or Prod). Always `Some` in a normal
|
||||
/// build — the only reason it is `Option` is so tests that don't exercise
|
||||
/// container RPCs can skip constructing one.
|
||||
orchestrator: Option<Arc<dyn ContainerOrchestrator>>,
|
||||
/// Concrete handle to the dev orchestrator, when we're in dev mode. Used by
|
||||
/// `container-install { manifest_path }` which takes an ad-hoc manifest
|
||||
/// path and is not part of the shared trait.
|
||||
dev_orchestrator: Option<Arc<DevContainerOrchestrator>>,
|
||||
state_manager: Arc<StateManager>,
|
||||
pub(crate) metrics_store: Arc<MetricsStore>,
|
||||
port_allocator: Arc<tokio::sync::Mutex<PortAllocator>>,
|
||||
@@ -79,6 +87,15 @@ pub struct RpcHandler {
|
||||
/// Our own Ed25519 pubkey hex — needed by ContentRef senders for cap scoping
|
||||
/// and by ContentRef receivers to request caps scoped to themselves.
|
||||
pub(crate) self_pubkey_hex: Arc<tokio::sync::RwLock<Option<String>>>,
|
||||
/// Kick the package scanner to run immediately (bypassing the 60s interval).
|
||||
/// Used by install/update success paths so the fresh manifest (with populated
|
||||
/// `interfaces.main.ui`) lands before we flip state to Running — closes the
|
||||
/// "Launch button is missing for up to 60s after install" UX gap.
|
||||
pub(crate) scan_kick: Arc<tokio::sync::Notify>,
|
||||
/// Monotonic counter incremented by the scan loop after each completed scan.
|
||||
/// Install/update success paths subscribe to this to know when a kicked scan
|
||||
/// has actually finished before flipping to the terminal state.
|
||||
pub(crate) scan_tick: Arc<tokio::sync::watch::Sender<u64>>,
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
@@ -87,15 +104,10 @@ impl RpcHandler {
|
||||
state_manager: Arc<StateManager>,
|
||||
metrics_store: Arc<MetricsStore>,
|
||||
session_store: SessionStore,
|
||||
orchestrator: Option<Arc<dyn ContainerOrchestrator>>,
|
||||
dev_orchestrator: Option<Arc<DevContainerOrchestrator>>,
|
||||
) -> Result<Self> {
|
||||
let auth_manager = AuthManager::new(config.data_dir.clone());
|
||||
let orchestrator = if config.dev_mode {
|
||||
Some(Arc::new(
|
||||
DevContainerOrchestrator::new(config.clone()).await?,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let port_allocator = Arc::new(tokio::sync::Mutex::new(
|
||||
PortAllocator::new(&config.data_dir).await?,
|
||||
));
|
||||
@@ -129,6 +141,7 @@ impl RpcHandler {
|
||||
config,
|
||||
auth_manager,
|
||||
orchestrator,
|
||||
dev_orchestrator,
|
||||
state_manager,
|
||||
metrics_store,
|
||||
port_allocator,
|
||||
@@ -140,6 +153,8 @@ impl RpcHandler {
|
||||
transport_router: Arc::new(tokio::sync::RwLock::new(None)),
|
||||
blob_store: Arc::new(tokio::sync::RwLock::new(None)),
|
||||
self_pubkey_hex: Arc::new(tokio::sync::RwLock::new(None)),
|
||||
scan_kick: Arc::new(tokio::sync::Notify::new()),
|
||||
scan_tick: Arc::new(tokio::sync::watch::channel(0u64).0),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -180,6 +195,21 @@ impl RpcHandler {
|
||||
Arc::clone(&self.mesh_service)
|
||||
}
|
||||
|
||||
/// Shared Notify handle the package-scanner loop waits on (in addition to
|
||||
/// its periodic tick). Install/update success paths call `notify_one()` to
|
||||
/// trigger an immediate scan so the fresh manifest lands before we flip to
|
||||
/// the terminal Running state.
|
||||
pub fn scan_kick(&self) -> Arc<tokio::sync::Notify> {
|
||||
Arc::clone(&self.scan_kick)
|
||||
}
|
||||
|
||||
/// Sender half of the scan-completion watch channel. The scanner bumps this
|
||||
/// counter after every finished scan; install/update wait for an advance
|
||||
/// after kicking so they know the fresh manifest has landed.
|
||||
pub fn scan_tick(&self) -> Arc<tokio::sync::watch::Sender<u64>> {
|
||||
Arc::clone(&self.scan_tick)
|
||||
}
|
||||
|
||||
fn cookie_suffix_for_request(&self, headers: &hyper::header::HeaderMap) -> &'static str {
|
||||
// Only set Secure flag when the original request was over HTTPS.
|
||||
// Nginx sends X-Forwarded-Proto: https for HTTPS connections.
|
||||
@@ -197,7 +227,10 @@ impl RpcHandler {
|
||||
""
|
||||
}
|
||||
|
||||
pub async fn handle(&self, req: Request<hyper::Body>) -> Result<Response<hyper::Body>> {
|
||||
pub async fn handle(
|
||||
self: Arc<Self>,
|
||||
req: Request<hyper::Body>,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
// Extract session cookie before consuming the request
|
||||
let (parts, body) = req.into_parts();
|
||||
let session_token = session::extract_session_cookie(&parts.headers);
|
||||
@@ -376,7 +409,7 @@ impl RpcHandler {
|
||||
|
||||
// Route to handler (track latency for metrics)
|
||||
let rpc_start = std::time::Instant::now();
|
||||
let result = self.dispatch(&rpc_req.method, params, &session_token).await;
|
||||
let result = Self::dispatch(&self, &rpc_req.method, params, &session_token).await;
|
||||
|
||||
// Record RPC latency for monitoring
|
||||
let elapsed_ms = rpc_start.elapsed().as_secs_f64() * 1000.0;
|
||||
|
||||
@@ -0,0 +1,443 @@
|
||||
//! Async wrappers for `package.install`, `package.uninstall`, `package.update`.
|
||||
//!
|
||||
//! The inner `handle_package_*` functions are large (install is 480 lines with
|
||||
//! the stack dispatchers, update is 300, uninstall is 200) and do their own
|
||||
//! fine-grained progress tracking via `install_progress` and `uninstall_stage`.
|
||||
//! We wrap them rather than refactor them.
|
||||
//!
|
||||
//! Each wrapper:
|
||||
//! 1. Parses + validates the RPC params (cheap, synchronous). Errors here
|
||||
//! return immediately to the caller before any state change.
|
||||
//! 2. Flips the package state to the transitional variant
|
||||
//! (`Installing` / `Removing` / `Updating`) so the UI sees it on the
|
||||
//! next WebSocket push (before the RPC response even lands).
|
||||
//! 3. `tokio::spawn`s a background task that invokes the existing
|
||||
//! `handle_package_*` method on the Arc-held self.
|
||||
//! 4. On task success: no state change needed — the inner handler has
|
||||
//! already written the terminal state (Running for install/update, or
|
||||
//! removed the entry for uninstall).
|
||||
//! 5. On task failure: revert state to the pre-transition value (or delete
|
||||
//! the entry for install, since there was no pre-state), write a line
|
||||
//! to the persistent install log, and clear any stale progress fields.
|
||||
//! 6. Returns `{ "status": "installing" }` etc. immediately.
|
||||
//!
|
||||
//! The server package-scan loop's `merge_preserving_transitional` helper
|
||||
//! already knows to preserve `Installing` / `Removing` / `Updating` between
|
||||
//! scans, so live progress updates broadcast from inside the spawned task
|
||||
//! reach the UI correctly.
|
||||
|
||||
use super::install::install_log;
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use crate::data_model::PackageState;
|
||||
use crate::state::StateManager;
|
||||
use anyhow::Result;
|
||||
use std::sync::Arc;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
impl RpcHandler {
|
||||
/// Async wrapper for `package.install`. Returns `{ "status": "installing" }`
|
||||
/// immediately after flipping state to `Installing` and spawning the
|
||||
/// actual install pipeline. On failure, removes the package entry from
|
||||
/// state so the UI reverts to "not installed".
|
||||
pub(in crate::api::rpc) async fn spawn_package_install(
|
||||
self: Arc<Self>,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
// Extract + validate package_id synchronously so bad params fail
|
||||
// fast without touching state.
|
||||
let params_val = params
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let package_id = params_val
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.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).
|
||||
{
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
if let Some(entry) = data.package_data.get(&package_id) {
|
||||
if matches!(
|
||||
entry.state,
|
||||
PackageState::Installing | PackageState::Removing | PackageState::Updating
|
||||
) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"{} is already {:?}",
|
||||
package_id,
|
||||
entry.state
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flip state to Installing BEFORE the spawn so the first WebSocket
|
||||
// push carries the transitional state. Uses the same
|
||||
// `create_installing_entry` path the inner handler would use once
|
||||
// it starts pulling, so the UI sees a consistent shape.
|
||||
flip_to_installing(&self.state_manager, &package_id).await;
|
||||
|
||||
install_log(&format!("INSTALL SPAWN: {}", package_id)).await;
|
||||
|
||||
let handler = Arc::clone(&self);
|
||||
let package_id_spawn = package_id.clone();
|
||||
tokio::spawn(async move {
|
||||
match handler.handle_package_install(params).await {
|
||||
Ok(_) => {
|
||||
info!("package.install {}: complete", package_id_spawn);
|
||||
// The install pipeline has verified the container is up
|
||||
// and healthy (see install.rs post-start exit check).
|
||||
// Kick the scanner first so the fresh manifest (with
|
||||
// `interfaces.main.ui` from the live port binding) lands
|
||||
// BEFORE we flip to Running — without this the Launch
|
||||
// button is missing for up to 60s after a successful
|
||||
// install, because the skeletal install-time manifest
|
||||
// has `interfaces: None`.
|
||||
kick_scanner_and_wait(&handler).await;
|
||||
// We MUST explicitly transition out of Installing here:
|
||||
// `merge_preserving_transitional` in the package-scan
|
||||
// loop treats Installing as RPC-owned and refuses to
|
||||
// let the scanner overwrite it with the observed
|
||||
// Running state. Without this write, the entry stays
|
||||
// stuck at Installing forever.
|
||||
set_package_state(
|
||||
&handler.state_manager,
|
||||
&package_id_spawn,
|
||||
PackageState::Running,
|
||||
)
|
||||
.await;
|
||||
handler.clear_install_progress(&package_id_spawn).await;
|
||||
}
|
||||
Err(e) => {
|
||||
error!("package.install {} failed: {:#}", package_id_spawn, e);
|
||||
install_log(&format!("INSTALL FAIL: {} — {:#}", package_id_spawn, e)).await;
|
||||
// Don't remove the entry — that's what made the card
|
||||
// vanish from My Apps mid-install / between retry-loop
|
||||
// attempts (e.g. tailscale's entrypoint failure). Leave
|
||||
// the entry visible with state=Stopped + the install
|
||||
// error in install_progress.message so the user can see
|
||||
// what went wrong and decide whether to retry or
|
||||
// uninstall. clear_install_progress would erase the
|
||||
// message, so we set it explicitly here instead.
|
||||
let err_msg = format!("Install failed: {:#}", e);
|
||||
let (mut data, _) = handler.state_manager.get_snapshot().await;
|
||||
if let Some(entry) = data.package_data.get_mut(&package_id_spawn) {
|
||||
entry.state = PackageState::Stopped;
|
||||
entry.install_progress = Some(crate::data_model::InstallProgress {
|
||||
size: 0,
|
||||
downloaded: 0,
|
||||
phase: None,
|
||||
message: Some(err_msg),
|
||||
});
|
||||
handler.state_manager.update_data(data).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"status": "installing",
|
||||
"package_id": package_id,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Async wrapper for `package.uninstall`. Returns `{ "status": "removing" }`
|
||||
/// immediately. State stays `Removing` until the inner handler finishes
|
||||
/// (including the `sudo rm -rf` of app data, which can take minutes for
|
||||
/// bitcoin-core's chainstate). On failure, reverts to the pre-transition
|
||||
/// state (usually Running or Stopped) so the user can retry.
|
||||
pub(in crate::api::rpc) async fn spawn_package_uninstall(
|
||||
self: Arc<Self>,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params_val = params
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let package_id = params_val
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?
|
||||
.to_string();
|
||||
super::validation::validate_app_id(&package_id)?;
|
||||
|
||||
// Reject if already in a transitional lifecycle.
|
||||
{
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
if let Some(entry) = data.package_data.get(&package_id) {
|
||||
if matches!(
|
||||
entry.state,
|
||||
PackageState::Installing | PackageState::Removing | PackageState::Updating
|
||||
) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"{} is already {:?}",
|
||||
package_id,
|
||||
entry.state
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let pre_state =
|
||||
flip_package_state(&self.state_manager, &package_id, PackageState::Removing).await;
|
||||
|
||||
install_log(&format!("UNINSTALL SPAWN: {}", package_id)).await;
|
||||
|
||||
let handler = Arc::clone(&self);
|
||||
let package_id_spawn = package_id.clone();
|
||||
tokio::spawn(async move {
|
||||
match handler.handle_package_uninstall(params).await {
|
||||
Ok(_) => {
|
||||
info!("package.uninstall {}: complete", package_id_spawn);
|
||||
// Inner handler already removed the package entry on
|
||||
// success. Nothing more to do here.
|
||||
}
|
||||
Err(e) => {
|
||||
error!("package.uninstall {} failed: {:#}", package_id_spawn, e);
|
||||
install_log(&format!("UNINSTALL FAIL: {} — {:#}", package_id_spawn, e)).await;
|
||||
// Revert to pre-transition state so the user can retry.
|
||||
// Also clear any stale uninstall_stage label.
|
||||
if let Some(prev) = pre_state {
|
||||
set_package_state_and_clear_uninstall_stage(
|
||||
&handler.state_manager,
|
||||
&package_id_spawn,
|
||||
prev,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"status": "removing",
|
||||
"package_id": package_id,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Async wrapper for `package.update`. Returns `{ "status": "updating" }`
|
||||
/// immediately. The inner handler already manages its own rollback on
|
||||
/// failure (restarts old containers); this wrapper just flips state and
|
||||
/// spawns.
|
||||
pub(in crate::api::rpc) async fn spawn_package_update(
|
||||
self: Arc<Self>,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params_val = params
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let package_id = params_val
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?
|
||||
.to_string();
|
||||
super::validation::validate_app_id(&package_id)?;
|
||||
|
||||
// Reject if already in a transitional lifecycle.
|
||||
{
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
if let Some(entry) = data.package_data.get(&package_id) {
|
||||
if matches!(
|
||||
entry.state,
|
||||
PackageState::Installing | PackageState::Removing | PackageState::Updating
|
||||
) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"{} is already {:?}",
|
||||
package_id,
|
||||
entry.state
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The inner handler flips state to Updating itself, but we do it
|
||||
// here too so the transitional state lands before the spawn yields.
|
||||
let pre_state =
|
||||
flip_package_state(&self.state_manager, &package_id, PackageState::Updating).await;
|
||||
|
||||
install_log(&format!("UPDATE SPAWN: {}", package_id)).await;
|
||||
|
||||
let handler = Arc::clone(&self);
|
||||
let package_id_spawn = package_id.clone();
|
||||
tokio::spawn(async move {
|
||||
match handler.handle_package_update(params).await {
|
||||
Ok(_) => {
|
||||
info!("package.update {}: complete", package_id_spawn);
|
||||
// Same reasoning as install: the merge_preserving_transitional
|
||||
// helper treats Updating as RPC-owned, so we MUST write the
|
||||
// terminal Running state ourselves or the entry will stay
|
||||
// stuck at Updating forever. The update pipeline has
|
||||
// already verified the new container is running via its
|
||||
// post-recreate check.
|
||||
// Kick the scanner first so any manifest changes from the
|
||||
// new image version (interfaces, ports, etc.) land before
|
||||
// we flip to Running.
|
||||
kick_scanner_and_wait(&handler).await;
|
||||
set_package_state(
|
||||
&handler.state_manager,
|
||||
&package_id_spawn,
|
||||
PackageState::Running,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Err(e) => {
|
||||
error!("package.update {} failed: {:#}", package_id_spawn, e);
|
||||
install_log(&format!("UPDATE FAIL: {} — {:#}", package_id_spawn, e)).await;
|
||||
// Inner handler already ran rollback_update + cleared
|
||||
// update state, but be defensive: revert to pre-state
|
||||
// in case the inner flow died before its cleanup.
|
||||
if let Some(prev) = pre_state {
|
||||
set_package_state(&handler.state_manager, &package_id_spawn, prev).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"status": "updating",
|
||||
"package_id": package_id,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State-manager helpers (free fns, usable from inside spawned tasks)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Create or update the entry for this package with `Installing` state.
|
||||
/// Matches what the inner handler's `set_install_progress` would do on first
|
||||
/// call, but fires before the spawn so the UI sees it immediately.
|
||||
async fn flip_to_installing(state_manager: &StateManager, package_id: &str) {
|
||||
use crate::data_model::{Description, Manifest, PackageDataEntry, StaticFiles};
|
||||
let (mut data, _) = state_manager.get_snapshot().await;
|
||||
let entry = data
|
||||
.package_data
|
||||
.entry(package_id.to_string())
|
||||
.or_insert_with(|| PackageDataEntry {
|
||||
state: PackageState::Installing,
|
||||
health: None,
|
||||
exit_code: None,
|
||||
static_files: StaticFiles {
|
||||
license: String::new(),
|
||||
instructions: String::new(),
|
||||
// Leave icon empty during the transient Installing window:
|
||||
// hardcoding `<id>.png` is wrong for ~half our apps (many use
|
||||
// `.svg` / `.webp`), producing a broken-image flicker until
|
||||
// the scanner refreshes the entry. The frontend's `icon`
|
||||
// computed falls through to `curatedMap.get(id)?.icon` which
|
||||
// has the correct extensions for known apps.
|
||||
icon: String::new(),
|
||||
},
|
||||
manifest: Manifest {
|
||||
id: package_id.to_string(),
|
||||
title: package_id.to_string(),
|
||||
version: String::new(),
|
||||
description: Description {
|
||||
short: "Installing...".to_string(),
|
||||
long: String::new(),
|
||||
},
|
||||
release_notes: String::new(),
|
||||
license: String::new(),
|
||||
wrapper_repo: String::new(),
|
||||
upstream_repo: String::new(),
|
||||
support_site: String::new(),
|
||||
marketing_site: String::new(),
|
||||
donation_url: None,
|
||||
author: None,
|
||||
website: None,
|
||||
interfaces: None,
|
||||
tier: None,
|
||||
},
|
||||
installed: None,
|
||||
install_progress: None,
|
||||
uninstall_stage: None,
|
||||
available_update: None,
|
||||
});
|
||||
entry.state = PackageState::Installing;
|
||||
state_manager.update_data(data).await;
|
||||
}
|
||||
|
||||
/// Flip an existing entry's state and return the pre-flip value (or None if
|
||||
/// no entry existed). Used for revert-on-failure.
|
||||
async fn flip_package_state(
|
||||
state_manager: &StateManager,
|
||||
package_id: &str,
|
||||
new_state: PackageState,
|
||||
) -> Option<PackageState> {
|
||||
let (mut data, _) = state_manager.get_snapshot().await;
|
||||
let prev = data.package_data.get(package_id).map(|e| e.state.clone());
|
||||
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||
entry.state = new_state;
|
||||
state_manager.update_data(data).await;
|
||||
} else {
|
||||
warn!(
|
||||
"flip_package_state: no entry for {} — cannot flip",
|
||||
package_id
|
||||
);
|
||||
}
|
||||
prev
|
||||
}
|
||||
|
||||
/// Set state unconditionally (no-op if entry no longer exists).
|
||||
async fn set_package_state(
|
||||
state_manager: &StateManager,
|
||||
package_id: &str,
|
||||
new_state: PackageState,
|
||||
) {
|
||||
let (mut data, _) = state_manager.get_snapshot().await;
|
||||
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||
if entry.state != new_state {
|
||||
entry.state = new_state;
|
||||
state_manager.update_data(data).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Set state and clear the uninstall_stage label. Used when an uninstall
|
||||
/// fails and we revert — the user doesn't want a stale "Removing app data"
|
||||
/// message sitting on a Running entry.
|
||||
async fn set_package_state_and_clear_uninstall_stage(
|
||||
state_manager: &StateManager,
|
||||
package_id: &str,
|
||||
new_state: PackageState,
|
||||
) {
|
||||
let (mut data, _) = state_manager.get_snapshot().await;
|
||||
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||
entry.state = new_state;
|
||||
entry.uninstall_stage = None;
|
||||
state_manager.update_data(data).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Kick the container scanner to run immediately and wait for it to finish
|
||||
/// (with a 2s timeout). Used by install/update success paths so the fresh
|
||||
/// manifest — with `interfaces.main.ui` populated from the now-running
|
||||
/// container's port binding — lands BEFORE we flip state to Running.
|
||||
///
|
||||
/// Without this, the frontend sees `state = running` but the skeletal
|
||||
/// install-time manifest (interfaces = None), and hides the Launch button
|
||||
/// for up to the full 60s scan interval.
|
||||
///
|
||||
/// The scan merges via `merge_preserving_transitional`, which keeps
|
||||
/// state = Installing (we haven't flipped yet) while taking the fresh
|
||||
/// manifest. After this returns, the caller writes Running on top of the
|
||||
/// now-populated manifest.
|
||||
async fn kick_scanner_and_wait(handler: &RpcHandler) {
|
||||
let mut rx = handler.scan_tick.subscribe();
|
||||
let start = *rx.borrow_and_update();
|
||||
handler.scan_kick.notify_one();
|
||||
// 2s is well above a typical podman scan (~200ms on .228, ~500ms worst
|
||||
// case). If it times out we proceed anyway — the next 60s scan will
|
||||
// self-heal and the worst case is the pre-fix behavior (Launch button
|
||||
// appears a bit late).
|
||||
let _ = tokio::time::timeout(std::time::Duration::from_secs(2), async {
|
||||
while *rx.borrow_and_update() == start {
|
||||
if rx.changed().await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
@@ -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)]
|
||||
@@ -9,7 +99,7 @@ pub(super) const TRUSTED_REGISTRIES: &[&str] = &[
|
||||
"ghcr.io/",
|
||||
"localhost/",
|
||||
"git.tx1138.com/",
|
||||
"23.182.128.160:3000/",
|
||||
"146.59.87.168:3000/",
|
||||
];
|
||||
|
||||
/// Validate Docker image against trusted registry allowlist.
|
||||
@@ -29,7 +119,7 @@ pub(super) fn is_valid_docker_image(image: &str) -> bool {
|
||||
};
|
||||
matches!(
|
||||
registry,
|
||||
"docker.io" | "ghcr.io" | "localhost" | "git.tx1138.com" | "23.182.128.160:3000"
|
||||
"docker.io" | "ghcr.io" | "localhost" | "git.tx1138.com" | "146.59.87.168:3000"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -63,26 +153,39 @@ 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 and Lightning need file ownership ops + NET_BIND_SERVICE for port binding
|
||||
// LND additionally needs NET_RAW for TLS certificate generation (netlinkrib interface enumeration)
|
||||
"bitcoin" | "bitcoin-core" | "bitcoin-knots" | "lnd" | "fedimint" | "fedimint-gateway" => {
|
||||
vec![
|
||||
"--cap-add=CHOWN".to_string(),
|
||||
"--cap-add=FOWNER".to_string(),
|
||||
"--cap-add=SETUID".to_string(),
|
||||
"--cap-add=SETGID".to_string(),
|
||||
"--cap-add=DAC_OVERRIDE".to_string(),
|
||||
"--cap-add=NET_BIND_SERVICE".to_string(),
|
||||
"--cap-add=NET_RAW".to_string(),
|
||||
]
|
||||
}
|
||||
// Bitcoin needs only file-ownership ops + NET_BIND_SERVICE for the
|
||||
// RPC port. NO NET_RAW — bitcoind never opens raw sockets and
|
||||
// dropping it removes a class of intra-pod spoofing capability.
|
||||
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => vec![
|
||||
"--cap-add=CHOWN".to_string(),
|
||||
"--cap-add=FOWNER".to_string(),
|
||||
"--cap-add=SETUID".to_string(),
|
||||
"--cap-add=SETGID".to_string(),
|
||||
"--cap-add=DAC_OVERRIDE".to_string(),
|
||||
"--cap-add=NET_BIND_SERVICE".to_string(),
|
||||
],
|
||||
// LND additionally needs NET_RAW for TLS certificate generation
|
||||
// (netlink interface enumeration during `lnd --tlscertpath` first run).
|
||||
// Fedimint inherits the same set because the gateway also enumerates
|
||||
// network interfaces on startup.
|
||||
"lnd" | "fedimint" | "fedimint-gateway" => vec![
|
||||
"--cap-add=CHOWN".to_string(),
|
||||
"--cap-add=FOWNER".to_string(),
|
||||
"--cap-add=SETUID".to_string(),
|
||||
"--cap-add=SETGID".to_string(),
|
||||
"--cap-add=DAC_OVERRIDE".to_string(),
|
||||
"--cap-add=NET_BIND_SERVICE".to_string(),
|
||||
"--cap-add=NET_RAW".to_string(),
|
||||
],
|
||||
// Vaultwarden needs file ownership + NET_BIND_SERVICE (binds port 80 internally)
|
||||
"vaultwarden" => vec![
|
||||
"--cap-add=CHOWN".to_string(),
|
||||
@@ -114,7 +217,7 @@ pub(super) fn get_app_capabilities(app_id: &str) -> Vec<String> {
|
||||
"--cap-add=DAC_OVERRIDE".to_string(),
|
||||
"--cap-add=NET_BIND_SERVICE".to_string(),
|
||||
],
|
||||
// Nostr VPN and FIPS: mesh networking daemons need TUN + NET_ADMIN
|
||||
// VPN/mesh daemons need TUN + NET_ADMIN.
|
||||
// Note: --device=/dev/net/tun is added separately in install.rs
|
||||
"nostr-vpn" | "fips" => vec![
|
||||
"--cap-add=NET_ADMIN".to_string(),
|
||||
@@ -153,36 +256,35 @@ pub(super) fn is_readonly_compatible(app_id: &str) -> bool {
|
||||
/// Get container health check arguments for podman run.
|
||||
/// Returns (health-cmd, interval, retries) args to append to run_args.
|
||||
pub(super) fn get_health_check_args(app_id: &str, _rpc_pass: &str) -> Vec<String> {
|
||||
// bitcoin-cli reads the .cookie file from -datadir automatically (no plaintext creds needed)
|
||||
let btc_health =
|
||||
"bitcoin-cli -datadir=/home/bitcoin/.bitcoin getblockchaininfo || exit 1".to_string();
|
||||
let (cmd, interval, retries) = match app_id {
|
||||
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => (btc_health.as_str(), "30s", "3"),
|
||||
// Bitcoin images do not consistently ship bitcoin-cli/curl/nc. Rely on
|
||||
// process state here; manifests still describe the desired TCP check.
|
||||
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => return vec![],
|
||||
"lnd" => ("lncli getinfo || exit 1", "30s", "3"),
|
||||
"btcpay-server" | "btcpayserver" => {
|
||||
("curl -sf http://localhost:49392/ || exit 1", "30s", "3")
|
||||
("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")
|
||||
}
|
||||
"nextcloud" => (
|
||||
"curl -sf http://localhost:80/status.php || exit 1",
|
||||
"curl -s -o /dev/null http://localhost:80/status.php || exit 1",
|
||||
"30s",
|
||||
"3",
|
||||
),
|
||||
"homeassistant" | "home-assistant" => {
|
||||
("curl -sf http://localhost:8123/api/ || exit 1", "30s", "3")
|
||||
("curl -sf http://localhost:8123/ || exit 1", "30s", "3")
|
||||
}
|
||||
"grafana" => (
|
||||
"curl -sf http://localhost:3000/api/health || exit 1",
|
||||
"test -w /var/lib/grafana && test -w /var/lib/grafana/grafana.db && curl -sf http://localhost:3000/api/health || exit 1",
|
||||
"30s",
|
||||
"3",
|
||||
),
|
||||
@@ -194,7 +296,12 @@ pub(super) fn get_health_check_args(app_id: &str, _rpc_pass: &str) -> Vec<String
|
||||
"vaultwarden" => ("curl -sf http://localhost:80/alive || exit 1", "30s", "3"),
|
||||
"uptime-kuma" => ("curl -sf http://localhost:3001/ || exit 1", "30s", "3"),
|
||||
"filebrowser" => ("curl -sf http://localhost:80/health || exit 1", "30s", "3"),
|
||||
"searxng" => ("curl -sf http://localhost:8080/ || exit 1", "30s", "3"),
|
||||
"botfights" => (
|
||||
"node -e \"fetch(\\\"http://127.0.0.1:9100/api/health\\\").then(r=>process.exit(r.ok?0:1)).catch(()=>process.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",
|
||||
@@ -210,18 +317,12 @@ pub(super) fn get_health_check_args(app_id: &str, _rpc_pass: &str) -> Vec<String
|
||||
"30s",
|
||||
"3",
|
||||
),
|
||||
"portainer" => (
|
||||
"curl -sf http://localhost:9000/api/status || exit 1",
|
||||
"30s",
|
||||
"3",
|
||||
),
|
||||
"portainer" => return vec![],
|
||||
"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",
|
||||
@@ -236,20 +337,37 @@ 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 {
|
||||
// Heavy apps
|
||||
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => "4g",
|
||||
// Heavy apps. Bitcoin: dbcache uses ~4GB; the daemon also needs
|
||||
// headroom for mempool + connection buffers + script-verifier
|
||||
// memory + I/O. 4g caused OOM-cascades during IBD. 8g is the
|
||||
// floor; ideally this would be host-RAM aware (next pass).
|
||||
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => "8g",
|
||||
// ElectrumX: 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
|
||||
"lnd" => "512m",
|
||||
"electrumx" | "mempool-electrs" | "electrs" => "1g",
|
||||
"nextcloud" => "1g",
|
||||
"immich_server" | "immich" => "1g",
|
||||
"btcpay-server" | "btcpayserver" => "1g",
|
||||
@@ -271,11 +389,13 @@ pub(super) fn get_memory_limit(app_id: &str) -> &'static str {
|
||||
"nostr-rs-relay" | "nostr-relay" => "256m",
|
||||
"routstr" => "512m",
|
||||
"nostr-vpn" => "256m",
|
||||
"netbird" => "1g",
|
||||
"fips" => "256m",
|
||||
"nginx-proxy-manager" => "256m",
|
||||
// Databases
|
||||
"archy-btcpay-db" | "archy-mempool-db" | "mysql-mempool" => "512m",
|
||||
"immich_postgres" | "penpot-postgres" => "256m",
|
||||
"immich_postgres" => "2g",
|
||||
"penpot-postgres" => "256m",
|
||||
"immich_redis" | "penpot-valkey" => "128m",
|
||||
// Default
|
||||
_ => "512m",
|
||||
@@ -291,16 +411,23 @@ pub(super) fn all_container_names(package_id: &str) -> Vec<String> {
|
||||
let archy = format!("archy-{}", package_id);
|
||||
|
||||
match package_id {
|
||||
// Bitcoin: multiple historical names
|
||||
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => vec![
|
||||
// Bitcoin variants share the UI but not the backend process. Keep
|
||||
// backend names precise so stopping one implementation does not clear
|
||||
// stop markers or issue podman operations for the other.
|
||||
"bitcoin" | "bitcoin-knots" => vec![
|
||||
"bitcoin-knots".into(),
|
||||
"bitcoin".into(),
|
||||
"bitcoin-core".into(),
|
||||
"archy-bitcoin-knots".into(),
|
||||
"archy-bitcoin".into(),
|
||||
"bitcoin-ui".into(),
|
||||
"archy-bitcoin-ui".into(),
|
||||
],
|
||||
"bitcoin-core" => vec![
|
||||
"bitcoin-core".into(),
|
||||
"archy-bitcoin-core".into(),
|
||||
"bitcoin-ui".into(),
|
||||
"archy-bitcoin-ui".into(),
|
||||
],
|
||||
// LND + UI
|
||||
"lnd" => vec!["lnd".into(), "archy-lnd".into(), "archy-lnd-ui".into()],
|
||||
// Electrumx: multiple aliases
|
||||
@@ -359,6 +486,16 @@ pub(super) fn all_container_names(package_id: &str) -> Vec<String> {
|
||||
"penpot-exporter".into(),
|
||||
"penpot-frontend".into(),
|
||||
],
|
||||
"indeedhub" => vec![
|
||||
"indeedhub-postgres".into(),
|
||||
"indeedhub-redis".into(),
|
||||
"indeedhub-minio".into(),
|
||||
"indeedhub-relay".into(),
|
||||
"indeedhub-api".into(),
|
||||
"indeedhub-ffmpeg".into(),
|
||||
"indeedhub".into(),
|
||||
],
|
||||
"netbird" => vec!["netbird".into(), "netbird-server".into()],
|
||||
"nostr-vpn" => vec![
|
||||
"nostr-vpn".into(),
|
||||
"archy-nostr-vpn".into(),
|
||||
@@ -373,12 +510,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();
|
||||
@@ -393,16 +532,47 @@ pub(super) async fn get_containers_for_app(package_id: &str) -> Result<Vec<Strin
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{all_container_names, get_health_check_args};
|
||||
|
||||
#[test]
|
||||
fn bitcoin_variant_container_names_are_precise() {
|
||||
let core = all_container_names("bitcoin-core");
|
||||
assert!(core.contains(&"bitcoin-core".to_string()));
|
||||
assert!(!core.contains(&"bitcoin-knots".to_string()));
|
||||
|
||||
let knots = all_container_names("bitcoin-knots");
|
||||
assert!(knots.contains(&"bitcoin-knots".to_string()));
|
||||
assert!(!knots.contains(&"bitcoin-core".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grafana_health_requires_writable_data_and_http_health() {
|
||||
let args = get_health_check_args("grafana", "unused");
|
||||
let health_cmd = args
|
||||
.iter()
|
||||
.find_map(|arg| arg.strip_prefix("--health-cmd="))
|
||||
.expect("grafana should have a health command");
|
||||
|
||||
assert!(health_cmd.contains("test -w /var/lib/grafana"));
|
||||
assert!(health_cmd.contains("test -w /var/lib/grafana/grafana.db"));
|
||||
assert!(health_cmd.contains("http://localhost:3000/api/health"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Get data directories to clean for an app.
|
||||
/// Caller must validate package_id before calling.
|
||||
pub(super) fn get_data_dirs_for_app(package_id: &str) -> Vec<String> {
|
||||
let base = "/var/lib/archipelago";
|
||||
match package_id {
|
||||
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => {
|
||||
vec![format!("{}/bitcoin", base), format!("{}/bitcoin-ui", base)]
|
||||
}
|
||||
"mempool" | "mempool-web" => vec![
|
||||
format!("{}/mempool", base),
|
||||
format!("{}/mysql-mempool", base),
|
||||
format!("{}/electrumx", base),
|
||||
format!("{}/mempool-electrs", base),
|
||||
],
|
||||
"fedimint" => vec![
|
||||
format!("{}/fedimint", base),
|
||||
@@ -414,6 +584,7 @@ pub(super) fn get_data_dirs_for_app(package_id: &str) -> Vec<String> {
|
||||
format!("{}/penpot-assets", base),
|
||||
format!("{}/penpot-postgres", base),
|
||||
],
|
||||
"netbird" => vec![format!("{}/netbird", base)],
|
||||
_ => vec![format!("{}/{}", base, package_id)],
|
||||
}
|
||||
}
|
||||
@@ -475,6 +646,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()],
|
||||
@@ -483,7 +658,43 @@ pub(super) async fn get_app_config(
|
||||
None,
|
||||
None,
|
||||
),
|
||||
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => (
|
||||
"bitcoin-core" => (
|
||||
vec![
|
||||
"8332:8332".to_string(),
|
||||
"8333:8333".to_string(),
|
||||
"28332:28332".to_string(),
|
||||
"28333:28333".to_string(),
|
||||
],
|
||||
vec!["/var/lib/archipelago/bitcoin:/home/bitcoin/.bitcoin".to_string()],
|
||||
vec![],
|
||||
None,
|
||||
// Vanilla bitcoin/bitcoin image has no entrypoint wrapper and reads
|
||||
// only what's in bitcoin.conf + argv. The shared bitcoin.conf
|
||||
// carries rpcauth; we inject the networking flags as CLI args so
|
||||
// RPC is reachable from the bitcoin-ui companion container.
|
||||
//
|
||||
// Sync-speed flags:
|
||||
// -dbcache=4096 — UTXO set cache; 4GB is the sweet spot before
|
||||
// diminishing returns. Container has --memory=8g now so
|
||||
// there's headroom for mempool + connections.
|
||||
// -par=0 — use all available cores for script
|
||||
// verification (defaults to NCPU-1 capped at 16). Was
|
||||
// effectively pinned at 2 by --cpus=2 (now removed).
|
||||
// -maxconnections=125 — default but explicit, so ops can
|
||||
// tune downward on bandwidth-constrained nodes.
|
||||
Some(vec![
|
||||
"-server=1".to_string(),
|
||||
"-rpcbind=0.0.0.0".to_string(),
|
||||
"-rpcallowip=0.0.0.0/0".to_string(),
|
||||
"-rpcport=8332".to_string(),
|
||||
"-printtoconsole=1".to_string(),
|
||||
"-datadir=/home/bitcoin/.bitcoin".to_string(),
|
||||
"-dbcache=4096".to_string(),
|
||||
"-par=0".to_string(),
|
||||
"-maxconnections=125".to_string(),
|
||||
]),
|
||||
),
|
||||
"bitcoin" | "bitcoin-knots" => (
|
||||
vec![
|
||||
"8332:8332".to_string(),
|
||||
"8333:8333".to_string(),
|
||||
@@ -499,7 +710,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![],
|
||||
@@ -510,9 +721,7 @@ pub(super) async fn get_app_config(
|
||||
"--bitcoin.node=bitcoind".to_string(),
|
||||
format!("--bitcoind.rpcuser={}", rpc_user),
|
||||
format!("--bitcoind.rpcpass={}", rpc_pass),
|
||||
"--bitcoind.rpchost=host.containers.internal:8332".to_string(),
|
||||
"--bitcoind.zmqpubrawblock=tcp://host.containers.internal:28332".to_string(),
|
||||
"--bitcoind.zmqpubrawtx=tcp://host.containers.internal:28333".to_string(),
|
||||
"--bitcoind.rpchost=bitcoin-knots:8332".to_string(),
|
||||
"--rpclisten=0.0.0.0:10009".to_string(),
|
||||
"--restlisten=0.0.0.0:8080".to_string(),
|
||||
"--listen=0.0.0.0:9735".to_string(),
|
||||
@@ -526,7 +735,8 @@ pub(super) async fn get_app_config(
|
||||
"BTCPAY_PROTOCOL=http".to_string(),
|
||||
format!("BTCPAY_HOST={}:23000", host_ip),
|
||||
"BTCPAY_CHAINS=btc".to_string(),
|
||||
format!("BTCPAY_BTCRPCURL=http://{}:8332", host_ip),
|
||||
"BTCPAY_BTCEXPLORERURL=http://archy-nbxplorer:32838".to_string(),
|
||||
"BTCPAY_BTCRPCURL=http://bitcoin-knots:8332".to_string(),
|
||||
format!("BTCPAY_BTCRPCUSER={}", rpc_user),
|
||||
format!("BTCPAY_BTCRPCPASSWORD={}", rpc_pass),
|
||||
format!("BTCPAY_POSTGRES=User ID=btcpay;Password={};Host=archy-btcpay-db;Port=5432;Database=btcpay;Include Error Detail=true",
|
||||
@@ -538,7 +748,7 @@ pub(super) async fn get_app_config(
|
||||
"mempool" | "mempool-web" => (
|
||||
vec!["4080:8080".to_string()],
|
||||
vec![],
|
||||
vec![format!("BACKEND_MAINNET_HTTP_HOST={}", host_ip)],
|
||||
vec!["BACKEND_MAINNET_HTTP_HOST=mempool-api".to_string()],
|
||||
None,
|
||||
None,
|
||||
),
|
||||
@@ -547,12 +757,12 @@ pub(super) async fn get_app_config(
|
||||
vec!["/var/lib/archipelago/mempool:/data".to_string()],
|
||||
vec![
|
||||
"MEMPOOL_BACKEND=electrum".to_string(),
|
||||
"ELECTRUM_HOST=host.containers.internal".to_string(),
|
||||
"ELECTRUM_HOST=electrumx".to_string(),
|
||||
"ELECTRUM_PORT=50001".to_string(),
|
||||
"ELECTRUM_TLS_ENABLED=false".to_string(),
|
||||
format!("CORE_RPC_HOST={}", host_ip),
|
||||
"CORE_RPC_HOST=bitcoin-knots".to_string(),
|
||||
"CORE_RPC_PORT=8332".to_string(),
|
||||
format!("CORE_RPC_USERNAME={}", rpc_user),
|
||||
"CORE_RPC_USERNAME=archipelago".to_string(),
|
||||
format!("CORE_RPC_PASSWORD={}", rpc_pass),
|
||||
"DATABASE_ENABLED=true".to_string(),
|
||||
"DATABASE_HOST=archy-mempool-db".to_string(),
|
||||
@@ -569,12 +779,20 @@ pub(super) async fn get_app_config(
|
||||
vec!["/var/lib/archipelago/electrumx:/data".to_string()],
|
||||
vec![
|
||||
format!(
|
||||
"DAEMON_URL=http://{}:{}@host.containers.internal:8332/",
|
||||
"DAEMON_URL=http://{}:{}@bitcoin-knots:8332/",
|
||||
rpc_user, rpc_pass
|
||||
),
|
||||
"COIN=Bitcoin".to_string(),
|
||||
"DB_DIRECTORY=/data".to_string(),
|
||||
"SERVICES=tcp://:50001,rpc://0.0.0.0:8000".to_string(),
|
||||
// Sync-speed: bigger LRU/write cache during initial
|
||||
// history index. Default is 1200MB; 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(),
|
||||
],
|
||||
None,
|
||||
None,
|
||||
@@ -587,7 +805,7 @@ pub(super) async fn get_app_config(
|
||||
"MYSQL_DATABASE=mempool".to_string(),
|
||||
"MYSQL_USER=mempool".to_string(),
|
||||
format!("MYSQL_PASSWORD={}", read_secret("mempool-db-password", "mempoolpass")),
|
||||
format!("MYSQL_ROOT_PASSWORD={}", read_secret("mempool-db-root-password", "rootpass")),
|
||||
format!("MYSQL_ROOT_PASSWORD={}", read_secret("mysql-root-db-password", "rootpass")),
|
||||
],
|
||||
None,
|
||||
None,
|
||||
@@ -711,45 +929,70 @@ 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![
|
||||
"/var/lib/archipelago/portainer:/data".to_string(),
|
||||
"/var/run/podman/podman.sock:/var/run/docker.sock".to_string(),
|
||||
"/run/user/1000/podman/podman.sock:/var/run/docker.sock".to_string(),
|
||||
],
|
||||
vec![],
|
||||
None,
|
||||
None,
|
||||
),
|
||||
"uptime-kuma" => (
|
||||
vec!["3001:3001".to_string()],
|
||||
vec!["3002:3001".to_string()],
|
||||
vec!["/var/lib/archipelago/uptime-kuma:/app/data".to_string()],
|
||||
vec!["TZ=UTC".to_string()],
|
||||
None,
|
||||
None,
|
||||
Some(vec![
|
||||
"--".to_string(),
|
||||
"node".to_string(),
|
||||
"server/server.js".to_string(),
|
||||
]),
|
||||
),
|
||||
"tailscale" => (
|
||||
vec!["8240:8240".to_string()],
|
||||
vec!["/var/lib/archipelago/tailscale:/var/lib/tailscale".to_string()],
|
||||
vec!["TS_STATE_DIR=/var/lib/tailscale".to_string()],
|
||||
Some(
|
||||
"sh -c 'tailscale web --listen 0.0.0.0:8240 & exec tailscaled'".to_string(),
|
||||
),
|
||||
// Don't use custom_command (Option<String>) — install.rs passes
|
||||
// it as a SINGLE arg to podman, which then treats the whole
|
||||
// "sh -c 'tailscale web …'" string as the executable name and
|
||||
// fails: "executable file `sh -c 'tailscale web …'` not found".
|
||||
// custom_args (Option<Vec<String>>) splits properly.
|
||||
None,
|
||||
Some(vec![
|
||||
"sh".to_string(),
|
||||
"-c".to_string(),
|
||||
"tailscaled --tun=userspace-networking & sleep 2; tailscale web --listen 0.0.0.0:8240 & wait".to_string(),
|
||||
]),
|
||||
),
|
||||
"fedimint" => (
|
||||
vec![
|
||||
@@ -768,13 +1011,13 @@ pub(super) async fn get_app_config(
|
||||
"FM_BIND_UI=0.0.0.0:8175".to_string(),
|
||||
format!("FM_P2P_URL=fedimint://{}:8173", host_ip),
|
||||
format!("FM_API_URL=ws://{}:8174", host_ip),
|
||||
format!("FM_BITCOIND_URL=http://{}:8332", host_ip),
|
||||
"FM_BITCOIND_URL=http://bitcoin-knots:8332".to_string(),
|
||||
],
|
||||
None,
|
||||
Some(vec![
|
||||
"--data-dir".to_string(),
|
||||
"/data".to_string(),
|
||||
format!("--bitcoind-url=http://{}:{}@{}:8332", rpc_user, rpc_pass, host_ip),
|
||||
format!("--bitcoind-url=http://{}:{}@bitcoin-knots:8332", rpc_user, rpc_pass),
|
||||
]),
|
||||
),
|
||||
"fedimint-gateway" => {
|
||||
@@ -798,7 +1041,7 @@ pub(super) async fn get_app_config(
|
||||
"--network".to_string(),
|
||||
"bitcoin".to_string(),
|
||||
"--bitcoind-url".to_string(),
|
||||
format!("http://{}:8332", host_ip),
|
||||
"http://bitcoin-knots:8332".to_string(),
|
||||
"--bitcoind-username".to_string(),
|
||||
rpc_user.to_string(),
|
||||
"--bitcoind-password".to_string(),
|
||||
@@ -909,8 +1152,8 @@ pub(super) async fn get_app_config(
|
||||
None,
|
||||
)
|
||||
}
|
||||
// Gitea binds to 3001 internally. Nginx on port 3000 strips X-Frame-Options
|
||||
// so Gitea works in Archipelago's iframe. See nginx-gitea-iframe.conf.
|
||||
// Gitea listens on container port 3000 and is launched directly on
|
||||
// host port 3001 because it blocks iframe embedding.
|
||||
"gitea" => (
|
||||
vec!["3001:3000".to_string(), "2222:22".to_string()],
|
||||
vec![
|
||||
@@ -931,31 +1174,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)
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use super::config::get_containers_for_app;
|
||||
use anyhow::Result;
|
||||
use crate::data_model::{PackageDataEntry, PackageState};
|
||||
use anyhow::{Context, Result};
|
||||
use std::collections::HashMap;
|
||||
use tracing::info;
|
||||
|
||||
/// Names of container variants that represent a running Bitcoin node
|
||||
@@ -7,6 +9,20 @@ 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!(
|
||||
package_id,
|
||||
"electrumx" | "mempool-electrs" | "electrs" | "mempool" | "mempool-web"
|
||||
)
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -15,13 +31,43 @@ pub(super) struct RunningDeps {
|
||||
pub has_lnd: bool,
|
||||
}
|
||||
|
||||
pub(super) fn detect_running_deps_from_package_data(
|
||||
packages: &HashMap<String, PackageDataEntry>,
|
||||
) -> RunningDeps {
|
||||
let is_running = |names: &[&str]| {
|
||||
names.iter().any(|name| {
|
||||
packages
|
||||
.get(*name)
|
||||
.map(|pkg| pkg.state == PackageState::Running)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
};
|
||||
|
||||
RunningDeps {
|
||||
has_bitcoin: is_running(BITCOIN_NAMES),
|
||||
has_electrumx: is_running(ELECTRUM_NAMES),
|
||||
has_lnd: is_running(&["lnd"]),
|
||||
}
|
||||
}
|
||||
|
||||
/// Query podman for currently running containers and return dependency status.
|
||||
pub(super) async fn detect_running_deps() -> Result<RunningDeps> {
|
||||
let dep_check = tokio::process::Command::new("podman")
|
||||
.args(["ps", "--format", "{{.Names}}"])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to check running containers: {}", e))?;
|
||||
let dep_check = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(30),
|
||||
tokio::process::Command::new("podman")
|
||||
.args(["ps", "--format", "{{.Names}}"])
|
||||
.output(),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("Timed out checking running containers"))?
|
||||
.map_err(|e| anyhow::anyhow!("Failed to check running containers: {}", e))?;
|
||||
|
||||
if !dep_check.status.success() {
|
||||
anyhow::bail!(
|
||||
"Failed to check running containers: {}",
|
||||
String::from_utf8_lossy(&dep_check.stderr).trim()
|
||||
);
|
||||
}
|
||||
|
||||
let running = String::from_utf8_lossy(&dep_check.stdout);
|
||||
let is_running = |names: &[&str]| {
|
||||
@@ -76,6 +122,107 @@ pub(super) fn check_install_deps(package_id: &str, deps: &RunningDeps) -> Result
|
||||
}
|
||||
}
|
||||
|
||||
/// ElectrumX and Mempool's Electrum backend need historical blocks from an
|
||||
/// unpruned node while building their indexes. A pruned Bitcoin node can be
|
||||
/// running and RPC-reachable but still leave them stuck with closed ports.
|
||||
pub(super) async fn check_bitcoin_pruning_compatibility(package_id: &str) -> Result<()> {
|
||||
if !requires_unpruned_bitcoin(package_id) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let (rpc_user, rpc_pass) = crate::bitcoin_rpc::bitcoin_rpc_credentials().await;
|
||||
let body = serde_json::json!({
|
||||
"jsonrpc": "1.0",
|
||||
"id": "package-install-prune-check",
|
||||
"method": "getblockchaininfo",
|
||||
"params": [],
|
||||
});
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.context("building Bitcoin RPC client")?;
|
||||
|
||||
let 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");
|
||||
};
|
||||
if result
|
||||
.get("pruned")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
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 {
|
||||
@@ -129,6 +276,18 @@ pub(super) fn startup_order(package_id: &str) -> &'static [&'static str] {
|
||||
"mempool",
|
||||
],
|
||||
"immich" => &["immich_postgres", "immich_redis", "immich_server"],
|
||||
"indeedhub" => &[
|
||||
"indeedhub-postgres",
|
||||
"indeedhub-redis",
|
||||
"indeedhub-minio",
|
||||
"indeedhub-relay",
|
||||
"indeedhub-api",
|
||||
"indeedhub-ffmpeg",
|
||||
"indeedhub",
|
||||
],
|
||||
"btcpay-server" | "btcpayserver" | "btcpay" => {
|
||||
&["archy-btcpay-db", "archy-nbxplorer", "btcpay-server"]
|
||||
}
|
||||
"penpot" | "penpot-frontend" => &[
|
||||
"penpot-postgres",
|
||||
"penpot-valkey",
|
||||
@@ -144,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)
|
||||
}
|
||||
@@ -211,3 +376,32 @@ pub(super) fn configure_fedimint_lnd(
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
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() {
|
||||
for package_id in [
|
||||
"electrumx",
|
||||
"mempool-electrs",
|
||||
"electrs",
|
||||
"mempool",
|
||||
"mempool-web",
|
||||
] {
|
||||
assert!(requires_unpruned_bitcoin(package_id), "{package_id}");
|
||||
}
|
||||
for package_id in ["bitcoin-knots", "btcpay-server", "lnd", "fedimint"] {
|
||||
assert!(!requires_unpruned_bitcoin(package_id), "{package_id}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
||||
mod async_lifecycle;
|
||||
mod config;
|
||||
mod dependencies;
|
||||
mod install;
|
||||
@@ -8,5 +9,6 @@ mod stacks;
|
||||
mod update;
|
||||
mod validation;
|
||||
|
||||
// Re-export items needed by sibling modules (container.rs, security.rs)
|
||||
// Re-export items needed by sibling modules (container.rs, security.rs, transitional.rs)
|
||||
pub(in crate::api::rpc) use install::install_log;
|
||||
pub(super) use validation::validate_app_id;
|
||||
|
||||
@@ -2,12 +2,17 @@
|
||||
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use crate::data_model::{
|
||||
Description, InstallProgress, Manifest, PackageDataEntry, PackageState, StaticFiles,
|
||||
Description, InstallPhase, InstallProgress, Manifest, PackageDataEntry, PackageState,
|
||||
StaticFiles,
|
||||
};
|
||||
|
||||
impl RpcHandler {
|
||||
/// Set install progress for a package and broadcast the update.
|
||||
/// Creates a minimal package entry if one doesn't exist yet.
|
||||
///
|
||||
/// Prefer `set_install_phase` — this byte-counter API is kept for
|
||||
/// the rare case where the pull stream actually parses, but podman
|
||||
/// almost never emits parseable progress on a piped stderr.
|
||||
pub(super) async fn set_install_progress(&self, package_id: &str, downloaded: u64, size: u64) {
|
||||
let (mut data, _rev) = self.state_manager.get_snapshot().await;
|
||||
let entry = data
|
||||
@@ -15,7 +20,44 @@ impl RpcHandler {
|
||||
.entry(package_id.to_string())
|
||||
.or_insert_with(|| create_installing_entry(package_id));
|
||||
entry.state = PackageState::Installing;
|
||||
entry.install_progress = Some(InstallProgress { size, downloaded });
|
||||
let existing_phase = entry.install_progress.as_ref().and_then(|p| p.phase);
|
||||
entry.install_progress = Some(InstallProgress {
|
||||
size,
|
||||
downloaded,
|
||||
phase: existing_phase,
|
||||
message: None,
|
||||
});
|
||||
self.state_manager.update_data(data).await;
|
||||
}
|
||||
|
||||
/// Set the install pipeline phase and broadcast. This is the
|
||||
/// primary progress signal — the UI maps each phase to a
|
||||
/// percentage and a user-facing label. Byte counters are retained
|
||||
/// for the rare case podman emits parseable progress.
|
||||
pub(super) async fn set_install_phase(&self, package_id: &str, phase: InstallPhase) {
|
||||
let (mut data, _rev) = self.state_manager.get_snapshot().await;
|
||||
let entry = data
|
||||
.package_data
|
||||
.entry(package_id.to_string())
|
||||
.or_insert_with(|| create_installing_entry(package_id));
|
||||
// Preparing / PullingImage / CreatingContainer / StartingContainer /
|
||||
// WaitingHealthy / PostInstall all map to the Installing state.
|
||||
// Updates use Updating state — the wrapper has already flipped
|
||||
// state to Updating, so don't clobber it.
|
||||
if entry.state != PackageState::Updating {
|
||||
entry.state = PackageState::Installing;
|
||||
}
|
||||
let (size, downloaded) = entry
|
||||
.install_progress
|
||||
.as_ref()
|
||||
.map(|p| (p.size, p.downloaded))
|
||||
.unwrap_or((0, 0));
|
||||
entry.install_progress = Some(InstallProgress {
|
||||
size,
|
||||
downloaded,
|
||||
phase: Some(phase),
|
||||
message: None,
|
||||
});
|
||||
self.state_manager.update_data(data).await;
|
||||
}
|
||||
|
||||
@@ -52,9 +94,12 @@ impl RpcHandler {
|
||||
.package_data
|
||||
.entry(package_id.to_string())
|
||||
.or_insert_with(|| create_installing_entry(package_id));
|
||||
let existing_phase = entry.install_progress.as_ref().and_then(|p| p.phase);
|
||||
entry.install_progress = Some(InstallProgress {
|
||||
size: total,
|
||||
downloaded,
|
||||
phase: existing_phase,
|
||||
message: None,
|
||||
});
|
||||
state_manager.update_data(data).await;
|
||||
}
|
||||
@@ -69,7 +114,11 @@ fn create_installing_entry(package_id: &str) -> PackageDataEntry {
|
||||
static_files: StaticFiles {
|
||||
license: String::new(),
|
||||
instructions: String::new(),
|
||||
icon: format!("/assets/img/app-icons/{}.png", package_id),
|
||||
// Empty icon: hardcoding `<id>.png` is wrong for apps that use
|
||||
// `.svg` or `.webp` assets and produces a broken-image flicker.
|
||||
// The frontend's `icon` computed falls through to the curated
|
||||
// map which has correct extensions for known apps.
|
||||
icon: String::new(),
|
||||
},
|
||||
manifest: Manifest {
|
||||
id: package_id.to_string(),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
//! Per-app manual update handler.
|
||||
//!
|
||||
//! Flow: validate → set Updating state → graceful stop → pull new image(s) →
|
||||
//! remove old container(s) → recreate via reconcile script → verify running.
|
||||
//! remove old container(s) → recreate (orchestrator-first, legacy fallback) → verify running.
|
||||
//! Data volumes are preserved (bind mounts, not stored in container).
|
||||
|
||||
use super::config::get_containers_for_app;
|
||||
@@ -11,7 +11,7 @@ use super::runtime::stop_timeout_secs;
|
||||
use super::validation::validate_app_id;
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use crate::container::image_versions;
|
||||
use crate::data_model::PackageState;
|
||||
use crate::data_model::{InstallPhase, PackageState};
|
||||
use anyhow::{Context, Result};
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tracing::{error, info, warn};
|
||||
@@ -34,15 +34,10 @@ impl RpcHandler {
|
||||
let pinned = image_versions::pinned_image_for_app(package_id)
|
||||
.ok_or_else(|| anyhow::anyhow!("No pinned image found for {}", package_id))?;
|
||||
|
||||
// Reject if already updating
|
||||
{
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
if let Some(entry) = data.package_data.get(package_id) {
|
||||
if entry.state == PackageState::Updating {
|
||||
return Err(anyhow::anyhow!("{} is already updating", package_id));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Note: the `already updating` guard lives in `spawn_package_update`
|
||||
// (the async wrapper that dispatch actually routes to). By the time
|
||||
// this inner function runs, the wrapper has already flipped state to
|
||||
// `Updating`, so duplicating the check here would be a false positive.
|
||||
|
||||
install_log(&format!("UPDATE: {} → {}", package_id, pinned)).await;
|
||||
|
||||
@@ -56,6 +51,64 @@ impl RpcHandler {
|
||||
self.state_manager.update_data(data).await;
|
||||
}
|
||||
|
||||
// Preferred path: for single-container apps managed by manifests, route
|
||||
// updates through the orchestrator's upgrade lifecycle instead of the
|
||||
// legacy shell/CLI flow. Keep stack-style packages on legacy for now.
|
||||
if should_try_orchestrator_update(package_id, self.orchestrator.is_some()) {
|
||||
let orchestrator_app_id = orchestrator_update_app_id(package_id);
|
||||
self.set_install_phase(package_id, InstallPhase::Preparing)
|
||||
.await;
|
||||
install_log(&format!(
|
||||
"UPDATE ORCH: {} — attempting orchestrator upgrade as {}",
|
||||
package_id, orchestrator_app_id
|
||||
))
|
||||
.await;
|
||||
|
||||
if let Some(orchestrator) = self.orchestrator.as_ref() {
|
||||
match orchestrator.upgrade(orchestrator_app_id).await {
|
||||
Ok(()) => {
|
||||
self.set_install_phase(package_id, InstallPhase::WaitingHealthy)
|
||||
.await;
|
||||
if let Ok(health) = orchestrator.health(orchestrator_app_id).await {
|
||||
if health != "healthy" {
|
||||
warn!(
|
||||
"Update {}: orchestrator upgrade completed with health={} (expected healthy)",
|
||||
package_id, health
|
||||
);
|
||||
}
|
||||
}
|
||||
install_log(&format!(
|
||||
"UPDATE ORCH OK: {} (app={})",
|
||||
package_id, orchestrator_app_id
|
||||
))
|
||||
.await;
|
||||
self.clear_install_progress(package_id).await;
|
||||
return Ok(serde_json::json!({
|
||||
"status": "updated",
|
||||
"package_id": package_id,
|
||||
}));
|
||||
}
|
||||
Err(e) if is_unknown_app_id_error(&e) => {
|
||||
info!(
|
||||
"Update {}: orchestrator has no manifest mapping yet, falling back to legacy updater",
|
||||
package_id
|
||||
);
|
||||
install_log(&format!(
|
||||
"UPDATE ORCH SKIP: {} — unknown app_id, using legacy flow",
|
||||
package_id
|
||||
))
|
||||
.await;
|
||||
}
|
||||
Err(e) => {
|
||||
install_log(&format!("UPDATE ORCH FAIL: {} — {}", package_id, e)).await;
|
||||
self.clear_install_progress(package_id).await;
|
||||
self.clear_update_state(package_id).await;
|
||||
return Err(e.context(format!("Orchestrator update {} failed", package_id)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve images to pull — either a stack or single container
|
||||
let images_to_pull = self.resolve_images_to_pull(package_id, &pinned);
|
||||
|
||||
@@ -101,6 +154,11 @@ impl RpcHandler {
|
||||
containers: &[String],
|
||||
images_to_pull: &[(String, String)],
|
||||
) -> Result<()> {
|
||||
// Phase: Preparing — about to stop the running container(s) so
|
||||
// we can swap images. Fast.
|
||||
self.set_install_phase(package_id, InstallPhase::Preparing)
|
||||
.await;
|
||||
|
||||
// 1. Graceful stop all containers (reverse order for dependencies)
|
||||
info!(
|
||||
"Update {}: stopping {} containers",
|
||||
@@ -130,6 +188,10 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// Phase: PullingImage — about to fetch each pinned image in turn.
|
||||
self.set_install_phase(package_id, InstallPhase::PullingImage)
|
||||
.await;
|
||||
|
||||
// 2. Pull new images with progress
|
||||
info!(
|
||||
"Update {}: pulling {} images",
|
||||
@@ -173,38 +235,23 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Recreate via reconcile script (single source of truth for container specs)
|
||||
info!("Update {}: recreating containers via reconcile", package_id);
|
||||
// Phase: CreatingContainer — about to recreate each container.
|
||||
self.set_install_phase(package_id, InstallPhase::CreatingContainer)
|
||||
.await;
|
||||
|
||||
// 4. Recreate containers (orchestrator-first, reconcile fallback)
|
||||
info!("Update {}: recreating containers", package_id);
|
||||
for name in containers {
|
||||
let out = tokio::process::Command::new("bash")
|
||||
.args([
|
||||
"/opt/archipelago/scripts/reconcile-containers.sh",
|
||||
&format!("--container={}", name),
|
||||
"--force",
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
.context(format!("Failed to reconcile {}", name))?;
|
||||
if !out.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
error!(
|
||||
"Update {}: reconcile {} failed:\nstdout: {}\nstderr: {}",
|
||||
package_id,
|
||||
name,
|
||||
stdout.trim(),
|
||||
stderr.trim()
|
||||
);
|
||||
return Err(anyhow::anyhow!(
|
||||
"Reconcile failed for {}: {}",
|
||||
name,
|
||||
stderr.trim()
|
||||
));
|
||||
}
|
||||
self.recreate_container_for_update(package_id, name).await?;
|
||||
// Brief delay between containers for dependency initialization
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
}
|
||||
|
||||
// Phase: WaitingHealthy — reconcile has started every container,
|
||||
// now verifying each reached running state.
|
||||
self.set_install_phase(package_id, InstallPhase::WaitingHealthy)
|
||||
.await;
|
||||
|
||||
// 5. Verify containers reached running state
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
for name in containers {
|
||||
@@ -226,12 +273,62 @@ impl RpcHandler {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn recreate_container_for_update(
|
||||
&self,
|
||||
package_id: &str,
|
||||
container_name: &str,
|
||||
) -> Result<()> {
|
||||
let Some(orchestrator) = self.orchestrator.as_ref() else {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Cannot recreate {} during update {}: orchestrator unavailable",
|
||||
container_name,
|
||||
package_id
|
||||
));
|
||||
};
|
||||
|
||||
let mut attempted = Vec::new();
|
||||
for app_id in candidate_app_ids_for_container(container_name) {
|
||||
attempted.push(app_id.clone());
|
||||
match orchestrator.install(&app_id).await {
|
||||
Ok(created_name) => {
|
||||
install_log(&format!(
|
||||
"UPDATE ORCH RECREATE OK: {} — container={} app_id={} created={}",
|
||||
package_id, container_name, app_id, created_name
|
||||
))
|
||||
.await;
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) if is_unknown_app_id_error(&e) => {
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(e.context(format!(
|
||||
"orchestrator recreate failed for update {} (container={}, app_id={})",
|
||||
package_id, container_name, app_id
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow::anyhow!(
|
||||
"No manifest mapping found while recreating {} during update {} (attempted app_ids: {})",
|
||||
container_name,
|
||||
package_id,
|
||||
attempted.join(", ")
|
||||
))
|
||||
}
|
||||
|
||||
/// Pull a single image with progress broadcasting (reuses install progress pattern).
|
||||
async fn pull_update_image(&self, package_id: &str, image: &str) -> Result<()> {
|
||||
self.set_install_progress(package_id, 0, 0).await;
|
||||
|
||||
let mut child = tokio::process::Command::new("podman")
|
||||
.args(["pull", image])
|
||||
let mut cmd = tokio::process::Command::new("podman");
|
||||
cmd.arg("pull");
|
||||
if archipelago_container::image_uses_insecure_registry(image) {
|
||||
cmd.arg("--tls-verify=false");
|
||||
}
|
||||
let mut child = cmd
|
||||
.arg(image)
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
@@ -296,15 +393,16 @@ impl RpcHandler {
|
||||
Ok(o) => {
|
||||
let stderr = String::from_utf8_lossy(&o.stderr);
|
||||
warn!("Rollback: could not restart {}: {}", name, stderr.trim());
|
||||
// Container was already removed — try reconcile to recreate with old image
|
||||
let _ = tokio::process::Command::new("bash")
|
||||
.args([
|
||||
"/opt/archipelago/scripts/reconcile-containers.sh",
|
||||
&format!("--container={}", name),
|
||||
"--force",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
// Container was already removed (forward path ran `podman rm`).
|
||||
// Recreate via orchestrator-first path with legacy fallback.
|
||||
if let Err(recreate_err) =
|
||||
self.recreate_container_for_update(package_id, name).await
|
||||
{
|
||||
error!(
|
||||
"Rollback: failed to recreate {} during rollback of {}: {}",
|
||||
name, package_id, recreate_err
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Rollback: failed to restart {}: {}", name, e);
|
||||
@@ -325,3 +423,129 @@ impl RpcHandler {
|
||||
self.state_manager.update_data(data).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn should_try_orchestrator_update(package_id: &str, orchestrator_available: bool) -> bool {
|
||||
orchestrator_available && !uses_legacy_update_flow(package_id)
|
||||
}
|
||||
|
||||
fn orchestrator_update_app_id(package_id: &str) -> &str {
|
||||
match package_id {
|
||||
"bitcoin-knots" => "bitcoin-core",
|
||||
"electrs" | "mempool-electrs" => "electrumx",
|
||||
_ => package_id,
|
||||
}
|
||||
}
|
||||
|
||||
fn uses_legacy_update_flow(package_id: &str) -> bool {
|
||||
matches!(
|
||||
package_id,
|
||||
// Multi-container stacks still updated via the stack-aware path.
|
||||
"immich" | "penpot" | "penpot-frontend" | "indeedhub"
|
||||
)
|
||||
}
|
||||
|
||||
fn is_unknown_app_id_error(err: &anyhow::Error) -> bool {
|
||||
err.chain()
|
||||
.any(|cause| cause.to_string().contains("unknown app_id"))
|
||||
}
|
||||
|
||||
fn candidate_app_ids_for_container(container_name: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut push = |s: &str| {
|
||||
if !out.iter().any(|e: &String| e == s) {
|
||||
out.push(s.to_string());
|
||||
}
|
||||
};
|
||||
|
||||
match container_name {
|
||||
"bitcoin-knots" | "bitcoin-core" => {
|
||||
push("bitcoin-core");
|
||||
push("bitcoin-knots");
|
||||
}
|
||||
"archy-bitcoin-ui" => push("bitcoin-ui"),
|
||||
"archy-lnd-ui" => push("lnd-ui"),
|
||||
"archy-electrs-ui" => push("electrs-ui"),
|
||||
"mempool" => {
|
||||
push("archy-mempool-web");
|
||||
push("mempool");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
push(container_name);
|
||||
if let Some(stripped) = container_name.strip_prefix("archy-") {
|
||||
push(stripped);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
candidate_app_ids_for_container, orchestrator_update_app_id,
|
||||
should_try_orchestrator_update, uses_legacy_update_flow,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn legacy_flow_for_stack_apps() {
|
||||
for app in ["immich", "penpot", "indeedhub"] {
|
||||
assert!(uses_legacy_update_flow(app), "{app} should stay legacy");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orchestrator_flow_for_single_apps() {
|
||||
for app in [
|
||||
"lnd",
|
||||
"bitcoin-core",
|
||||
"searxng",
|
||||
"grafana",
|
||||
"btcpay-server",
|
||||
"mempool",
|
||||
"fedimint",
|
||||
] {
|
||||
assert!(
|
||||
!uses_legacy_update_flow(app),
|
||||
"{app} should be orchestrator-first"
|
||||
);
|
||||
assert!(
|
||||
should_try_orchestrator_update(app, true),
|
||||
"{app} should use orchestrator when available"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_orchestrator_means_no_orchestrator_flow() {
|
||||
assert!(!should_try_orchestrator_update("lnd", false));
|
||||
assert!(!should_try_orchestrator_update("btcpay-server", false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn container_name_candidates_cover_common_aliases() {
|
||||
assert_eq!(
|
||||
candidate_app_ids_for_container("bitcoin-knots"),
|
||||
vec!["bitcoin-core", "bitcoin-knots"]
|
||||
);
|
||||
assert_eq!(
|
||||
candidate_app_ids_for_container("archy-bitcoin-ui"),
|
||||
vec!["bitcoin-ui", "archy-bitcoin-ui"]
|
||||
);
|
||||
assert_eq!(
|
||||
candidate_app_ids_for_container("mempool"),
|
||||
vec!["archy-mempool-web", "mempool"]
|
||||
);
|
||||
assert_eq!(
|
||||
candidate_app_ids_for_container("archy-mempool-db"),
|
||||
vec!["archy-mempool-db", "mempool-db"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_aliases_map_to_manifest_app_ids() {
|
||||
assert_eq!(orchestrator_update_app_id("bitcoin-knots"), "bitcoin-core");
|
||||
assert_eq!(orchestrator_update_app_id("electrs"), "electrumx");
|
||||
assert_eq!(orchestrator_update_app_id("mempool-electrs"), "electrumx");
|
||||
assert_eq!(orchestrator_update_app_id("fedimint"), "fedimint");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,8 +147,7 @@ impl RpcHandler {
|
||||
.get("onion")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing onion"))?;
|
||||
let fips_npub =
|
||||
crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||
let reachable = node_message::check_peer_reachable(onion, fips_npub.as_deref())
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
|
||||
@@ -353,9 +353,9 @@ 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" => 7777,
|
||||
"indeedhub" => 7778,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
//! Async lifecycle helper for container Stop/Start/Restart RPCs.
|
||||
//!
|
||||
//! The `ContainerOrchestrator` trait is intentionally synchronous — blocking
|
||||
//! calls keep the reconciler, boot flow, chaos harness, and unit tests
|
||||
//! deterministic. But the RPC layer must return to the UI in <1s so the
|
||||
//! dashboard can render a transitional "Stopping…" / "Starting…" label while
|
||||
//! the underlying `podman stop` (up to 600s for bitcoin-core) runs in the
|
||||
//! background.
|
||||
//!
|
||||
//! `RpcHandler::spawn_transitional` bridges the two: it
|
||||
//! 1. flips the package state in `StateManager` to the appropriate
|
||||
//! transitional variant (`Stopping` / `Starting` / `Restarting`),
|
||||
//! which fans out to WebSocket clients immediately.
|
||||
//! 2. `tokio::spawn`s the actual orchestrator call.
|
||||
//! 3. on success, writes the final state (`Stopped` / `Running`).
|
||||
//! 4. on error, reverts to the pre-transition state and logs via
|
||||
//! `install_log()` so the incident shows up in
|
||||
//! `/var/log/archipelago/container-installs.log`.
|
||||
//!
|
||||
//! The server.rs package-scan loop must also be taught to preserve
|
||||
//! transitional states — see `server.rs:scan_and_update_packages`'s merge
|
||||
//! logic and the companion `merge_preserving_transitional` helper.
|
||||
|
||||
use super::package::install_log;
|
||||
use super::RpcHandler;
|
||||
use crate::container::ContainerOrchestrator;
|
||||
use crate::data_model::PackageState;
|
||||
use crate::state::StateManager;
|
||||
use anyhow::Result;
|
||||
use std::sync::Arc;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
/// The three transitional lifecycle operations that run asynchronously from
|
||||
/// the RPC handler. `Install` and `Remove` are intentionally NOT here — they
|
||||
/// already have their own progress-tracking paths (`install_progress`,
|
||||
/// `uninstall_stage`) with multi-step UI feedback.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(super) enum Op {
|
||||
Stop,
|
||||
Start,
|
||||
Restart,
|
||||
}
|
||||
|
||||
impl Op {
|
||||
/// The `PackageState` to set on the entry while the operation is in
|
||||
/// flight. The package-scan merge loop must preserve this variant and
|
||||
/// refuse to overwrite it with whatever podman reports (see
|
||||
/// `merge_preserving_transitional` in server.rs).
|
||||
fn transitional_state(self) -> PackageState {
|
||||
match self {
|
||||
Op::Stop => PackageState::Stopping,
|
||||
Op::Start => PackageState::Starting,
|
||||
Op::Restart => PackageState::Restarting,
|
||||
}
|
||||
}
|
||||
|
||||
/// The `PackageState` to set on success. On error the caller reverts to
|
||||
/// the pre-transition state rather than using these.
|
||||
fn final_state_on_success(self) -> PackageState {
|
||||
match self {
|
||||
Op::Stop => PackageState::Stopped,
|
||||
Op::Start => PackageState::Running,
|
||||
Op::Restart => PackageState::Running,
|
||||
}
|
||||
}
|
||||
|
||||
/// Prefix used in `install_log` entries so post-mortem readers can grep
|
||||
/// the operation that failed.
|
||||
fn log_prefix(self) -> &'static str {
|
||||
match self {
|
||||
Op::Stop => "STOP",
|
||||
Op::Start => "START",
|
||||
Op::Restart => "RESTART",
|
||||
}
|
||||
}
|
||||
|
||||
/// Call the orchestrator for this op. Kept in one place so the spawned
|
||||
/// task doesn't repeat the match four times.
|
||||
async fn dispatch(self, orch: &dyn ContainerOrchestrator, app_id: &str) -> Result<()> {
|
||||
match self {
|
||||
Op::Stop => orch.stop(app_id).await,
|
||||
Op::Start => orch.start(app_id).await,
|
||||
Op::Restart => orch.restart(app_id).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
/// Flip the package state to `op.transitional_state()`, spawn a background
|
||||
/// task that runs `op.dispatch()`, and return immediately. The spawned
|
||||
/// task writes the final state on completion or reverts to the
|
||||
/// pre-transition state on failure.
|
||||
///
|
||||
/// If no package entry exists for `app_id` (e.g. Start on a container
|
||||
/// that was never installed), no pre-state is recorded and the spawn
|
||||
/// still runs — the post-success path will no-op the state write and
|
||||
/// the next scan will pick up the newly-created entry with the correct
|
||||
/// state. This keeps the helper usable for stacks that lazily create
|
||||
/// their entries.
|
||||
pub(super) async fn spawn_transitional(&self, op: Op, app_id: String) -> Result<()> {
|
||||
let orchestrator = self
|
||||
.orchestrator
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available"))?
|
||||
.clone();
|
||||
let state_manager = Arc::clone(&self.state_manager);
|
||||
|
||||
// Snapshot pre-transition state (for revert on error) and flip to
|
||||
// transitional variant. Done BEFORE the spawn so the WebSocket push
|
||||
// beats the RPC response — the UI should see "Stopping…" the moment
|
||||
// it gets the RPC ok, not on the next scan.
|
||||
let pre_state =
|
||||
flip_to_transitional(&state_manager, &app_id, op.transitional_state()).await;
|
||||
|
||||
let log_prefix = op.log_prefix();
|
||||
let app_id_log = app_id.clone();
|
||||
install_log(&format!("{}: {}", log_prefix, app_id_log)).await;
|
||||
|
||||
tokio::spawn(async move {
|
||||
match op.dispatch(orchestrator.as_ref(), &app_id).await {
|
||||
Ok(()) => {
|
||||
info!("{} complete: {}", log_prefix, app_id);
|
||||
set_state(&state_manager, &app_id, op.final_state_on_success()).await;
|
||||
}
|
||||
Err(e) => {
|
||||
error!("{} failed for {}: {:#}", log_prefix, app_id, e);
|
||||
install_log(&format!("{} FAIL: {} — {:#}", log_prefix, app_id, e)).await;
|
||||
// Revert to pre-transition state if we had one; otherwise
|
||||
// leave the entry untouched so the next scan reconciles.
|
||||
if let Some(prev) = pre_state {
|
||||
set_state(&state_manager, &app_id, prev).await;
|
||||
} else {
|
||||
warn!(
|
||||
"{}: no pre-transition state recorded for {}; leaving entry to next scan",
|
||||
log_prefix, app_id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Flip the entry's state to `transitional` and return the previous state.
|
||||
/// Returns `None` if there is no entry for `app_id`.
|
||||
async fn flip_to_transitional(
|
||||
state_manager: &StateManager,
|
||||
app_id: &str,
|
||||
transitional: PackageState,
|
||||
) -> Option<PackageState> {
|
||||
let (mut data, _) = state_manager.get_snapshot().await;
|
||||
let prev = data.package_data.get(app_id).map(|e| e.state.clone());
|
||||
if let Some(entry) = data.package_data.get_mut(app_id) {
|
||||
entry.state = transitional;
|
||||
state_manager.update_data(data).await;
|
||||
}
|
||||
prev
|
||||
}
|
||||
|
||||
/// Set the entry's state to `new_state`. No-ops if the entry has since been
|
||||
/// removed (e.g. uninstall ran concurrently).
|
||||
async fn set_state(state_manager: &StateManager, app_id: &str, new_state: PackageState) {
|
||||
let (mut data, _) = state_manager.get_snapshot().await;
|
||||
if let Some(entry) = data.package_data.get_mut(app_id) {
|
||||
if entry.state != new_state {
|
||||
entry.state = new_state;
|
||||
state_manager.update_data(data).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,26 +4,9 @@ use anyhow::{Context, Result};
|
||||
|
||||
impl RpcHandler {
|
||||
/// Check for available system updates.
|
||||
/// Tries git-based check first (if repo exists), falls back to manifest-based.
|
||||
/// Prefer manifest-based OTA so installed nodes with a checked-out repo do
|
||||
/// not depend on a potentially stale git remote. Git remains a dev fallback.
|
||||
pub(super) async fn handle_update_check(&self) -> Result<serde_json::Value> {
|
||||
// Manifest override: when ARCHIPELAGO_UPDATE_URL is explicitly set,
|
||||
// the operator wants OTA via manifest — typically a dev box where
|
||||
// ~/archy/.git exists but isn't the intended update surface.
|
||||
// Without this short-circuit the dev box always advertises "Pull
|
||||
// & Rebuild" and can never exercise the manifest OTA path.
|
||||
let manifest_override = std::env::var("ARCHIPELAGO_UPDATE_URL").is_ok();
|
||||
|
||||
let repo_dir = std::path::PathBuf::from(
|
||||
std::env::var("HOME").unwrap_or_else(|_| "/home/archipelago".to_string()),
|
||||
)
|
||||
.join("archy");
|
||||
if !manifest_override && repo_dir.join(".git").exists() {
|
||||
if let Ok(git_status) = self.git_check_update(&repo_dir).await {
|
||||
return Ok(git_status);
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to manifest-based check
|
||||
let state = update::check_for_updates(&self.config.data_dir).await?;
|
||||
|
||||
let update_info = state.available_update.as_ref().map(|u| {
|
||||
@@ -35,10 +18,30 @@ impl RpcHandler {
|
||||
})
|
||||
});
|
||||
|
||||
if update_info.is_some() {
|
||||
return Ok(serde_json::json!({
|
||||
"current_version": state.current_version,
|
||||
"last_check": state.last_check,
|
||||
"update_available": true,
|
||||
"update": update_info,
|
||||
"manifest_mirror": state.manifest_mirror,
|
||||
}));
|
||||
}
|
||||
|
||||
let repo_dir = std::path::PathBuf::from(
|
||||
std::env::var("HOME").unwrap_or_else(|_| "/home/archipelago".to_string()),
|
||||
)
|
||||
.join("archy");
|
||||
if std::env::var("ARCHIPELAGO_GIT_UPDATES").is_ok() && repo_dir.join(".git").exists() {
|
||||
if let Ok(git_status) = self.git_check_update(&repo_dir).await {
|
||||
return Ok(git_status);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"current_version": state.current_version,
|
||||
"last_check": state.last_check,
|
||||
"update_available": update_info.is_some(),
|
||||
"update_available": false,
|
||||
"update": update_info,
|
||||
"manifest_mirror": state.manifest_mirror,
|
||||
}))
|
||||
@@ -162,10 +165,8 @@ impl RpcHandler {
|
||||
// progress bar after navigation instead of showing the fake
|
||||
// creep again. An RPC poll every ~1s during download drives a
|
||||
// real progress indicator that survives route changes.
|
||||
let downloaded = update::DOWNLOAD_BYTES
|
||||
.load(std::sync::atomic::Ordering::Relaxed);
|
||||
let total = update::DOWNLOAD_TOTAL
|
||||
.load(std::sync::atomic::Ordering::Relaxed);
|
||||
let downloaded = update::DOWNLOAD_BYTES.load(std::sync::atomic::Ordering::Relaxed);
|
||||
let total = update::DOWNLOAD_TOTAL.load(std::sync::atomic::Ordering::Relaxed);
|
||||
let active = total > 0 && downloaded < total;
|
||||
let completed = total > 0 && downloaded >= total;
|
||||
|
||||
@@ -175,8 +176,7 @@ impl RpcHandler {
|
||||
// read timeout). The UI uses this to surface a Cancel button
|
||||
// with explanatory copy.
|
||||
let stalled = if active {
|
||||
let last_at = update::DOWNLOAD_PROGRESS_AT
|
||||
.load(std::sync::atomic::Ordering::Relaxed);
|
||||
let last_at = update::DOWNLOAD_PROGRESS_AT.load(std::sync::atomic::Ordering::Relaxed);
|
||||
if last_at > 0 {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
|
||||
@@ -185,12 +185,32 @@ impl AuthManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback: user.json
|
||||
Ok(self
|
||||
.get_user()
|
||||
.await?
|
||||
.map(|u| u.onboarding_complete)
|
||||
.unwrap_or(false))
|
||||
// Fallback: user.json. A node that has a password set AND
|
||||
// setup_complete=true has been through onboarding by
|
||||
// definition — you can't reach the password-set step any
|
||||
// other way. The separate `onboarding_complete` flag can drift
|
||||
// out of sync (e.g. the completion RPC never reached disk, or
|
||||
// the node was seeded from a backup pre-dating the flag), so
|
||||
// auto-heal by inferring from setup_complete + password_hash.
|
||||
// Without this, a fully-onboarded node whose `onboarding_complete`
|
||||
// is stuck false will force its user back through the intro
|
||||
// wizard on every cleared browser cache.
|
||||
if let Some(u) = self.get_user().await? {
|
||||
if u.onboarding_complete {
|
||||
return Ok(true);
|
||||
}
|
||||
if u.setup_complete && !u.password_hash.is_empty() {
|
||||
// Persist the healed state so subsequent calls skip this
|
||||
// inference. Ignore write errors — returning true is
|
||||
// still correct even if we can't persist.
|
||||
let healed = OnboardingState { complete: true };
|
||||
if let Ok(json) = serde_json::to_string_pretty(&healed) {
|
||||
let _ = fs::write(&onboarding_file, json).await;
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// Check if 2FA is enabled for the user.
|
||||
@@ -361,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);
|
||||
|
||||
@@ -18,12 +18,12 @@ use base64::Engine;
|
||||
/// Convert a byte to an HSL triple biased toward readable foregrounds on
|
||||
/// dark backgrounds (saturation 60–85%, lightness 52–70%).
|
||||
fn hue_color(seed: u8) -> String {
|
||||
let hue = (seed as u16) * 360 / 256;
|
||||
let hue = (seed as u32) * 360 / 256;
|
||||
format!("hsl({}, 72%, 60%)", hue)
|
||||
}
|
||||
|
||||
fn accent_color(seed: u8) -> String {
|
||||
let hue = (seed as u16) * 360 / 256;
|
||||
let hue = (seed as u32) * 360 / 256;
|
||||
format!("hsl({}, 80%, 68%)", hue)
|
||||
}
|
||||
|
||||
@@ -37,7 +37,10 @@ fn encode_svg(svg: &str) -> String {
|
||||
/// avatar rather than an error.
|
||||
fn seed_bytes(pubkey_hex: &str) -> [u8; 8] {
|
||||
let mut out = [0u8; 8];
|
||||
let clean: String = pubkey_hex.chars().filter(|c| c.is_ascii_hexdigit()).collect();
|
||||
let clean: String = pubkey_hex
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_hexdigit())
|
||||
.collect();
|
||||
for (i, byte) in out.iter_mut().enumerate() {
|
||||
let lo = i * 2;
|
||||
if clean.len() >= lo + 2 {
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
//! Cached Bitcoin node status for browser UIs.
|
||||
//!
|
||||
//! The bitcoin-ui should not poll Bitcoin RPC directly for display state.
|
||||
//! During container restarts, reindexing, and IBD, direct browser RPC polling
|
||||
//! turns short RPC gaps into visible UI failures. This module owns the RPC
|
||||
//! polling loop, caches the last successful snapshot, and serves stale-but-known
|
||||
//! state while the node is reconnecting.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Serialize;
|
||||
use std::sync::OnceLock;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
const CACHE_REFRESH_SECS: u64 = 5;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct BitcoinNodeStatus {
|
||||
pub ok: bool,
|
||||
pub stale: bool,
|
||||
pub updated_at_ms: u64,
|
||||
pub error: Option<String>,
|
||||
pub blockchain_info: Option<serde_json::Value>,
|
||||
pub network_info: Option<serde_json::Value>,
|
||||
pub index_info: Option<serde_json::Value>,
|
||||
pub zmq_notifications: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl Default for BitcoinNodeStatus {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
ok: false,
|
||||
stale: false,
|
||||
updated_at_ms: 0,
|
||||
error: Some("Connecting to Bitcoin node...".to_string()),
|
||||
blockchain_info: None,
|
||||
network_info: None,
|
||||
index_info: None,
|
||||
zmq_notifications: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static STATUS_CACHE: OnceLock<RwLock<BitcoinNodeStatus>> = OnceLock::new();
|
||||
|
||||
fn cache() -> &'static RwLock<BitcoinNodeStatus> {
|
||||
STATUS_CACHE.get_or_init(|| RwLock::new(BitcoinNodeStatus::default()))
|
||||
}
|
||||
|
||||
fn now_ms() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as u64
|
||||
}
|
||||
|
||||
fn transient_error(err_msg: &str) -> bool {
|
||||
let lower = err_msg.to_lowercase();
|
||||
lower.contains("connect")
|
||||
|| lower.contains("reset")
|
||||
|| lower.contains("refused")
|
||||
|| lower.contains("timed out")
|
||||
|| lower.contains("timeout")
|
||||
|| lower.contains("broken pipe")
|
||||
|| lower.contains("eof")
|
||||
|| lower.contains("500 internal server error")
|
||||
}
|
||||
|
||||
pub fn spawn_status_cache() {
|
||||
tokio::spawn(async {
|
||||
loop {
|
||||
let fresh = fetch_bitcoin_status().await;
|
||||
let mut cached = cache().write().await;
|
||||
match fresh {
|
||||
Ok(mut status) => {
|
||||
status.ok = true;
|
||||
status.stale = false;
|
||||
status.error = None;
|
||||
*cached = status;
|
||||
}
|
||||
Err(e) => {
|
||||
let err_msg = e.to_string();
|
||||
if transient_error(&err_msg) {
|
||||
debug!("Bitcoin status: transient RPC failure: {}", err_msg);
|
||||
} else {
|
||||
warn!("Bitcoin status: RPC failure: {}", err_msg);
|
||||
}
|
||||
|
||||
if cached.blockchain_info.is_some() {
|
||||
cached.ok = false;
|
||||
cached.stale = true;
|
||||
cached.error = Some(format!(
|
||||
"Bitcoin node is reconnecting; showing last known state: {}",
|
||||
err_msg
|
||||
));
|
||||
} else {
|
||||
*cached = BitcoinNodeStatus {
|
||||
ok: false,
|
||||
stale: false,
|
||||
updated_at_ms: now_ms(),
|
||||
error: Some(format!("Connecting to Bitcoin node: {}", err_msg)),
|
||||
..BitcoinNodeStatus::default()
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(cached);
|
||||
tokio::time::sleep(Duration::from_secs(CACHE_REFRESH_SECS)).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub async fn get_bitcoin_status() -> BitcoinNodeStatus {
|
||||
cache().read().await.clone()
|
||||
}
|
||||
|
||||
async fn fetch_bitcoin_status() -> Result<BitcoinNodeStatus> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(8))
|
||||
.build()
|
||||
.context("build Bitcoin status HTTP client")?;
|
||||
|
||||
let blockchain_info = bitcoin_rpc_call(&client, "getblockchaininfo", serde_json::json!([]))
|
||||
.await
|
||||
.context("getblockchaininfo")?;
|
||||
let network_info = bitcoin_rpc_call(&client, "getnetworkinfo", serde_json::json!([]))
|
||||
.await
|
||||
.context("getnetworkinfo")
|
||||
.ok();
|
||||
let index_info = bitcoin_rpc_call(&client, "getindexinfo", serde_json::json!([]))
|
||||
.await
|
||||
.context("getindexinfo")
|
||||
.ok();
|
||||
let zmq_notifications = bitcoin_rpc_call(&client, "getzmqnotifications", serde_json::json!([]))
|
||||
.await
|
||||
.context("getzmqnotifications")
|
||||
.ok();
|
||||
|
||||
Ok(BitcoinNodeStatus {
|
||||
ok: true,
|
||||
stale: false,
|
||||
updated_at_ms: now_ms(),
|
||||
error: None,
|
||||
blockchain_info: Some(blockchain_info),
|
||||
network_info,
|
||||
index_info,
|
||||
zmq_notifications,
|
||||
})
|
||||
}
|
||||
|
||||
async fn bitcoin_rpc_call(
|
||||
client: &reqwest::Client,
|
||||
method: &str,
|
||||
params: serde_json::Value,
|
||||
) -> Result<serde_json::Value> {
|
||||
let (rpc_user, rpc_pass) = crate::bitcoin_rpc::bitcoin_rpc_credentials().await;
|
||||
let body = serde_json::json!({
|
||||
"jsonrpc": "1.0",
|
||||
"id": "bitcoin-status",
|
||||
"method": method,
|
||||
"params": params,
|
||||
});
|
||||
|
||||
let resp = client
|
||||
.post(crate::constants::BITCOIN_RPC_URL)
|
||||
.basic_auth(rpc_user, Some(rpc_pass))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.context("Bitcoin RPC request failed")?;
|
||||
|
||||
let status = resp.status();
|
||||
let json: serde_json::Value = resp.json().await.context("decode Bitcoin RPC JSON")?;
|
||||
if !status.is_success() {
|
||||
anyhow::bail!("Bitcoin RPC returned {}: {}", status, json);
|
||||
}
|
||||
if let Some(error) = json.get("error").filter(|e| !e.is_null()) {
|
||||
anyhow::bail!("Bitcoin RPC {} error: {}", method, error);
|
||||
}
|
||||
json.get("result")
|
||||
.cloned()
|
||||
.context("missing Bitcoin RPC result")
|
||||
}
|
||||
@@ -0,0 +1,618 @@
|
||||
//! Bootstrap host-side artifacts on every archipelago startup.
|
||||
//!
|
||||
//! The update pipeline swaps the archipelago binary but does not touch
|
||||
//! scripts, systemd units, or nginx configuration — those are installed
|
||||
//! once by the ISO builder. Without this module, changes to
|
||||
//! `container-doctor.sh`, the doctor service/timer, or the nginx config
|
||||
//! never reach boxes installed before the change.
|
||||
//!
|
||||
//! Two things are synced on startup:
|
||||
//! 1. Doctor artifacts (container-doctor.sh + service + timer).
|
||||
//! 2. Missing nginx backend proxy blocks required for frontend fetches to
|
||||
//! reach the backend instead of the SPA fallback.
|
||||
//!
|
||||
//! Idempotent: no-ops on boxes that are already in sync. All work is
|
||||
//! best-effort — failures are logged but never abort the backend.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::fs;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::update::host_sudo;
|
||||
|
||||
const DOCTOR_SH: &str = include_str!("../../../scripts/container-doctor.sh");
|
||||
const DOCTOR_SERVICE: &str =
|
||||
include_str!("../../../image-recipe/configs/archipelago-doctor.service");
|
||||
const DOCTOR_TIMER: &str = include_str!("../../../image-recipe/configs/archipelago-doctor.timer");
|
||||
|
||||
const DOCTOR_SH_PATH: &str = "/home/archipelago/archy/scripts/container-doctor.sh";
|
||||
const DOCTOR_SERVICE_PATH: &str = "/etc/systemd/system/archipelago-doctor.service";
|
||||
const DOCTOR_TIMER_PATH: &str = "/etc/systemd/system/archipelago-doctor.timer";
|
||||
|
||||
const NGINX_CONF_PATH: &str = "/etc/nginx/sites-available/archipelago";
|
||||
const NGINX_ENABLED_CONF_PATH: &str = "/etc/nginx/sites-enabled/archipelago";
|
||||
const RUNTIME_ASSETS_DIR: &str = "/opt/archipelago/web-ui/archipelago-runtime";
|
||||
|
||||
/// Inserted into every server block of the nginx config that lacks the
|
||||
/// `/api/app-catalog` proxy. Kept in sync with the canonical block in
|
||||
/// image-recipe/configs/nginx-archipelago.conf.
|
||||
const NGINX_APP_CATALOG_BLOCK: &str = "\n # App Store catalog proxy — backend fetches from configured registries\n # so the browser doesn't hit CORS/CSP. Without this block nginx falls\n # through to the SPA index.html and the frontend gets HTML back instead\n # of JSON.\n location /api/app-catalog {\n proxy_pass http://127.0.0.1:5678;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header Cookie $http_cookie;\n proxy_connect_timeout 15s;\n proxy_read_timeout 30s;\n proxy_send_timeout 15s;\n error_page 502 503 = @backend_unavailable;\n error_page 504 = @backend_timeout;\n }\n\n";
|
||||
|
||||
const NGINX_BITCOIN_STATUS_BLOCK: &str = "\n location /bitcoin-status {\n proxy_pass http://127.0.0.1:5678/bitcoin-status;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_connect_timeout 10s;\n proxy_read_timeout 10s;\n proxy_send_timeout 5s;\n error_page 502 503 = @backend_unavailable;\n error_page 504 = @backend_timeout;\n }\n";
|
||||
|
||||
/// Entry point called from main startup. Never returns an error to the caller —
|
||||
/// failing to bootstrap host artifacts must not prevent the backend from serving.
|
||||
pub async fn ensure_doctor_installed() {
|
||||
match run_service_override_repair().await {
|
||||
Ok(true) => info!("Removed stale Archipelago dev-mode service override"),
|
||||
Ok(false) => debug!("No stale Archipelago dev-mode service override found"),
|
||||
Err(e) => warn!("Service override repair failed (non-fatal): {:#}", e),
|
||||
}
|
||||
match run_runtime_assets().await {
|
||||
Ok(changed) if changed => info!("Runtime assets synchronized from OTA payload"),
|
||||
Ok(_) => debug!("No OTA runtime payload to synchronize"),
|
||||
Err(e) => warn!("Runtime asset bootstrap failed (non-fatal): {:#}", e),
|
||||
}
|
||||
match run().await {
|
||||
Ok(changed) if changed => info!("Doctor artifacts synchronized with binary"),
|
||||
Ok(_) => debug!("Doctor artifacts already in sync"),
|
||||
Err(e) => warn!("Doctor bootstrap failed (non-fatal): {:#}", e),
|
||||
}
|
||||
match run_nginx().await {
|
||||
Ok(true) => info!("Patched nginx config to proxy missing backend endpoints"),
|
||||
Ok(false) => debug!("Nginx backend endpoint proxy blocks already present"),
|
||||
Err(e) => warn!("Nginx bootstrap failed (non-fatal): {:#}", e),
|
||||
}
|
||||
match run_bitcoin_rpc_repair().await {
|
||||
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),
|
||||
}
|
||||
match tighten_secrets_dir().await {
|
||||
Ok(n) if n > 0 => info!(tightened = n, "Tightened mode on secret files"),
|
||||
Ok(_) => debug!("Secrets directory already at expected mode"),
|
||||
Err(e) => warn!("Secrets dir tightening failed (non-fatal): {:#}", e),
|
||||
}
|
||||
// Podman probing MUST be the last bootstrap stage. We used to delete
|
||||
// transient runroot state here when `podman info` failed, but live nodes
|
||||
// can still have rootlessport/conmon processes holding that state. Removing
|
||||
// it automatically makes failures worse: containers lose `.containerenv`,
|
||||
// ports stay bound, and later starts fail. Report the fault instead; repair
|
||||
// must be deliberate/operator-driven.
|
||||
match heal_podman_state().await {
|
||||
Ok(PodmanHealOutcome::Healthy) => debug!("podman runtime state healthy"),
|
||||
Ok(PodmanHealOutcome::Unhealthy) => warn!(
|
||||
"podman runtime state is unhealthy at startup — skipping automatic runroot cleanup"
|
||||
),
|
||||
Err(e) => warn!(
|
||||
"podman self-heal failed (non-fatal, will retry next boot): {:#}",
|
||||
e
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum PodmanHealOutcome {
|
||||
Healthy,
|
||||
Unhealthy,
|
||||
}
|
||||
|
||||
async fn heal_podman_state() -> Result<PodmanHealOutcome> {
|
||||
if probe_podman_ok().await {
|
||||
return Ok(PodmanHealOutcome::Healthy);
|
||||
}
|
||||
Ok(PodmanHealOutcome::Unhealthy)
|
||||
}
|
||||
|
||||
/// True iff `podman info` returns 0 within 5s. Any timeout, spawn
|
||||
/// failure, or non-zero exit reads as "wedged" and triggers cleanup.
|
||||
async fn probe_podman_ok() -> bool {
|
||||
use std::time::Duration;
|
||||
let probe = tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
tokio::process::Command::new("podman")
|
||||
.arg("info")
|
||||
.arg("--format=json")
|
||||
.output(),
|
||||
)
|
||||
.await;
|
||||
match probe {
|
||||
Ok(Ok(out)) => out.status.success(),
|
||||
Ok(Err(_)) | Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Make sure /var/lib/archipelago/secrets/ stays 0700 owned by archipelago,
|
||||
/// and every file inside is 0600. The parent dir mode is the load-bearing
|
||||
/// boundary against host-side reads from other UIDs (rootless container
|
||||
/// escapes get mapped to UID >= 100000 and can't traverse a 0700/uid=1000
|
||||
/// directory). The per-file 0600 sweep is defense-in-depth in case some
|
||||
/// installer wrote a 0644 file.
|
||||
async fn tighten_secrets_dir() -> Result<u32> {
|
||||
let dir = Path::new("/var/lib/archipelago/secrets");
|
||||
if !dir.exists() {
|
||||
return Ok(0);
|
||||
}
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))
|
||||
.await
|
||||
.with_context(|| format!("chmod 0700 {}", dir.display()))?;
|
||||
|
||||
let mut entries = fs::read_dir(dir)
|
||||
.await
|
||||
.with_context(|| format!("read_dir {}", dir.display()))?;
|
||||
let mut tightened = 0u32;
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let path = entry.path();
|
||||
let meta = match entry.metadata().await {
|
||||
Ok(m) => m,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if !meta.is_file() {
|
||||
continue;
|
||||
}
|
||||
if meta.permissions().mode() & 0o777 != 0o600 {
|
||||
fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
|
||||
.await
|
||||
.with_context(|| format!("chmod 0600 {}", path.display()))?;
|
||||
tightened += 1;
|
||||
}
|
||||
}
|
||||
Ok(tightened)
|
||||
}
|
||||
|
||||
async fn run_service_override_repair() -> Result<bool> {
|
||||
let override_path = Path::new("/etc/systemd/system/archipelago.service.d/override.conf");
|
||||
let Ok(content) = fs::read_to_string(override_path).await else {
|
||||
return Ok(false);
|
||||
};
|
||||
if !content.contains("ARCHIPELAGO_DEV_MODE=true") {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let only_dev_mode_override = content
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty() && !line.starts_with('#'))
|
||||
.all(|line| line == "[Service]" || line == "Environment=ARCHIPELAGO_DEV_MODE=true");
|
||||
if !only_dev_mode_override {
|
||||
warn!(
|
||||
path = %override_path.display(),
|
||||
"Archipelago service override contains ARCHIPELAGO_DEV_MODE=true plus other settings; leaving it untouched"
|
||||
);
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let path_s = override_path.to_string_lossy().to_string();
|
||||
let status = host_sudo(&["rm", "-f", &path_s])
|
||||
.await
|
||||
.with_context(|| format!("remove {}", override_path.display()))?;
|
||||
if !status.success() {
|
||||
anyhow::bail!("remove {} exited with {}", override_path.display(), status);
|
||||
}
|
||||
let _ = host_sudo(&["systemctl", "daemon-reload"]).await;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn run_runtime_assets() -> Result<bool> {
|
||||
// The v1.7.50 OTA bridge puts scripts/apps/docker assets inside the
|
||||
// frontend tarball because older binaries only know how to apply the
|
||||
// backend binary and frontend archive. Once the new backend starts, it
|
||||
// promotes that payload into /opt so app installs use the matching specs.
|
||||
let runtime_dir = Path::new(RUNTIME_ASSETS_DIR);
|
||||
if !runtime_dir.exists() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let mut changed = false;
|
||||
for (relative, dest) in [
|
||||
("apps", "/opt/archipelago/apps"),
|
||||
("scripts", "/opt/archipelago/scripts"),
|
||||
("docker", "/opt/archipelago/docker"),
|
||||
] {
|
||||
let src = runtime_dir.join(relative);
|
||||
if src.exists() {
|
||||
replace_dir_from_runtime(&src, dest).await?;
|
||||
if relative == "scripts" {
|
||||
let _ = host_sudo(&[
|
||||
"find", dest, "-type", "f", "-name", "*.sh", "-exec", "chmod", "755", "{}", "+",
|
||||
])
|
||||
.await;
|
||||
let image_versions = format!("{}/image-versions.sh", dest);
|
||||
if Path::new(&image_versions).exists() {
|
||||
let _ =
|
||||
host_sudo(&["cp", &image_versions, "/opt/archipelago/image-versions.sh"])
|
||||
.await;
|
||||
}
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
let configs = runtime_dir.join("image-recipe/configs");
|
||||
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() {
|
||||
let src_s = src.to_string_lossy().to_string();
|
||||
let dest = format!("/etc/systemd/system/{}", unit);
|
||||
let status = host_sudo(&["install", "-m", "644", &src_s, &dest])
|
||||
.await
|
||||
.with_context(|| format!("install {}", unit))?;
|
||||
if !status.success() {
|
||||
anyhow::bail!("install {} exited with {}", unit, status);
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if changed {
|
||||
let _ = host_sudo(&["systemctl", "daemon-reload"]).await;
|
||||
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)
|
||||
}
|
||||
|
||||
async fn replace_dir_from_runtime(src: &Path, dest: &str) -> Result<()> {
|
||||
let tmp = format!("{}.new.{}", dest, chrono::Utc::now().timestamp_millis());
|
||||
let src_dot = path_dot(src);
|
||||
let mkdir = host_sudo(&["mkdir", "-p", &tmp])
|
||||
.await
|
||||
.with_context(|| format!("mkdir {}", tmp))?;
|
||||
if !mkdir.success() {
|
||||
anyhow::bail!("mkdir {} exited with {}", tmp, mkdir);
|
||||
}
|
||||
let copy = host_sudo(&["cp", "-a", &src_dot, &tmp])
|
||||
.await
|
||||
.with_context(|| format!("copy runtime {} -> {}", src.display(), tmp))?;
|
||||
if !copy.success() {
|
||||
let _ = host_sudo(&["rm", "-rf", &tmp]).await;
|
||||
anyhow::bail!("copy runtime {} exited with {}", src.display(), copy);
|
||||
}
|
||||
let _ = host_sudo(&["mkdir", "-p", dest]).await;
|
||||
let cleanup = host_sudo(&[
|
||||
"find",
|
||||
dest,
|
||||
"-mindepth",
|
||||
"1",
|
||||
"-maxdepth",
|
||||
"1",
|
||||
"-exec",
|
||||
"rm",
|
||||
"-rf",
|
||||
"{}",
|
||||
"+",
|
||||
])
|
||||
.await
|
||||
.with_context(|| format!("clean {}", dest))?;
|
||||
if !cleanup.success() {
|
||||
let _ = host_sudo(&["rm", "-rf", &tmp]).await;
|
||||
anyhow::bail!("clean {} exited with {}", dest, cleanup);
|
||||
}
|
||||
let tmp_dot = format!("{}/.", tmp);
|
||||
let promote = host_sudo(&["cp", "-a", &tmp_dot, dest])
|
||||
.await
|
||||
.with_context(|| format!("promote {} -> {}", tmp, dest))?;
|
||||
let _ = host_sudo(&["rm", "-rf", &tmp]).await;
|
||||
if !promote.success() {
|
||||
anyhow::bail!("promote {} exited with {}", dest, promote);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn path_dot(path: &Path) -> String {
|
||||
let mut p = PathBuf::from(path);
|
||||
p.push(".");
|
||||
p.to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
async fn run_bitcoin_rpc_repair() -> Result<bool> {
|
||||
// Older installs can have a container-owned bitcoin.conf with only rpcauth
|
||||
// and printtoconsole. Repair it at startup so OTA fixes existing nodes
|
||||
// without a manual uninstall/reinstall. Bind/port stay in the container
|
||||
// command line to avoid duplicate RPC endpoint definitions.
|
||||
let script = r#"
|
||||
set -eu
|
||||
conf=/var/lib/archipelago/bitcoin/bitcoin.conf
|
||||
[ -f "$conf" ] || exit 0
|
||||
changed=0
|
||||
ensure_line() {
|
||||
line="$1"
|
||||
key="${line%%=*}"
|
||||
if ! grep -q "^${key}=" "$conf"; then
|
||||
printf '%s\n' "$line" >> "$conf"
|
||||
changed=1
|
||||
fi
|
||||
}
|
||||
ensure_line server=1
|
||||
ensure_line rpcallowip=0.0.0.0/0
|
||||
ensure_line listen=1
|
||||
[ "$changed" -eq 0 ] && exit 0
|
||||
exit 2
|
||||
"#;
|
||||
let status = host_sudo(&["sh", "-lc", script])
|
||||
.await
|
||||
.context("repair bitcoin.conf RPC bind settings")?;
|
||||
match status.code() {
|
||||
Some(0) => Ok(false),
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run() -> Result<bool> {
|
||||
// Dev-box guard: on contributors' laptops `/home/archipelago/archy` is
|
||||
// typically a symlink into the git checkout, and writing through it
|
||||
// would clobber the working tree with whatever the binary happens to
|
||||
// have been compiled from. Production ISO installs materialize a real
|
||||
// directory.
|
||||
let home_archy = Path::new("/home/archipelago/archy");
|
||||
if fs::symlink_metadata(home_archy)
|
||||
.await
|
||||
.map(|m| m.file_type().is_symlink())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
debug!("/home/archipelago/archy is a symlink — skipping doctor bootstrap (dev box)");
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// Skip entirely on machines without the canonical scripts directory —
|
||||
// writing orphan files there just causes confusion.
|
||||
let scripts_dir = Path::new(DOCTOR_SH_PATH)
|
||||
.parent()
|
||||
.context("doctor script path has no parent")?;
|
||||
if !scripts_dir.exists() {
|
||||
debug!(
|
||||
"Scripts dir {} missing — skipping doctor bootstrap",
|
||||
scripts_dir.display()
|
||||
);
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let mut changed = false;
|
||||
|
||||
// 1. Script — lives in archipelago's home dir, user-writable.
|
||||
if needs_write(DOCTOR_SH_PATH, DOCTOR_SH).await {
|
||||
fs::write(DOCTOR_SH_PATH, DOCTOR_SH)
|
||||
.await
|
||||
.with_context(|| format!("write {}", DOCTOR_SH_PATH))?;
|
||||
let _ = tokio::process::Command::new("chmod")
|
||||
.args(["+x", DOCTOR_SH_PATH])
|
||||
.status()
|
||||
.await;
|
||||
info!("Updated {}", DOCTOR_SH_PATH);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
// 2. Systemd unit files — /etc is restricted; route through host_sudo.
|
||||
let service_changed = write_root_if_needed(DOCTOR_SERVICE_PATH, DOCTOR_SERVICE).await?;
|
||||
let timer_changed = write_root_if_needed(DOCTOR_TIMER_PATH, DOCTOR_TIMER).await?;
|
||||
changed = changed || service_changed || timer_changed;
|
||||
|
||||
// 3. Reload if units changed. Do not enable/start the timer here: lifecycle
|
||||
// qualification and explicit app operations need deterministic Podman
|
||||
// ownership, and the doctor can race those flows. Operators can enable it
|
||||
// separately when they want periodic host repair.
|
||||
if service_changed || timer_changed {
|
||||
if let Err(e) = host_sudo(&["systemctl", "daemon-reload"]).await {
|
||||
warn!("daemon-reload failed: {:#}", e);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
async fn needs_write(path: &str, expected: &str) -> bool {
|
||||
match fs::read_to_string(path).await {
|
||||
Ok(current) => current != expected,
|
||||
Err(_) => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Write content to a root-owned path via `sudo mv` of a user-owned tmp file.
|
||||
/// Returns true if a write happened.
|
||||
async fn write_root_if_needed(path: &str, content: &str) -> Result<bool> {
|
||||
if !needs_write(path, content).await {
|
||||
return Ok(false);
|
||||
}
|
||||
let tmp = format!(
|
||||
"/tmp/archipelago-bootstrap-{}-{}.tmp",
|
||||
std::process::id(),
|
||||
Path::new(path)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("unit")
|
||||
);
|
||||
fs::write(&tmp, content)
|
||||
.await
|
||||
.with_context(|| format!("write tmp {}", tmp))?;
|
||||
let status = host_sudo(&["mv", &tmp, path])
|
||||
.await
|
||||
.with_context(|| format!("sudo mv {} -> {}", tmp, path))?;
|
||||
if !status.success() {
|
||||
let _ = fs::remove_file(&tmp).await;
|
||||
anyhow::bail!("sudo mv to {} exited with {}", path, status);
|
||||
}
|
||||
info!("Updated {}", path);
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Patch the nginx site config to add missing backend proxy blocks. Older ISO
|
||||
/// configs shipped individual per-endpoint `location` blocks, so missing
|
||||
/// endpoints silently fell through to the SPA `index.html` and the frontend
|
||||
/// got HTML instead of JSON.
|
||||
///
|
||||
/// Validates via `nginx -t` before reloading. On failure the patch is
|
||||
/// rolled back from a backup written just before the write.
|
||||
async fn run_nginx() -> Result<bool> {
|
||||
// Skip on dev symlinks — we don't want to touch `/etc/nginx` on laptops.
|
||||
let home_archy = Path::new("/home/archipelago/archy");
|
||||
if fs::symlink_metadata(home_archy)
|
||||
.await
|
||||
.map(|m| m.file_type().is_symlink())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let mut changed = false;
|
||||
let mut patched_paths = Vec::<PathBuf>::new();
|
||||
for path in [NGINX_CONF_PATH, NGINX_ENABLED_CONF_PATH] {
|
||||
let candidate = Path::new(path);
|
||||
if !candidate.exists() {
|
||||
debug!("{} missing — skipping nginx bootstrap", path);
|
||||
continue;
|
||||
}
|
||||
let canonical = fs::canonicalize(candidate)
|
||||
.await
|
||||
.unwrap_or_else(|_| candidate.to_path_buf());
|
||||
if patched_paths.iter().any(|p| p == &canonical) {
|
||||
continue;
|
||||
}
|
||||
patched_paths.push(canonical);
|
||||
changed |= patch_nginx_conf(path).await?;
|
||||
}
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
async fn patch_nginx_conf(path: &str) -> Result<bool> {
|
||||
let content = fs::read_to_string(path)
|
||||
.await
|
||||
.with_context(|| format!("read {}", path))?;
|
||||
let missing_app_catalog = !content.contains("location /api/app-catalog");
|
||||
let missing_bitcoin_status = !content.contains("location /bitcoin-status");
|
||||
if !missing_app_catalog && !missing_bitcoin_status {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let mut patched = content.clone();
|
||||
|
||||
if missing_bitcoin_status {
|
||||
let anchor = " location /electrs-status {";
|
||||
if !patched.contains(anchor) {
|
||||
warn!("nginx conf missing electrs-status anchor — skipping /bitcoin-status patch");
|
||||
} else {
|
||||
let replacement = format!("{}{}", NGINX_BITCOIN_STATUS_BLOCK, anchor);
|
||||
patched = patched.replace(anchor, &replacement);
|
||||
}
|
||||
}
|
||||
|
||||
if missing_app_catalog {
|
||||
// The DWN comment sits at the same indent right after the `/api/blob`
|
||||
// block in both server blocks — a stable anchor that existed on every
|
||||
// ISO shipped to date. If it's absent (config got heavily customized),
|
||||
// skip rather than guess where to splice.
|
||||
let anchor = " # DWN endpoints — peer access over Tor (no auth)";
|
||||
if !patched.contains(anchor) {
|
||||
warn!("nginx conf missing DWN anchor — skipping /api/app-catalog patch");
|
||||
} else {
|
||||
let replacement = format!("{}{}", NGINX_APP_CATALOG_BLOCK, anchor);
|
||||
patched = patched.replace(anchor, &replacement);
|
||||
}
|
||||
}
|
||||
|
||||
if patched == content {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// Write patched config via a user-owned tmp + sudo mv, after stashing
|
||||
// a backup outside nginx include dirs so validation cannot load it too.
|
||||
let pid = std::process::id();
|
||||
let tmp = format!("/tmp/archipelago-nginx-{}.conf", pid);
|
||||
fs::write(&tmp, &patched)
|
||||
.await
|
||||
.with_context(|| format!("write {}", tmp))?;
|
||||
|
||||
let backup = format!(
|
||||
"/tmp/archipelago-nginx-backup-{}-{}.conf",
|
||||
pid,
|
||||
patched.len()
|
||||
);
|
||||
if let Err(e) = host_sudo(&["cp", path, &backup]).await {
|
||||
let _ = fs::remove_file(&tmp).await;
|
||||
return Err(e.context("backup nginx conf"));
|
||||
}
|
||||
|
||||
let mv = host_sudo(&["mv", &tmp, path]).await;
|
||||
match mv {
|
||||
Ok(s) if s.success() => {}
|
||||
Ok(s) => {
|
||||
let _ = fs::remove_file(&tmp).await;
|
||||
anyhow::bail!("sudo mv nginx conf to {} exited with {}", path, s);
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = fs::remove_file(&tmp).await;
|
||||
return Err(e.context("mv tmp -> nginx conf"));
|
||||
}
|
||||
}
|
||||
|
||||
// Validate.
|
||||
let test = host_sudo(&["nginx", "-t"]).await;
|
||||
let valid = matches!(&test, Ok(s) if s.success());
|
||||
if !valid {
|
||||
warn!("nginx -t failed after patch — reverting");
|
||||
let _ = host_sudo(&["mv", &backup, path]).await;
|
||||
if let Err(e) = test {
|
||||
return Err(e.context("nginx -t"));
|
||||
}
|
||||
anyhow::bail!("nginx config invalid after patch — reverted");
|
||||
}
|
||||
|
||||
// Reload nginx so the new block takes effect immediately. Reload (not
|
||||
// restart) keeps in-flight connections alive.
|
||||
if let Err(e) = host_sudo(&["systemctl", "reload", "nginx"]).await {
|
||||
warn!("nginx reload failed (non-fatal): {:#}", e);
|
||||
}
|
||||
let _ = host_sudo(&["rm", "-f", &backup]).await;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn podman_heal_outcome_no_longer_has_cleanup_variant() {
|
||||
let outcome = PodmanHealOutcome::Unhealthy;
|
||||
assert_ne!(outcome, PodmanHealOutcome::Healthy);
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,14 @@ pub struct Config {
|
||||
/// Tor SOCKS5 proxy (e.g. 127.0.0.1:9050). When set, ALL Nostr traffic routes through Tor.
|
||||
#[serde(default)]
|
||||
pub nostr_tor_proxy: Option<String>,
|
||||
/// Phase 3.2 of v1.7.52: route orchestrator-managed backend installs
|
||||
/// through Quadlet (`.container` units in ~/.config/containers/systemd
|
||||
/// + systemctl --user start) instead of `podman create + start`. Default
|
||||
/// off so the legacy path stays the production path until the harness
|
||||
/// at tests/lifecycle/run-20x.sh has gone green against the new path
|
||||
/// on .228 + .198. See `project_v1_7_52_phase3_quadlet_design`.
|
||||
#[serde(default)]
|
||||
pub use_quadlet_backends: bool,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -132,9 +140,12 @@ impl Config {
|
||||
config.log_level = level;
|
||||
}
|
||||
|
||||
// Dev mode configuration
|
||||
if let Ok(dev_mode) = std::env::var("ARCHIPELAGO_DEV_MODE") {
|
||||
config.dev_mode = dev_mode.parse().unwrap_or(false);
|
||||
// Production binaries must not be switched into dev orchestration by
|
||||
// host environment. Several live nodes carried a stale systemd
|
||||
// ARCHIPELAGO_DEV_MODE override, which rewrote production volume
|
||||
// mounts into /tmp and prevented real installs from starting.
|
||||
if std::env::var("ARCHIPELAGO_DEV_MODE").is_ok() {
|
||||
tracing::warn!("Ignoring ARCHIPELAGO_DEV_MODE in production config");
|
||||
}
|
||||
|
||||
if let Ok(runtime) = std::env::var("ARCHIPELAGO_CONTAINER_RUNTIME") {
|
||||
@@ -171,6 +182,17 @@ impl Config {
|
||||
config.nostr_tor_proxy = if s.is_empty() { None } else { Some(s) };
|
||||
}
|
||||
|
||||
// Phase 3.2 of v1.7.52. Truthy values (1, true, yes, on — case-insensitive)
|
||||
// route backend installs through the Quadlet path without requiring a
|
||||
// config.json edit + archipelago.service restart (which would trigger
|
||||
// FM3 cgroup cascade until 3.5 ships). Anything else (or unset) leaves
|
||||
// the config.json value untouched.
|
||||
if let Ok(v) = std::env::var("ARCHIPELAGO_USE_QUADLET_BACKENDS") {
|
||||
if parse_truthy_env(&v) {
|
||||
config.use_quadlet_backends = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Host IP for container env vars (detect if not set)
|
||||
if let Ok(ip) = std::env::var("ARCHIPELAGO_HOST_IP") {
|
||||
config.host_ip = ip;
|
||||
@@ -218,10 +240,23 @@ impl Default for Config {
|
||||
"wss://relay.nostr.info".into(),
|
||||
],
|
||||
nostr_tor_proxy: Some("127.0.0.1:9050".into()),
|
||||
use_quadlet_backends: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Recognise the canonical "the user meant true" forms for boolean env
|
||||
/// vars: 1, true, yes, on (case-insensitive, surrounding whitespace
|
||||
/// trimmed). Anything else — including the typo'd "ture" or the empty
|
||||
/// string — counts as false. Centralised so future env flags stay
|
||||
/// consistent with each other.
|
||||
fn parse_truthy_env(raw: &str) -> bool {
|
||||
matches!(
|
||||
raw.trim().to_ascii_lowercase().as_str(),
|
||||
"1" | "true" | "yes" | "on"
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -425,4 +460,24 @@ mod tests {
|
||||
assert!(!config.nostr_relays.is_empty());
|
||||
assert!(config.nostr_relays.iter().all(|r| r.starts_with("wss://")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_truthy_env_recognises_canonical_forms() {
|
||||
for t in ["1", "true", "TRUE", "yes", "Yes", "on", "ON", " true "] {
|
||||
assert!(parse_truthy_env(t), "{t:?} should parse truthy");
|
||||
}
|
||||
for f in ["", "0", "false", "no", "off", "ture", "anything else", " "] {
|
||||
assert!(!parse_truthy_env(f), "{f:?} should NOT parse truthy");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_use_quadlet_backends_defaults_off() {
|
||||
// Phase 3.2 of v1.7.52 — the new path stays gated until the 20×
|
||||
// harness goes green on .228 and .198. Flipping this default
|
||||
// ahead of that would route every backend install through code
|
||||
// we haven't fleet-validated yet.
|
||||
let config = Config::default();
|
||||
assert!(!config.use_quadlet_backends);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
//! bitcoin-ui nginx.conf renderer.
|
||||
//!
|
||||
//! Step 7 of the rust-orchestrator migration. Replaces the old
|
||||
//! `sed -i __BITCOIN_RPC_AUTH__` approach from `first-boot-containers.sh`
|
||||
//! (which destructively overwrote its own template, broke on rotation,
|
||||
//! and had no story for dual Knots/Core UIs) with a binary-embedded
|
||||
//! template rendered at install/reconcile time and atomic-written to
|
||||
//! disk.
|
||||
//!
|
||||
//! The manifest bind-mounts the rendered file read-only into the
|
||||
//! container at `/etc/nginx/conf.d/default.conf`. On every reconcile
|
||||
//! pass we re-render and compare — if the rendered bytes would differ
|
||||
//! from what's on disk (password rotated, template changed via OTA),
|
||||
//! we rewrite atomically and the reconciler restarts the container.
|
||||
//!
|
||||
//! Source of truth:
|
||||
//! * RPC user: hardcoded `archipelago` (matches the image's `bitcoin.conf`).
|
||||
//! * RPC password: `/var/lib/archipelago/secrets/bitcoin-rpc-password`,
|
||||
//! plaintext, written by the seed-derived credential setup.
|
||||
//!
|
||||
//! Both Knots and Core back-ends expose RPC on 127.0.0.1:8332 with the
|
||||
//! same auth shape, so one template serves both.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use base64::Engine;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tokio::fs;
|
||||
|
||||
/// The nginx.conf template. Embedded at compile time so it can never
|
||||
/// drift from the code that renders it, and ships atomically with OTA.
|
||||
///
|
||||
/// `{{BITCOIN_RPC_AUTH}}` is the only placeholder — replaced with a
|
||||
/// `base64(user:password)` blob at render time.
|
||||
pub(crate) const TEMPLATE: &str = include_str!("bitcoin_ui_nginx.conf.template");
|
||||
|
||||
/// The single placeholder in `TEMPLATE`.
|
||||
const PLACEHOLDER: &str = "{{BITCOIN_RPC_AUTH}}";
|
||||
|
||||
/// Hardcoded RPC user. Matches the user written into `bitcoin.conf` by
|
||||
/// the bitcoin-core/bitcoin-knots bootstrap, and the legacy
|
||||
/// `BITCOIN_RPC_USER="archipelago"` from `first-boot-containers.sh`.
|
||||
const RPC_USER: &str = "archipelago";
|
||||
|
||||
/// Default path to the plaintext RPC password secret.
|
||||
///
|
||||
/// Written by the seed-derived credential flow; same file the bash
|
||||
/// scripts read today at `first-boot-containers.sh:277` and `:1225`.
|
||||
pub const DEFAULT_SECRET_PATH: &str = "/var/lib/archipelago/secrets/bitcoin-rpc-password";
|
||||
|
||||
/// Default output path for the rendered nginx.conf.
|
||||
///
|
||||
/// The manifest bind-mounts this file read-only into the bitcoin-ui
|
||||
/// container at `/etc/nginx/conf.d/default.conf`.
|
||||
pub const DEFAULT_RENDERED_PATH: &str = "/var/lib/archipelago/bitcoin-ui/nginx.conf";
|
||||
|
||||
/// Parameters for rendering. Injectable so tests can hit a tmpdir
|
||||
/// instead of `/var/lib/archipelago`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RenderPaths {
|
||||
/// Path to read the plaintext RPC password from.
|
||||
pub secret_path: PathBuf,
|
||||
/// Path to write the rendered nginx.conf to.
|
||||
pub rendered_path: PathBuf,
|
||||
}
|
||||
|
||||
impl Default for RenderPaths {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
secret_path: PathBuf::from(DEFAULT_SECRET_PATH),
|
||||
rendered_path: PathBuf::from(DEFAULT_RENDERED_PATH),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Outcome of a render pass. `Written` if the rendered bytes differed
|
||||
/// from the current on-disk contents and we rewrote; `Unchanged` if
|
||||
/// they matched and we left the file alone.
|
||||
///
|
||||
/// The caller (reconciler / install path) decides whether to restart
|
||||
/// the bitcoin-ui container based on this.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RenderOutcome {
|
||||
Written,
|
||||
Unchanged,
|
||||
}
|
||||
|
||||
/// Render the bitcoin-ui nginx.conf and atomic-write it to disk if it
|
||||
/// differs from what's already there.
|
||||
///
|
||||
/// Idempotent: safe to call on every reconcile pass. Does a byte
|
||||
/// comparison before writing so an unchanged password + template is a
|
||||
/// no-op (no inode churn, no container restart cascade).
|
||||
///
|
||||
/// Errors if the secret file is missing or empty. Upstream callers
|
||||
/// treat that as "bitcoin-ui isn't installable yet" rather than fatal
|
||||
/// — the RPC password comes into being during bitcoin-core's own
|
||||
/// bootstrap, which may not have happened yet on a fresh node.
|
||||
pub async fn render(paths: &RenderPaths) -> Result<RenderOutcome> {
|
||||
let password = read_password(&paths.secret_path).await?;
|
||||
let auth_b64 = encode_basic_auth(RPC_USER, &password);
|
||||
let rendered = TEMPLATE.replace(PLACEHOLDER, &auth_b64);
|
||||
|
||||
// Compare against existing. read-to-string fails on ENOENT (first
|
||||
// install) — treat as "different".
|
||||
let existing = fs::read_to_string(&paths.rendered_path).await.ok();
|
||||
if existing.as_deref() == Some(rendered.as_str()) {
|
||||
return Ok(RenderOutcome::Unchanged);
|
||||
}
|
||||
|
||||
// Atomic write: write to sibling tmp + rename. Keeps the bind-
|
||||
// mounted file pointing at a fully-formed config at all times.
|
||||
let parent = paths
|
||||
.rendered_path
|
||||
.parent()
|
||||
.ok_or_else(|| anyhow::anyhow!("rendered_path has no parent directory"))?;
|
||||
fs::create_dir_all(parent)
|
||||
.await
|
||||
.with_context(|| format!("creating {}", parent.display()))?;
|
||||
|
||||
let tmp = unique_tmp_path(&paths.rendered_path);
|
||||
fs::write(&tmp, &rendered)
|
||||
.await
|
||||
.with_context(|| format!("writing tmp {}", tmp.display()))?;
|
||||
fs::rename(&tmp, &paths.rendered_path)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"renaming {} -> {}",
|
||||
tmp.display(),
|
||||
paths.rendered_path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
tracing::info!(
|
||||
path = %paths.rendered_path.display(),
|
||||
auth_hash = %short_hash(&auth_b64),
|
||||
"bitcoin-ui nginx.conf rendered"
|
||||
);
|
||||
|
||||
Ok(RenderOutcome::Written)
|
||||
}
|
||||
|
||||
fn unique_tmp_path(dest: &Path) -> PathBuf {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
let ts = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0);
|
||||
dest.with_extension(format!("tmp.{ts}.{n}"))
|
||||
}
|
||||
|
||||
/// Read the plaintext RPC password from disk. Trims trailing newlines
|
||||
/// (common from `echo "$PASS" > file`) but rejects an empty result.
|
||||
async fn read_password(path: &Path) -> Result<String> {
|
||||
let raw = fs::read_to_string(path)
|
||||
.await
|
||||
.with_context(|| format!("reading bitcoin RPC password from {}", path.display()))?;
|
||||
let trimmed = raw.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
anyhow::bail!(
|
||||
"bitcoin RPC password file {} is empty — bitcoin-core bootstrap hasn't written it yet",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
Ok(trimmed)
|
||||
}
|
||||
|
||||
/// `base64("user:password")` — the value nginx puts after `Basic ` in
|
||||
/// the upstream `Authorization` header.
|
||||
fn encode_basic_auth(user: &str, password: &str) -> String {
|
||||
let raw = format!("{user}:{password}");
|
||||
base64::engine::general_purpose::STANDARD.encode(raw.as_bytes())
|
||||
}
|
||||
|
||||
/// Short hash of the auth value for logging — we never want the
|
||||
/// plaintext or full base64 in logs (it's a credential), but a stable
|
||||
/// fingerprint helps correlate rotations.
|
||||
fn short_hash(s: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(s.as_bytes());
|
||||
let digest = hasher.finalize();
|
||||
hex::encode(&digest[..4])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn paths_in(dir: &Path, password: &str) -> RenderPaths {
|
||||
let secret = dir.join("bitcoin-rpc-password");
|
||||
std::fs::write(&secret, password).unwrap();
|
||||
RenderPaths {
|
||||
secret_path: secret,
|
||||
rendered_path: dir.join("nginx.conf"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn render_writes_file_with_substitution() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let paths = paths_in(tmp.path(), "hunter2");
|
||||
let outcome = render(&paths).await.unwrap();
|
||||
assert_eq!(outcome, RenderOutcome::Written);
|
||||
let contents = std::fs::read_to_string(&paths.rendered_path).unwrap();
|
||||
// archipelago:hunter2 -> "YXJjaGlwZWxhZ286aHVudGVyMg=="
|
||||
assert!(
|
||||
contents.contains("YXJjaGlwZWxhZ286aHVudGVyMg=="),
|
||||
"base64 auth not found in rendered config:\n{contents}"
|
||||
);
|
||||
assert!(
|
||||
!contents.contains(PLACEHOLDER),
|
||||
"placeholder left in output"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn render_is_idempotent_when_password_unchanged() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let paths = paths_in(tmp.path(), "hunter2");
|
||||
let first = render(&paths).await.unwrap();
|
||||
assert_eq!(first, RenderOutcome::Written);
|
||||
let second = render(&paths).await.unwrap();
|
||||
assert_eq!(second, RenderOutcome::Unchanged);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn render_rewrites_on_password_rotation() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let paths = paths_in(tmp.path(), "old-pass");
|
||||
render(&paths).await.unwrap();
|
||||
// Rotate.
|
||||
std::fs::write(&paths.secret_path, "new-pass").unwrap();
|
||||
let outcome = render(&paths).await.unwrap();
|
||||
assert_eq!(outcome, RenderOutcome::Written);
|
||||
let contents = std::fs::read_to_string(&paths.rendered_path).unwrap();
|
||||
// archipelago:new-pass -> "YXJjaGlwZWxhZ286bmV3LXBhc3M="
|
||||
assert!(contents.contains("YXJjaGlwZWxhZ286bmV3LXBhc3M="));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn render_trims_trailing_newline_from_secret() {
|
||||
// Matches `echo "$PASS" > file` behaviour.
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let paths = paths_in(tmp.path(), "hunter2\n");
|
||||
render(&paths).await.unwrap();
|
||||
let contents = std::fs::read_to_string(&paths.rendered_path).unwrap();
|
||||
assert!(
|
||||
contents.contains("YXJjaGlwZWxhZ286aHVudGVyMg=="),
|
||||
"trailing newline should be stripped before encoding"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn render_errors_on_empty_password() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let paths = paths_in(tmp.path(), "");
|
||||
let err = render(&paths).await.unwrap_err();
|
||||
let msg = format!("{err}");
|
||||
assert!(msg.contains("empty"), "unexpected error: {msg}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn render_errors_when_secret_missing() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let paths = RenderPaths {
|
||||
secret_path: tmp.path().join("does-not-exist"),
|
||||
rendered_path: tmp.path().join("nginx.conf"),
|
||||
};
|
||||
let err = render(&paths).await.unwrap_err();
|
||||
let msg = format!("{err}");
|
||||
assert!(
|
||||
msg.contains("reading bitcoin RPC password"),
|
||||
"unexpected error: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_contains_exactly_one_placeholder() {
|
||||
// Safety net: if someone adds a second placeholder to the
|
||||
// template without updating the renderer, we want a test to
|
||||
// fail loudly rather than ship a half-substituted config.
|
||||
let count = TEMPLATE.matches(PLACEHOLDER).count();
|
||||
assert_eq!(count, 1, "template must contain exactly one {PLACEHOLDER}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_proxies_bitcoin_rpc_on_8332() {
|
||||
// Lock in the core shape so a bad template edit doesn't ship.
|
||||
assert!(TEMPLATE.contains("proxy_pass http://127.0.0.1:8332/"));
|
||||
assert!(TEMPLATE.contains("location /bitcoin-rpc/"));
|
||||
assert!(TEMPLATE.contains("listen 8334"));
|
||||
}
|
||||
}
|
||||
+9
-1
@@ -9,11 +9,19 @@ server {
|
||||
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 Authorization "Basic __BITCOIN_RPC_AUTH__";
|
||||
proxy_set_header Authorization "Basic {{BITCOIN_RPC_AUTH}}";
|
||||
add_header Access-Control-Allow-Origin *;
|
||||
add_header Access-Control-Allow-Methods "POST, GET, OPTIONS";
|
||||
add_header Access-Control-Allow-Headers "Content-Type, Authorization";
|
||||
if ($request_method = OPTIONS) { return 204; }
|
||||
}
|
||||
location /bitcoin-status {
|
||||
proxy_pass http://127.0.0.1:5678/bitcoin-status;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
add_header Cache-Control "no-store";
|
||||
}
|
||||
location / { try_files $uri $uri/ /index.html; }
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
//! BootReconciler — the long-running task that keeps the prod orchestrator's
|
||||
//! desired-state view in lockstep with what podman actually has.
|
||||
//!
|
||||
//! Step 5 of the rust-orchestrator migration. Spawned once from `main.rs`
|
||||
//! (Step 6) after the initial `adopt_existing()` pass. Every `interval` it
|
||||
//! calls `ProdContainerOrchestrator::reconcile_existing()`, which repairs
|
||||
//! containers that already exist without installing every catalog manifest.
|
||||
//!
|
||||
//! Per answered design Q3, `interval` defaults to 30 seconds.
|
||||
//!
|
||||
//! Shutdown is signalled via `Arc<Notify>`. The reconciler finishes its
|
||||
//! current `reconcile_all` call before exiting — we don't interrupt an
|
||||
//! in-flight pull or build.
|
||||
//!
|
||||
//! See `docs/rust-orchestrator-migration.md` §269-352.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::sync::Notify;
|
||||
use tokio::time::{self, Instant};
|
||||
|
||||
use crate::container::prod_orchestrator::{ProdContainerOrchestrator, ReconcileReport};
|
||||
|
||||
/// Default reconciler cadence (answered design Q3).
|
||||
pub const DEFAULT_INTERVAL: Duration = Duration::from_secs(30);
|
||||
|
||||
pub struct BootReconciler {
|
||||
orchestrator: Arc<ProdContainerOrchestrator>,
|
||||
interval: Duration,
|
||||
shutdown: Arc<Notify>,
|
||||
/// Run the companion-unit repair stage each tick. Default true.
|
||||
/// Tests disable this — companion reconcile shells out to
|
||||
/// `systemctl --user` and `podman`, which both block real time
|
||||
/// and would race the paused-clock test fixtures.
|
||||
companion_stage: bool,
|
||||
}
|
||||
|
||||
impl BootReconciler {
|
||||
pub fn new(
|
||||
orchestrator: Arc<ProdContainerOrchestrator>,
|
||||
interval: Duration,
|
||||
shutdown: Arc<Notify>,
|
||||
) -> Self {
|
||||
Self {
|
||||
orchestrator,
|
||||
interval,
|
||||
shutdown,
|
||||
companion_stage: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Disable the companion-unit reconcile stage. Used by unit tests
|
||||
/// that exercise loop cadence without the real systemd / podman
|
||||
/// surface. Production must not call this.
|
||||
#[cfg(test)]
|
||||
pub fn without_companion_stage(mut self) -> Self {
|
||||
self.companion_stage = false;
|
||||
self
|
||||
}
|
||||
|
||||
/// Run the reconcile loop until `shutdown` is notified.
|
||||
///
|
||||
/// Does one reconcile immediately, then sleeps `interval` between
|
||||
/// subsequent passes. A `shutdown.notify_one()` call unblocks the sleep
|
||||
/// and the task returns after the *next* pass completes.
|
||||
///
|
||||
/// Each pass is two stages:
|
||||
/// 1. App reconcile: `reconcile_all()` keeps every loaded manifest's
|
||||
/// container running.
|
||||
/// 2. Companion reconcile: any expected Quadlet companion unit that
|
||||
/// is missing or inactive is repaired (writes the unit, daemon-
|
||||
/// reloads, starts the service). This is the safety net for the
|
||||
/// "someone deleted my unit file" / "systemd lost the service"
|
||||
/// failure modes.
|
||||
///
|
||||
/// Never panics: per-app failures are absorbed into `ReconcileReport`
|
||||
/// by the orchestrator, and companion failures are logged but never
|
||||
/// propagated.
|
||||
pub async fn run_forever(self) {
|
||||
// Initial pass: no delay.
|
||||
self.tick().await;
|
||||
|
||||
loop {
|
||||
let deadline = Instant::now() + self.interval;
|
||||
tokio::select! {
|
||||
_ = time::sleep_until(deadline) => {
|
||||
self.tick().await;
|
||||
}
|
||||
_ = self.shutdown.notified() => {
|
||||
tracing::info!("boot reconciler: shutdown requested, exiting loop");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn tick(&self) {
|
||||
let report = self.orchestrator.reconcile_existing().await;
|
||||
Self::log_report(&report);
|
||||
|
||||
if !self.companion_stage {
|
||||
return;
|
||||
}
|
||||
let installed = self.orchestrator.manifest_ids().await;
|
||||
for (companion, err) in crate::container::companion::reconcile(&installed).await {
|
||||
tracing::warn!(
|
||||
companion = %companion,
|
||||
error = %err,
|
||||
"companion reconcile failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn log_report(report: &ReconcileReport) {
|
||||
for (app_id, action) in &report.actions {
|
||||
tracing::debug!(app_id = %app_id, action = ?action, "reconcile action");
|
||||
}
|
||||
for (app_id, err) in &report.failures {
|
||||
tracing::warn!(app_id = %app_id, error = %err, "reconcile failure");
|
||||
}
|
||||
if report.failures.is_empty() {
|
||||
tracing::debug!(count = report.actions.len(), "reconcile pass complete");
|
||||
} else {
|
||||
tracing::warn!(
|
||||
ok = report.actions.len(),
|
||||
failed = report.failures.len(),
|
||||
"reconcile pass completed with failures"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::container::prod_orchestrator::ProdContainerOrchestrator;
|
||||
use anyhow::Result;
|
||||
use archipelago_container::{
|
||||
AppManifest, BuildConfig, ContainerRuntime as ContainerRuntimeTrait, ContainerState,
|
||||
ContainerStatus,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex as StdMutex;
|
||||
|
||||
/// Instrumented runtime that counts reconcile-loop side effects so tests
|
||||
/// can tell exactly how many passes have fired. All containers are
|
||||
/// reported as already Running so `reconcile_all` will NoOp — we are only
|
||||
/// measuring loop cadence, not install behavior.
|
||||
#[derive(Default)]
|
||||
struct CountingRuntime {
|
||||
/// Number of times get_container_status has been called. Each
|
||||
/// reconcile_all pass hits this once per manifest, so with one
|
||||
/// manifest this equals the number of reconcile passes.
|
||||
status_calls: StdMutex<u32>,
|
||||
running: StdMutex<HashMap<String, ContainerState>>,
|
||||
}
|
||||
|
||||
impl CountingRuntime {
|
||||
fn new_with(names: &[&str]) -> Self {
|
||||
let me = Self::default();
|
||||
let mut m = me.running.lock().unwrap();
|
||||
for n in names {
|
||||
m.insert((*n).to_string(), ContainerState::Running);
|
||||
}
|
||||
drop(m);
|
||||
me
|
||||
}
|
||||
fn status_call_count(&self) -> u32 {
|
||||
*self.status_calls.lock().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ContainerRuntimeTrait for CountingRuntime {
|
||||
async fn pull_image(&self, _: &str, _: Option<&str>) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
async fn create_container(&self, _: &AppManifest, name: &str, _: u16) -> Result<String> {
|
||||
Ok(name.to_string())
|
||||
}
|
||||
async fn start_container(&self, _: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
async fn stop_container(&self, _: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
async fn remove_container(&self, _: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
async fn get_container_status(&self, name: &str) -> Result<ContainerStatus> {
|
||||
*self.status_calls.lock().unwrap() += 1;
|
||||
let state = self
|
||||
.running
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(name)
|
||||
.cloned()
|
||||
.ok_or_else(|| anyhow::anyhow!("not found: {name}"))?;
|
||||
Ok(ContainerStatus {
|
||||
id: format!("id-{name}"),
|
||||
name: name.to_string(),
|
||||
state,
|
||||
health: None,
|
||||
exit_code: None,
|
||||
started_at: None,
|
||||
image: "test".into(),
|
||||
created: "now".into(),
|
||||
ports: vec![],
|
||||
lan_address: None,
|
||||
})
|
||||
}
|
||||
async fn get_container_logs(&self, _: &str, _: u32) -> Result<Vec<String>> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn list_containers(&self) -> Result<Vec<ContainerStatus>> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn image_exists(&self, _: &str) -> Result<bool> {
|
||||
Ok(true)
|
||||
}
|
||||
async fn build_image(&self, _: &BuildConfig) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn pull_manifest(id: &str, image: &str) -> AppManifest {
|
||||
let yaml = format!(
|
||||
"app:\n id: {id}\n name: {id}\n version: 1.0.0\n container:\n image: {image}\n"
|
||||
);
|
||||
AppManifest::parse(&yaml).unwrap()
|
||||
}
|
||||
|
||||
async fn orch_with_one_running_manifest(
|
||||
rt: Arc<CountingRuntime>,
|
||||
) -> Arc<ProdContainerOrchestrator> {
|
||||
let mut orch =
|
||||
ProdContainerOrchestrator::with_runtime(rt, PathBuf::from("/nonexistent-for-tests"));
|
||||
let tmp = tempfile::tempdir().unwrap().keep();
|
||||
orch.set_data_dir(tmp);
|
||||
let orch = Arc::new(orch);
|
||||
orch.insert_manifest_for_test(
|
||||
pull_manifest("bitcoin-knots", "docker.io/bitcoin/knots:28"),
|
||||
PathBuf::from("/tmp/bk"),
|
||||
)
|
||||
.await;
|
||||
orch
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn initial_pass_fires_immediately() {
|
||||
let rt = Arc::new(CountingRuntime::new_with(&["bitcoin-knots"]));
|
||||
let orch = orch_with_one_running_manifest(rt.clone()).await;
|
||||
let shutdown = Arc::new(Notify::new());
|
||||
let reconciler =
|
||||
BootReconciler::new(orch.clone(), Duration::from_secs(30), shutdown.clone())
|
||||
.without_companion_stage();
|
||||
let handle = tokio::spawn(reconciler.run_forever());
|
||||
|
||||
// Yield so the spawned task gets CPU to run its initial reconcile.
|
||||
tokio::task::yield_now().await;
|
||||
tokio::task::yield_now().await;
|
||||
|
||||
// We expect exactly one reconcile pass to have run by now (the initial),
|
||||
// NOT a second one (the 30s sleep hasn't elapsed in paused time).
|
||||
assert_eq!(rt.status_call_count(), 1, "initial pass should fire once");
|
||||
|
||||
shutdown.notify_one();
|
||||
// Under paused clock the select! is blocked on sleep_until; the notify
|
||||
// will unblock it. Advance wall-clock a hair so the notify gets polled.
|
||||
tokio::task::yield_now().await;
|
||||
let _ = tokio::time::timeout(Duration::from_secs(1), handle).await;
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn second_pass_fires_after_interval() {
|
||||
let rt = Arc::new(CountingRuntime::new_with(&["bitcoin-knots"]));
|
||||
let orch = orch_with_one_running_manifest(rt.clone()).await;
|
||||
let shutdown = Arc::new(Notify::new());
|
||||
let reconciler =
|
||||
BootReconciler::new(orch.clone(), Duration::from_secs(30), shutdown.clone())
|
||||
.without_companion_stage();
|
||||
let handle = tokio::spawn(reconciler.run_forever());
|
||||
|
||||
tokio::task::yield_now().await;
|
||||
tokio::task::yield_now().await;
|
||||
assert_eq!(rt.status_call_count(), 1);
|
||||
|
||||
// Fast-forward past one interval; the sleep_until should fire.
|
||||
tokio::time::advance(Duration::from_secs(31)).await;
|
||||
tokio::task::yield_now().await;
|
||||
tokio::task::yield_now().await;
|
||||
|
||||
assert_eq!(
|
||||
rt.status_call_count(),
|
||||
2,
|
||||
"a second reconcile pass should fire after one interval"
|
||||
);
|
||||
|
||||
shutdown.notify_one();
|
||||
tokio::task::yield_now().await;
|
||||
let _ = tokio::time::timeout(Duration::from_secs(1), handle).await;
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn shutdown_terminates_loop() {
|
||||
let rt = Arc::new(CountingRuntime::new_with(&["bitcoin-knots"]));
|
||||
let orch = orch_with_one_running_manifest(rt.clone()).await;
|
||||
let shutdown = Arc::new(Notify::new());
|
||||
let reconciler =
|
||||
BootReconciler::new(orch.clone(), Duration::from_secs(30), shutdown.clone())
|
||||
.without_companion_stage();
|
||||
let handle = tokio::spawn(reconciler.run_forever());
|
||||
tokio::task::yield_now().await;
|
||||
tokio::task::yield_now().await;
|
||||
|
||||
shutdown.notify_one();
|
||||
// The select! should wake on Notified and return. Use a real timeout
|
||||
// with advancing the paused clock to make sure the task exits.
|
||||
tokio::time::advance(Duration::from_millis(10)).await;
|
||||
let result = tokio::time::timeout(Duration::from_secs(5), handle).await;
|
||||
assert!(result.is_ok(), "reconciler did not exit after shutdown");
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn failure_in_one_pass_does_not_stop_loop() {
|
||||
// Manifest references a container the runtime does not have AND
|
||||
// cannot create (no install path — install_fresh will also fail to
|
||||
// pull, since CountingRuntime::pull_image returns Ok but the
|
||||
// manifest's referenced container stays uncreated). In practice
|
||||
// reconcile_all will observe the missing container, install_fresh
|
||||
// will run, and the next pass will see a new state. We care about
|
||||
// "loop keeps ticking even when the report has actions".
|
||||
let rt = Arc::new(CountingRuntime::default());
|
||||
let mut orch = ProdContainerOrchestrator::with_runtime(
|
||||
rt.clone(),
|
||||
PathBuf::from("/nonexistent-for-tests"),
|
||||
);
|
||||
let tmp = tempfile::tempdir().unwrap().keep();
|
||||
orch.set_data_dir(tmp);
|
||||
let orch = Arc::new(orch);
|
||||
orch.insert_manifest_for_test(
|
||||
pull_manifest("bitcoin-knots", "docker.io/bitcoin/knots:28"),
|
||||
PathBuf::from("/tmp/bk"),
|
||||
)
|
||||
.await;
|
||||
let shutdown = Arc::new(Notify::new());
|
||||
let reconciler =
|
||||
BootReconciler::new(orch.clone(), Duration::from_secs(30), shutdown.clone())
|
||||
.without_companion_stage();
|
||||
let handle = tokio::spawn(reconciler.run_forever());
|
||||
|
||||
tokio::task::yield_now().await;
|
||||
tokio::task::yield_now().await;
|
||||
let first = rt.status_call_count();
|
||||
assert!(first >= 1, "initial pass should have touched the runtime");
|
||||
|
||||
// Advance one interval — second pass should fire regardless of what
|
||||
// the first pass did.
|
||||
tokio::time::advance(Duration::from_secs(31)).await;
|
||||
tokio::task::yield_now().await;
|
||||
tokio::task::yield_now().await;
|
||||
let second = rt.status_call_count();
|
||||
assert!(
|
||||
second > first,
|
||||
"loop should have fired a second pass after the interval"
|
||||
);
|
||||
|
||||
shutdown.notify_one();
|
||||
tokio::time::advance(Duration::from_millis(10)).await;
|
||||
let _ = tokio::time::timeout(Duration::from_secs(5), handle).await;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
//! Companion UI container lifecycle, entirely Quadlet-managed.
|
||||
//!
|
||||
//! A "companion" is a small nginx-based container that exposes a
|
||||
//! browser-friendly UI on top of a headless backend service:
|
||||
//!
|
||||
//! | Backend | Companion | Purpose |
|
||||
//! |------------------|--------------------|--------------------------|
|
||||
//! | bitcoin-knots | archy-bitcoin-ui | RPC viewer |
|
||||
//! | bitcoin-core | archy-bitcoin-ui | RPC viewer |
|
||||
//! | lnd | archy-lnd-ui | wallet/channel UI |
|
||||
//! | electrumx | archy-electrs-ui | indexer status UI |
|
||||
//!
|
||||
//! Lifecycle: `install` writes a Quadlet `.container` unit to
|
||||
//! `~/.config/containers/systemd/`, daemon-reloads, then starts the
|
||||
//! generated `.service`. systemd owns supervision from that point on
|
||||
//! — archipelago can crash, restart, or be uninstalled without
|
||||
//! touching the companion.
|
||||
//!
|
||||
//! This replaces the old `tokio::spawn { podman run }` block in
|
||||
//! `install.rs` (~165 lines of fire-and-forget shellouts) with a
|
||||
//! single declarative call.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use std::path::PathBuf;
|
||||
use tokio::fs;
|
||||
use tokio::process::Command;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::container::quadlet::{self, BindMount, NetworkMode, QuadletUnit};
|
||||
use archipelago_container::image_uses_insecure_registry;
|
||||
|
||||
const COMPANION_REGISTRY: &str = "146.59.87.168:3000/lfg2025";
|
||||
|
||||
/// Static description of one companion. The full list per backend
|
||||
/// app_id lives in `companions_for`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CompanionSpec {
|
||||
/// Container + unit name (e.g. "archy-bitcoin-ui").
|
||||
pub name: &'static str,
|
||||
/// Image base name in the lfg2025 registry namespace
|
||||
/// (e.g. "bitcoin-ui" → "146.59.87.168:3000/lfg2025/bitcoin-ui:latest").
|
||||
pub image_base: &'static str,
|
||||
/// Filesystem locations to look for a local Dockerfile (build wins
|
||||
/// over registry pull). Searched in order; first hit wins.
|
||||
pub build_dir_candidates: &'static [&'static str],
|
||||
/// Optional pre-start hook that renders config files referenced
|
||||
/// by `bind_mounts`. Returns Ok(()) on success; bind-mount must
|
||||
/// be present at start time or the companion will 502.
|
||||
pub pre_start: Option<PreStartHook>,
|
||||
/// Bind mounts. Always read-only — companions don't write to
|
||||
/// host paths.
|
||||
pub bind_mounts: &'static [(&'static str, &'static str)],
|
||||
/// Host-to-container TCP ports for non-host-network companions.
|
||||
pub ports: &'static [(u16, u16)],
|
||||
/// Whether the companion must share the host network namespace.
|
||||
pub host_network: bool,
|
||||
}
|
||||
|
||||
pub type PreStartHook = fn() -> futures_util::future::BoxFuture<'static, Result<()>>;
|
||||
|
||||
/// Companions to install when `package_id` lands. Empty for apps
|
||||
/// without a companion UI.
|
||||
pub fn companions_for(package_id: &str) -> &'static [CompanionSpec] {
|
||||
match package_id {
|
||||
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => BITCOIN_UI,
|
||||
"lnd" => LND_UI,
|
||||
"electrumx" | "electrs" | "mempool-electrs" => ELECTRS_UI,
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
const BITCOIN_UI: &[CompanionSpec] = &[CompanionSpec {
|
||||
name: "archy-bitcoin-ui",
|
||||
image_base: "bitcoin-ui",
|
||||
build_dir_candidates: &[
|
||||
"/opt/archipelago/docker/bitcoin-ui",
|
||||
"/home/archipelago/archy/docker/bitcoin-ui",
|
||||
"/home/archipelago/Projects/archy/docker/bitcoin-ui",
|
||||
],
|
||||
pre_start: Some(render_bitcoin_ui),
|
||||
bind_mounts: &[(
|
||||
"/var/lib/archipelago/bitcoin-ui/nginx.conf",
|
||||
"/etc/nginx/conf.d/default.conf",
|
||||
)],
|
||||
ports: &[],
|
||||
host_network: true,
|
||||
}];
|
||||
|
||||
const LND_UI: &[CompanionSpec] = &[CompanionSpec {
|
||||
name: "archy-lnd-ui",
|
||||
image_base: "lnd-ui",
|
||||
build_dir_candidates: &[
|
||||
"/opt/archipelago/docker/lnd-ui",
|
||||
"/home/archipelago/archy/docker/lnd-ui",
|
||||
"/home/archipelago/Projects/archy/docker/lnd-ui",
|
||||
],
|
||||
pre_start: None,
|
||||
bind_mounts: &[],
|
||||
ports: &[(18083, 80)],
|
||||
host_network: false,
|
||||
}];
|
||||
|
||||
const ELECTRS_UI: &[CompanionSpec] = &[CompanionSpec {
|
||||
name: "archy-electrs-ui",
|
||||
image_base: "electrs-ui",
|
||||
build_dir_candidates: &[
|
||||
"/opt/archipelago/docker/electrs-ui",
|
||||
"/home/archipelago/archy/docker/electrs-ui",
|
||||
"/home/archipelago/Projects/archy/docker/electrs-ui",
|
||||
],
|
||||
pre_start: None,
|
||||
bind_mounts: &[],
|
||||
ports: &[],
|
||||
host_network: true,
|
||||
}];
|
||||
|
||||
fn render_bitcoin_ui() -> futures_util::future::BoxFuture<'static, Result<()>> {
|
||||
Box::pin(async {
|
||||
let paths = crate::container::bitcoin_ui::RenderPaths::default();
|
||||
crate::container::bitcoin_ui::render(&paths)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.context("render bitcoin-ui nginx.conf")
|
||||
})
|
||||
}
|
||||
|
||||
/// Provision and start every companion for `package_id`. Each
|
||||
/// companion is independent — a failure in one is logged but does
|
||||
/// not abort the others.
|
||||
pub async fn install_for(package_id: &str) -> Vec<(String, anyhow::Error)> {
|
||||
let mut failures = Vec::new();
|
||||
for spec in companions_for(package_id) {
|
||||
if let Err(e) = install_one(spec).await {
|
||||
warn!(companion = spec.name, error = %e, "companion install failed");
|
||||
failures.push((spec.name.to_string(), e));
|
||||
}
|
||||
}
|
||||
failures
|
||||
}
|
||||
|
||||
/// Stop and remove every companion for `package_id`. Best effort:
|
||||
/// errors are logged but do not abort the sequence.
|
||||
pub async fn remove_for(package_id: &str) {
|
||||
let dir = match quadlet::unit_dir().await {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
warn!("companion remove: cannot resolve quadlet dir: {e:#}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
for spec in companions_for(package_id) {
|
||||
if let Err(e) = quadlet::disable_remove(spec.name, &dir).await {
|
||||
warn!(companion = spec.name, error = %e, "companion remove failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Provision one companion: pre-start hook → image present → write
|
||||
/// quadlet → daemon-reload → start.
|
||||
pub async fn install_one(spec: &CompanionSpec) -> Result<()> {
|
||||
if let Some(hook) = spec.pre_start {
|
||||
hook().await.with_context(|| {
|
||||
format!(
|
||||
"pre-start hook failed for {} — companion will not start",
|
||||
spec.name
|
||||
)
|
||||
})?;
|
||||
}
|
||||
let image = ensure_image_present(spec).await?;
|
||||
let unit = build_unit(spec, &image);
|
||||
let dir = quadlet::unit_dir().await?;
|
||||
let changed = quadlet::write_if_changed(&unit, &dir).await?;
|
||||
if changed {
|
||||
info!(companion = spec.name, "wrote quadlet unit");
|
||||
quadlet::daemon_reload_user().await?;
|
||||
}
|
||||
// Start is idempotent — if already running, systemctl returns 0.
|
||||
quadlet::enable_now(&unit.service_name()).await?;
|
||||
info!(companion = spec.name, "companion started");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build companion image locally if a Dockerfile exists, otherwise
|
||||
/// pull from the lfg2025 registry. Returns the image ref the quadlet
|
||||
/// should reference (`localhost/<base>:latest` for build, registry
|
||||
/// URL for pull).
|
||||
async fn ensure_image_present(spec: &CompanionSpec) -> Result<String> {
|
||||
let local_image = format!("localhost/{}:latest", spec.image_base);
|
||||
let local_image_compat = format!("localhost/{}:local", spec.image_base);
|
||||
let registry_image = format!("{}/{}:latest", COMPANION_REGISTRY, spec.image_base);
|
||||
|
||||
// Prefer local build — companions can carry build-time customizations
|
||||
// (e.g. nginx.conf templates baked in). Search known candidates.
|
||||
for dir in spec.build_dir_candidates {
|
||||
let dockerfile = PathBuf::from(dir).join("Dockerfile");
|
||||
if fs::try_exists(&dockerfile).await.unwrap_or(false) {
|
||||
if image_exists(&local_image_compat).await {
|
||||
return Ok(local_image_compat);
|
||||
}
|
||||
if image_exists(&local_image).await {
|
||||
return Ok(local_image);
|
||||
}
|
||||
info!(companion = spec.name, "building locally from {dir}");
|
||||
let out = Command::new("podman")
|
||||
.args(["build", "-t", &local_image, dir])
|
||||
.output()
|
||||
.await
|
||||
.context("spawn podman build")?;
|
||||
if out.status.success() {
|
||||
return Ok(local_image);
|
||||
}
|
||||
warn!(
|
||||
companion = spec.name,
|
||||
"local build failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
);
|
||||
// Fall through to registry pull rather than fail outright.
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Registry pull. Use insecure flag only for whitelisted hosts.
|
||||
let mut cmd = Command::new("podman");
|
||||
cmd.arg("pull");
|
||||
if image_uses_insecure_registry(®istry_image) {
|
||||
cmd.arg("--tls-verify=false");
|
||||
}
|
||||
cmd.arg(®istry_image);
|
||||
let out = cmd.output().await.context("spawn podman pull")?;
|
||||
if !out.status.success() {
|
||||
anyhow::bail!(
|
||||
"no local Dockerfile and registry pull failed for {}: {}",
|
||||
spec.name,
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
);
|
||||
}
|
||||
Ok(registry_image)
|
||||
}
|
||||
|
||||
async fn image_exists(image: &str) -> bool {
|
||||
Command::new("podman")
|
||||
.args(["image", "exists", image])
|
||||
.status()
|
||||
.await
|
||||
.is_ok_and(|status| status.success())
|
||||
}
|
||||
|
||||
fn build_unit(spec: &CompanionSpec, image: &str) -> QuadletUnit {
|
||||
QuadletUnit {
|
||||
name: spec.name.into(),
|
||||
description: format!("Archipelago companion UI: {}", spec.name),
|
||||
image: image.into(),
|
||||
network: if spec.host_network {
|
||||
NetworkMode::Host
|
||||
} else {
|
||||
NetworkMode::Bridge("bridge".into())
|
||||
},
|
||||
// Run as root inside the container so nginx can chown its
|
||||
// worker dirs. Rootless podman maps this to a high host UID,
|
||||
// so it is unprivileged on the host.
|
||||
user: Some("0:0".into()),
|
||||
memory_mb: Some(128),
|
||||
cap_drop_all: true,
|
||||
cap_add: vec![
|
||||
"CHOWN".into(),
|
||||
"DAC_OVERRIDE".into(),
|
||||
"NET_BIND_SERVICE".into(),
|
||||
"SETUID".into(),
|
||||
"SETGID".into(),
|
||||
],
|
||||
bind_mounts: spec
|
||||
.bind_mounts
|
||||
.iter()
|
||||
.map(|(host, container)| BindMount {
|
||||
host: PathBuf::from(*host),
|
||||
container: PathBuf::from(*container),
|
||||
read_only: true,
|
||||
})
|
||||
.collect(),
|
||||
ports: spec
|
||||
.ports
|
||||
.iter()
|
||||
.map(|(host, container)| (*host, *container, "tcp".into()))
|
||||
.collect(),
|
||||
extra_podman_args: vec![],
|
||||
depends_on: vec![],
|
||||
// Companions don't use the backend-manifest extension fields;
|
||||
// the renderer skips empty/false directives so the rendered
|
||||
// bytes are unchanged from before quadlet.rs grew the new fields.
|
||||
..QuadletUnit::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Is a user systemd manager reachable? In production archipelago.service
|
||||
/// inherits XDG_RUNTIME_DIR from systemd; in unit tests / CI sandboxes it
|
||||
/// is unset, in which case `systemctl --user` would fail and write to
|
||||
/// HOME would be an unwanted side effect. The reconciler skips its
|
||||
/// companion stage when this is false.
|
||||
fn user_systemd_available() -> bool {
|
||||
std::env::var_os("XDG_RUNTIME_DIR")
|
||||
.map(|v| !v.is_empty())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Reconcile companion presence: every expected companion for the
|
||||
/// given installed apps must have its quadlet unit on disk and its
|
||||
/// service active. Returns a list of (companion, error) for anything
|
||||
/// that needed correction and failed.
|
||||
///
|
||||
/// Called from `boot_reconciler` so a deleted unit file or a stopped
|
||||
/// service is repaired within one tick. No-ops if the user systemd
|
||||
/// manager is not reachable (CI / test environments).
|
||||
pub async fn reconcile(installed_apps: &[String]) -> Vec<(String, anyhow::Error)> {
|
||||
if !user_systemd_available() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut failures = Vec::new();
|
||||
for app_id in installed_apps {
|
||||
for spec in companions_for(app_id) {
|
||||
match needs_repair(spec).await {
|
||||
Ok(false) => {}
|
||||
Ok(true) => {
|
||||
info!(
|
||||
companion = spec.name,
|
||||
"reconcile: companion not active, repairing"
|
||||
);
|
||||
if let Err(e) = install_one(spec).await {
|
||||
failures.push((spec.name.to_string(), e));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(companion = spec.name, error = %e, "reconcile probe failed");
|
||||
failures.push((spec.name.to_string(), e));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
failures
|
||||
}
|
||||
|
||||
/// Does this companion need install_one to be re-run? Returns true if
|
||||
/// the unit file is missing, stale, or the service is not active.
|
||||
async fn needs_repair(spec: &CompanionSpec) -> Result<bool> {
|
||||
let dir = quadlet::unit_dir().await?;
|
||||
let unit_path = dir.join(format!("{}.container", spec.name));
|
||||
if !fs::try_exists(&unit_path).await.unwrap_or(false) {
|
||||
return Ok(true);
|
||||
}
|
||||
let expected_image = ensure_image_present(spec).await?;
|
||||
let expected_unit = build_unit(spec, &expected_image);
|
||||
if expected_unit.render() != fs::read_to_string(&unit_path).await.unwrap_or_default() {
|
||||
return Ok(true);
|
||||
}
|
||||
let svc = format!("{}.service", spec.name);
|
||||
Ok(!quadlet::is_active(&svc).await)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn companions_for_known_apps_returns_expected_set() {
|
||||
assert_eq!(companions_for("bitcoin-knots").len(), 1);
|
||||
assert_eq!(companions_for("bitcoin-core").len(), 1);
|
||||
assert_eq!(companions_for("bitcoin").len(), 1);
|
||||
assert_eq!(companions_for("lnd").len(), 1);
|
||||
assert_eq!(companions_for("electrumx").len(), 1);
|
||||
assert_eq!(companions_for("electrs").len(), 1);
|
||||
assert_eq!(companions_for("mempool-electrs").len(), 1);
|
||||
assert_eq!(companions_for("nextcloud").len(), 0);
|
||||
assert_eq!(companions_for("not-a-real-app").len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_unit_uses_host_network_and_drops_caps() {
|
||||
let spec = &BITCOIN_UI[0];
|
||||
let u = build_unit(spec, "localhost/bitcoin-ui:latest");
|
||||
assert_eq!(u.name, "archy-bitcoin-ui");
|
||||
assert!(matches!(u.network, NetworkMode::Host));
|
||||
assert!(u.cap_drop_all);
|
||||
assert!(u.cap_add.iter().any(|c| c == "NET_BIND_SERVICE"));
|
||||
assert_eq!(u.user.as_deref(), Some("0:0"));
|
||||
assert_eq!(u.memory_mb, Some(128));
|
||||
assert_eq!(u.bind_mounts.len(), 1);
|
||||
assert_eq!(
|
||||
u.bind_mounts[0].container,
|
||||
PathBuf::from("/etc/nginx/conf.d/default.conf")
|
||||
);
|
||||
assert!(u.bind_mounts[0].read_only);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lnd_ui_uses_port_mapping_not_host_port_80() {
|
||||
let spec = &LND_UI[0];
|
||||
let u = build_unit(spec, "localhost/lnd-ui:latest");
|
||||
assert_eq!(u.name, "archy-lnd-ui");
|
||||
assert!(matches!(u.network, NetworkMode::Bridge(ref n) if n == "bridge"));
|
||||
assert_eq!(u.ports, vec![(18083, 80, "tcp".into())]);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
use anyhow::{Context, Result};
|
||||
use archipelago_container::{
|
||||
AppManifest, BitcoinSimulationMode, BitcoinSimulator,
|
||||
ContainerRuntime as ContainerRuntimeTrait, ContainerStatus, PortManager,
|
||||
ContainerRuntime as ContainerRuntimeTrait, ContainerStatus, PortManager, ResolvedSource,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::config::{BitcoinSimulation, Config, ContainerRuntime};
|
||||
use crate::container::data_manager::DevDataManager;
|
||||
use crate::container::traits::ContainerOrchestrator;
|
||||
|
||||
pub struct DevContainerOrchestrator {
|
||||
runtime: Arc<dyn ContainerRuntimeTrait>,
|
||||
@@ -103,14 +106,27 @@ impl DevContainerOrchestrator {
|
||||
volume.source = dev_path.to_string_lossy().to_string();
|
||||
}
|
||||
|
||||
// Pull image
|
||||
self.runtime
|
||||
.pull_image(
|
||||
&manifest.app.container.image,
|
||||
manifest.app.container.image_signature.as_deref(),
|
||||
)
|
||||
.await
|
||||
.context("Failed to pull image")?;
|
||||
// Resolve pull-or-build. Dev orchestrator currently only supports pull;
|
||||
// Build support lands in Step 2 of the rust-orchestrator migration.
|
||||
match manifest.app.container.resolve().ok_or_else(|| {
|
||||
anyhow::anyhow!("manifest container config invalid (neither image nor build)")
|
||||
})? {
|
||||
ResolvedSource::Pull {
|
||||
image,
|
||||
image_signature,
|
||||
..
|
||||
} => {
|
||||
self.runtime
|
||||
.pull_image(&image, image_signature.as_deref())
|
||||
.await
|
||||
.context("Failed to pull image")?;
|
||||
}
|
||||
ResolvedSource::Build(_) => {
|
||||
anyhow::bail!(
|
||||
"dev orchestrator does not yet support local image builds (see rust-orchestrator-migration.md Step 2)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Create container with port offset
|
||||
let port_offset = if self.config.dev_mode {
|
||||
@@ -236,10 +252,160 @@ 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()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Load a manifest for `app_id` from the dev-mode apps directory.
|
||||
///
|
||||
/// Used by the trait-level `install(app_id)` entry point.
|
||||
///
|
||||
/// Search order intentionally mirrors production/operator reality:
|
||||
/// 1) `$ARCHIPELAGO_APPS_DIR` (explicit override)
|
||||
/// 2) `/opt/archipelago/apps` (image-recipe canonical path)
|
||||
/// 3) `/home/archipelago/Projects/archy/apps` (repo-local fallback on dev nodes)
|
||||
/// 4) `<data_dir>/apps` (legacy dev layout)
|
||||
async fn load_manifest_for(&self, app_id: &str) -> Result<AppManifest> {
|
||||
let candidates = candidate_manifest_paths(app_id, &self.config.data_dir);
|
||||
let mut last_err: Option<anyhow::Error> = None;
|
||||
|
||||
for path in candidates {
|
||||
let content = match tokio::fs::read_to_string(&path).await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
last_err = Some(e.into());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let manifest: AppManifest = serde_yaml::from_str(&content)
|
||||
.with_context(|| format!("parsing manifest {}", path.display()))?;
|
||||
return Ok(manifest);
|
||||
}
|
||||
|
||||
let msg = format!(
|
||||
"manifest for {} not found in any search path (set ARCHIPELAGO_APPS_DIR or install /opt/archipelago/apps)",
|
||||
app_id
|
||||
);
|
||||
Err(match last_err {
|
||||
Some(e) => e.context(msg),
|
||||
None => anyhow::anyhow!(msg),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn candidate_manifest_paths(app_id: &str, data_dir: &Path) -> Vec<PathBuf> {
|
||||
let mut roots: Vec<PathBuf> = Vec::new();
|
||||
|
||||
if let Ok(v) = std::env::var("ARCHIPELAGO_APPS_DIR") {
|
||||
let v = v.trim();
|
||||
if !v.is_empty() {
|
||||
roots.push(PathBuf::from(v));
|
||||
}
|
||||
}
|
||||
|
||||
roots.push(PathBuf::from("/opt/archipelago/apps"));
|
||||
roots.push(PathBuf::from("/home/archipelago/Projects/archy/apps"));
|
||||
roots.push(data_dir.join("apps"));
|
||||
|
||||
let mut deduped: Vec<PathBuf> = Vec::new();
|
||||
for root in roots {
|
||||
if !deduped.iter().any(|p| p == &root) {
|
||||
deduped.push(root);
|
||||
}
|
||||
}
|
||||
|
||||
deduped
|
||||
.into_iter()
|
||||
.map(|root| root.join(app_id).join("manifest.yml"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Trait impl (Step 4): expose the shared ContainerOrchestrator surface.
|
||||
// Forwards to the inherent methods, which internally apply the `-dev` suffix
|
||||
// and the port offset. The trait keeps the RPC layer mode-agnostic; Dev's
|
||||
// install_container (manifest_path-based) stays as an inherent method for the
|
||||
// ad-hoc dev-mode RPC and is not exposed on the trait.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[async_trait]
|
||||
impl ContainerOrchestrator for DevContainerOrchestrator {
|
||||
async fn install(&self, app_id: &str) -> Result<String> {
|
||||
let manifest = self.load_manifest_for(app_id).await?;
|
||||
let name = self.install_container(&manifest, "").await?;
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
async fn start(&self, app_id: &str) -> Result<()> {
|
||||
self.start_container(app_id).await
|
||||
}
|
||||
|
||||
async fn stop(&self, app_id: &str) -> Result<()> {
|
||||
self.stop_container(app_id).await
|
||||
}
|
||||
|
||||
async fn restart(&self, app_id: &str) -> Result<()> {
|
||||
let _ = self.stop_container(app_id).await;
|
||||
self.start_container(app_id).await
|
||||
}
|
||||
|
||||
async fn remove(&self, app_id: &str, preserve_data: bool) -> Result<()> {
|
||||
self.remove_container(app_id, preserve_data).await
|
||||
}
|
||||
|
||||
async fn upgrade(&self, app_id: &str) -> Result<()> {
|
||||
// Dev upgrade: stop, remove (preserving data), re-install from the loaded manifest.
|
||||
let _ = self.stop_container(app_id).await;
|
||||
let _ = self.remove_container(app_id, true).await;
|
||||
let manifest = self.load_manifest_for(app_id).await?;
|
||||
self.install_container(&manifest, "").await?;
|
||||
self.start_container(app_id).await
|
||||
}
|
||||
|
||||
async fn status(&self, app_id: &str) -> Result<ContainerStatus> {
|
||||
self.get_container_status(app_id).await
|
||||
}
|
||||
|
||||
async fn list(&self) -> Result<Vec<ContainerStatus>> {
|
||||
self.list_containers().await
|
||||
}
|
||||
|
||||
async fn logs(&self, app_id: &str, lines: u32) -> Result<Vec<String>> {
|
||||
self.get_container_logs(app_id, lines).await
|
||||
}
|
||||
|
||||
async fn health(&self, app_id: &str) -> Result<String> {
|
||||
self.get_health_status(app_id).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::candidate_manifest_paths;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn candidate_manifest_paths_include_expected_fallbacks() {
|
||||
let app_id = "bitcoin-ui";
|
||||
let paths = candidate_manifest_paths(app_id, &PathBuf::from("/var/lib/archipelago"));
|
||||
let as_strings: Vec<String> = paths
|
||||
.iter()
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
.collect();
|
||||
|
||||
assert!(as_strings
|
||||
.iter()
|
||||
.any(|p| p == "/opt/archipelago/apps/bitcoin-ui/manifest.yml"));
|
||||
assert!(as_strings
|
||||
.iter()
|
||||
.any(|p| p == "/home/archipelago/Projects/archy/apps/bitcoin-ui/manifest.yml"));
|
||||
assert!(as_strings
|
||||
.iter()
|
||||
.any(|p| p == "/var/lib/archipelago/apps/bitcoin-ui/manifest.yml"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,11 +44,6 @@ impl DockerPackageScanner {
|
||||
"nbxplorer",
|
||||
"mempool-db",
|
||||
"mempool-api",
|
||||
"penpot-postgres",
|
||||
"penpot-backend",
|
||||
"penpot-exporter",
|
||||
"penpot-valkey",
|
||||
"penpot-mailcatch",
|
||||
"immich_postgres",
|
||||
"immich_redis",
|
||||
"endurain-db",
|
||||
@@ -66,12 +61,18 @@ impl DockerPackageScanner {
|
||||
"indeedhub-build_minio-init_1",
|
||||
"indeedhub-build_relay_1",
|
||||
"indeedhub-build_ffmpeg-worker_1",
|
||||
"netbird-server",
|
||||
"buildx_buildkit_default",
|
||||
];
|
||||
|
||||
// First pass: collect UI containers
|
||||
// First pass: collect running UI containers. Custom UI-backed apps must
|
||||
// not advertise a launch URL unless their companion is actually alive.
|
||||
let mut ui_containers: HashMap<String, String> = HashMap::new();
|
||||
for container in &containers {
|
||||
if container.name.ends_with("-ui") {
|
||||
if !matches!(container.state, ContainerState::Running) {
|
||||
continue;
|
||||
}
|
||||
// Map fedimint-ui -> fedimint, lnd-ui -> lnd (normalize archy- prefix for lookup)
|
||||
let parent_app = container
|
||||
.name
|
||||
@@ -81,10 +82,10 @@ impl DockerPackageScanner {
|
||||
.strip_prefix("archy-")
|
||||
.unwrap_or(parent_app)
|
||||
.to_string();
|
||||
if !container.ports.is_empty() {
|
||||
if let Some(ui_address) = extract_lan_address(&container.ports) {
|
||||
ui_containers.insert(canonical_id, ui_address);
|
||||
}
|
||||
let ui_address = extract_lan_address(&container.ports)
|
||||
.or_else(|| companion_lan_address(&canonical_id));
|
||||
if let Some(ui_address) = ui_address {
|
||||
ui_containers.insert(canonical_id, ui_address);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -124,6 +125,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);
|
||||
@@ -137,17 +143,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())
|
||||
} else if app_id == "bitcoin-knots" {
|
||||
Some("http://localhost:8334".to_string())
|
||||
} else if app_id == "lnd" {
|
||||
Some("http://localhost:8081".to_string())
|
||||
} else if app_id == "electrumx" || app_id == "mempool-electrs" || app_id == "electrs" {
|
||||
Some("http://localhost:50002".to_string())
|
||||
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!(
|
||||
@@ -163,21 +170,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(),
|
||||
@@ -288,7 +282,7 @@ fn get_app_tier(app_id: &str) -> &'static str {
|
||||
"uptime-kuma" => "recommended",
|
||||
"grafana" => "recommended",
|
||||
"searxng" => "recommended",
|
||||
"tailscale" => "recommended",
|
||||
"tailscale" | "netbird" => "recommended",
|
||||
"portainer" => "recommended",
|
||||
// Optional: everything else
|
||||
_ => "optional",
|
||||
@@ -297,9 +291,16 @@ fn get_app_tier(app_id: &str) -> &'static str {
|
||||
|
||||
fn get_app_metadata(app_id: &str) -> AppMetadata {
|
||||
let mut meta = match app_id {
|
||||
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => AppMetadata {
|
||||
"bitcoin-core" => AppMetadata {
|
||||
title: "Bitcoin Core".to_string(),
|
||||
description: "Reference Bitcoin node implementation".to_string(),
|
||||
icon: "/assets/img/app-icons/bitcoin-core.svg".to_string(),
|
||||
repo: "https://github.com/bitcoin/bitcoin".to_string(),
|
||||
tier: "",
|
||||
},
|
||||
"bitcoin" | "bitcoin-knots" => AppMetadata {
|
||||
title: "Bitcoin Knots".to_string(),
|
||||
description: "Full Bitcoin node implementation".to_string(),
|
||||
description: "Enhanced Bitcoin node implementation".to_string(),
|
||||
icon: "/assets/img/app-icons/bitcoin-knots.webp".to_string(),
|
||||
repo: "https://github.com/bitcoinknots/bitcoin".to_string(),
|
||||
tier: "",
|
||||
@@ -409,13 +410,6 @@ fn get_app_metadata(app_id: &str) -> AppMetadata {
|
||||
repo: "https://github.com/cryptpad/cryptpad".to_string(),
|
||||
tier: "",
|
||||
},
|
||||
"penpot" | "penpot-frontend" => AppMetadata {
|
||||
title: "Penpot".to_string(),
|
||||
description: "Open-source design and prototyping".to_string(),
|
||||
icon: "/assets/img/app-icons/penpot.webp".to_string(),
|
||||
repo: "https://github.com/penpot/penpot".to_string(),
|
||||
tier: "",
|
||||
},
|
||||
"nextcloud" => AppMetadata {
|
||||
title: "Nextcloud".to_string(),
|
||||
description: "Self-hosted cloud storage and file management".to_string(),
|
||||
@@ -486,6 +480,13 @@ fn get_app_metadata(app_id: &str) -> AppMetadata {
|
||||
repo: "https://github.com/tailscale/tailscale".to_string(),
|
||||
tier: "",
|
||||
},
|
||||
"netbird" => AppMetadata {
|
||||
title: "NetBird".to_string(),
|
||||
description: "Self-hosted WireGuard mesh VPN control plane and dashboard".to_string(),
|
||||
icon: "/assets/img/app-icons/netbird.svg".to_string(),
|
||||
repo: "https://github.com/netbirdio/netbird".to_string(),
|
||||
tier: "",
|
||||
},
|
||||
"indeedhub" | "indeehub" => AppMetadata {
|
||||
title: "IndeedHub".to_string(),
|
||||
description: "Decentralized media streaming platform".to_string(),
|
||||
@@ -493,13 +494,6 @@ fn get_app_metadata(app_id: &str) -> AppMetadata {
|
||||
repo: "https://github.com/indeedhub/indeedhub".to_string(),
|
||||
tier: "",
|
||||
},
|
||||
"nostr-rs-relay" => AppMetadata {
|
||||
title: "Nostr Relay".to_string(),
|
||||
description: "Run your own Nostr relay for sovereign event storage".to_string(),
|
||||
icon: "/assets/img/app-icons/nostr-rs-relay.svg".to_string(),
|
||||
repo: "https://sr.ht/~gheartsfield/nostr-rs-relay/".to_string(),
|
||||
tier: "",
|
||||
},
|
||||
"dwn" => AppMetadata {
|
||||
title: "Decentralized Web Node".to_string(),
|
||||
description: "Store and sync personal data with DID-based access control".to_string(),
|
||||
@@ -571,10 +565,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.).
|
||||
@@ -645,9 +666,70 @@ 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()),
|
||||
"electrumx" | "mempool-electrs" | "electrs" => Some("http://localhost:50002".to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
//! filebrowser config bootstrap helper.
|
||||
//!
|
||||
//! Mirrors the legacy first-boot behavior that writes
|
||||
//! `/var/lib/archipelago/filebrowser-data/.filebrowser.json` before
|
||||
//! starting the container with `--config /data/.filebrowser.json`.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use std::path::PathBuf;
|
||||
use tokio::fs;
|
||||
|
||||
pub const DEFAULT_SRV_ROOT: &str = "/var/lib/archipelago/filebrowser";
|
||||
pub const DEFAULT_DATA_DIR: &str = "/var/lib/archipelago/filebrowser-data";
|
||||
pub const DEFAULT_CONFIG_PATH: &str = "/var/lib/archipelago/filebrowser-data/.filebrowser.json";
|
||||
|
||||
const DEFAULT_CONFIG_JSON: &str =
|
||||
"{\"port\":80,\"baseURL\":\"\",\"address\":\"0.0.0.0\",\"database\":\"/data/filebrowser.db\",\"root\":\"/srv\",\"log\":\"stdout\"}\n";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EnsurePaths {
|
||||
pub srv_root: PathBuf,
|
||||
pub data_dir: PathBuf,
|
||||
pub config_path: PathBuf,
|
||||
}
|
||||
|
||||
impl Default for EnsurePaths {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
srv_root: PathBuf::from(DEFAULT_SRV_ROOT),
|
||||
data_dir: PathBuf::from(DEFAULT_DATA_DIR),
|
||||
config_path: PathBuf::from(DEFAULT_CONFIG_PATH),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum EnsureOutcome {
|
||||
Written,
|
||||
Unchanged,
|
||||
}
|
||||
|
||||
pub async fn ensure_config(paths: &EnsurePaths) -> Result<EnsureOutcome> {
|
||||
fs::create_dir_all(&paths.srv_root)
|
||||
.await
|
||||
.with_context(|| format!("creating {}", paths.srv_root.display()))?;
|
||||
fs::create_dir_all(&paths.data_dir)
|
||||
.await
|
||||
.with_context(|| format!("creating {}", paths.data_dir.display()))?;
|
||||
|
||||
for d in ["Documents", "Photos", "Music", "Downloads", "Builds"] {
|
||||
fs::create_dir_all(paths.srv_root.join(d))
|
||||
.await
|
||||
.with_context(|| format!("creating {}/{}", paths.srv_root.display(), d))?;
|
||||
}
|
||||
|
||||
if paths.config_path.exists() {
|
||||
return Ok(EnsureOutcome::Unchanged);
|
||||
}
|
||||
|
||||
let parent = paths
|
||||
.config_path
|
||||
.parent()
|
||||
.ok_or_else(|| anyhow::anyhow!("config_path has no parent directory"))?;
|
||||
fs::create_dir_all(parent)
|
||||
.await
|
||||
.with_context(|| format!("creating {}", parent.display()))?;
|
||||
|
||||
let tmp = paths.config_path.with_extension("tmp");
|
||||
fs::write(&tmp, DEFAULT_CONFIG_JSON)
|
||||
.await
|
||||
.with_context(|| format!("writing tmp {}", tmp.display()))?;
|
||||
fs::rename(&tmp, &paths.config_path)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"renaming {} -> {}",
|
||||
tmp.display(),
|
||||
paths.config_path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(EnsureOutcome::Written)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn ensure_config_creates_dirs_and_file() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let paths = EnsurePaths {
|
||||
srv_root: tmp.path().join("filebrowser"),
|
||||
data_dir: tmp.path().join("filebrowser-data"),
|
||||
config_path: tmp.path().join("filebrowser-data/.filebrowser.json"),
|
||||
};
|
||||
|
||||
let out = ensure_config(&paths).await.unwrap();
|
||||
assert_eq!(out, EnsureOutcome::Written);
|
||||
assert!(paths.config_path.exists());
|
||||
assert!(paths.srv_root.join("Documents").exists());
|
||||
assert!(paths.srv_root.join("Photos").exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ensure_config_is_idempotent() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let paths = EnsurePaths {
|
||||
srv_root: tmp.path().join("filebrowser"),
|
||||
data_dir: tmp.path().join("filebrowser-data"),
|
||||
config_path: tmp.path().join("filebrowser-data/.filebrowser.json"),
|
||||
};
|
||||
|
||||
let first = ensure_config(&paths).await.unwrap();
|
||||
assert_eq!(first, EnsureOutcome::Written);
|
||||
let second = ensure_config(&paths).await.unwrap();
|
||||
assert_eq!(second, EnsureOutcome::Unchanged);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Parser for image-versions.sh — single source of truth for pinned container images.
|
||||
//!
|
||||
//! Reads the deployed file at /opt/archipelago/image-versions.sh (or the repo-local
|
||||
//! scripts/image-versions.sh as fallback) and exposes lookup functions so the container
|
||||
//! scanner can compare running images against pinned targets.
|
||||
//! Reads the deployed file at /opt/archipelago/scripts/image-versions.sh (the canonical
|
||||
//! location installed by the image-recipe) with fallbacks for older layouts and the
|
||||
//! repo-local scripts/image-versions.sh for development runs from the repo root.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
@@ -18,9 +18,15 @@ struct CacheEntry {
|
||||
images: HashMap<String, String>,
|
||||
}
|
||||
|
||||
/// File search order — production path first, then repo-local for dev.
|
||||
/// File search order — canonical production path first, older layout second,
|
||||
/// repo-local for dev last. The canonical deployed path is
|
||||
/// /opt/archipelago/scripts/image-versions.sh; earlier builds put it directly
|
||||
/// in /opt/archipelago/, so that path is kept as a fallback for not-yet-updated
|
||||
/// nodes. The repo-relative entry matches `cargo run` from the repo root.
|
||||
const PATHS: &[&str] = &[
|
||||
"/opt/archipelago/scripts/image-versions.sh",
|
||||
"/opt/archipelago/image-versions.sh",
|
||||
"/home/archipelago/Projects/archy/scripts/image-versions.sh",
|
||||
"scripts/image-versions.sh",
|
||||
];
|
||||
|
||||
@@ -102,8 +108,11 @@ fn parse_image_versions(content: &str) -> HashMap<String, String> {
|
||||
}
|
||||
}
|
||||
|
||||
// Keep only *_IMAGE entries
|
||||
vars.retain(|k, _| k.ends_with("_IMAGE"));
|
||||
// Keep only *_IMAGE entries whose value looks like a container image
|
||||
// reference (contains a `:` tag separator and at least one `/` path
|
||||
// component). Rejects placeholder values like "something" so a
|
||||
// hand-edit typo in image-versions.sh never gets treated as an image.
|
||||
vars.retain(|k, v| k.ends_with("_IMAGE") && v.contains(':') && v.contains('/'));
|
||||
vars
|
||||
}
|
||||
|
||||
@@ -134,12 +143,15 @@ fn image_var_for_app(app_id: &str) -> Option<&'static str> {
|
||||
"lnd" => Some("LND_IMAGE"),
|
||||
"electrumx" => Some("ELECTRUMX_IMAGE"),
|
||||
"electrs" | "mempool-electrs" => Some("ELECTRUMX_IMAGE"),
|
||||
"bitcoin-ui" | "archy-bitcoin-ui" => Some("BITCOIN_UI_IMAGE"),
|
||||
"lnd-ui" | "archy-lnd-ui" => Some("LND_UI_IMAGE"),
|
||||
"electrs-ui" | "archy-electrs-ui" => Some("ELECTRS_UI_IMAGE"),
|
||||
|
||||
// Mempool stack (primary = web)
|
||||
"mempool" | "mempool-web" => Some("MEMPOOL_WEB_IMAGE"),
|
||||
"mempool" | "mempool-web" | "archy-mempool-web" => Some("MEMPOOL_WEB_IMAGE"),
|
||||
|
||||
// BTCPay stack (primary = server)
|
||||
"btcpay" | "btcpay-server" | "btcpayserver" => Some("BTCPAY_IMAGE"),
|
||||
"btcpay" | "btcpay-server" | "btcpayserver" | "archy-btcpay-ui" => Some("BTCPAY_IMAGE"),
|
||||
|
||||
// Apps
|
||||
"homeassistant" | "home-assistant" => Some("HOMEASSISTANT_IMAGE"),
|
||||
@@ -156,6 +168,8 @@ fn image_var_for_app(app_id: &str) -> Option<&'static str> {
|
||||
"nginx-proxy-manager" => Some("NPM_IMAGE"),
|
||||
"portainer" => Some("PORTAINER_IMAGE"),
|
||||
"tailscale" => Some("TAILSCALE_IMAGE"),
|
||||
"netbird" => Some("NETBIRD_DASHBOARD_IMAGE"),
|
||||
"netbird-server" => Some("NETBIRD_SERVER_IMAGE"),
|
||||
|
||||
// Fedimint
|
||||
"fedimint" | "fedimintd" => Some("FEDIMINT_IMAGE"),
|
||||
@@ -193,6 +207,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.
|
||||
@@ -211,6 +249,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)> {
|
||||
@@ -237,6 +301,10 @@ pub fn containers_for_stack(app_id: &str) -> Vec<(&'static str, &'static str)> {
|
||||
("penpot-exporter", "PENPOT_EXPORTER_IMAGE"),
|
||||
("penpot-frontend", "PENPOT_FRONTEND_IMAGE"),
|
||||
],
|
||||
"netbird" => vec![
|
||||
("netbird", "NETBIRD_DASHBOARD_IMAGE"),
|
||||
("netbird-server", "NETBIRD_SERVER_IMAGE"),
|
||||
],
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
@@ -274,6 +342,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#"
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
//! lnd config bootstrap helper.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use base64::Engine;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use tokio::fs;
|
||||
|
||||
use crate::update::host_sudo;
|
||||
|
||||
pub const DEFAULT_DATA_DIR: &str = "/var/lib/archipelago/lnd";
|
||||
pub const DEFAULT_CONF_PATH: &str = "/var/lib/archipelago/lnd/lnd.conf";
|
||||
const LND_REST_BASE_URL: &str = "https://127.0.0.1:18080";
|
||||
pub const WALLET_PASSWORD: &str = "hellohello";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EnsurePaths {
|
||||
pub data_dir: PathBuf,
|
||||
pub conf_path: PathBuf,
|
||||
}
|
||||
|
||||
impl Default for EnsurePaths {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
data_dir: PathBuf::from(DEFAULT_DATA_DIR),
|
||||
conf_path: PathBuf::from(DEFAULT_CONF_PATH),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum EnsureOutcome {
|
||||
Written,
|
||||
Unchanged,
|
||||
}
|
||||
|
||||
pub async fn ensure_config(paths: &EnsurePaths, rpc_pass: &str) -> Result<EnsureOutcome> {
|
||||
fs::create_dir_all(&paths.data_dir)
|
||||
.await
|
||||
.with_context(|| format!("creating {}", paths.data_dir.display()))?;
|
||||
|
||||
if paths.conf_path.exists() {
|
||||
let existing = fs::read_to_string(&paths.conf_path)
|
||||
.await
|
||||
.with_context(|| format!("reading {}", paths.conf_path.display()))?;
|
||||
if has_required_lnd_flags(&existing, rpc_pass) {
|
||||
return Ok(EnsureOutcome::Unchanged);
|
||||
}
|
||||
}
|
||||
|
||||
let conf = format!(
|
||||
"debuglevel=info\n\
|
||||
maxpendingchannels=10\n\
|
||||
alias=Archipelago Node\n\
|
||||
color=#f7931a\n\
|
||||
listen=0.0.0.0:9735\n\
|
||||
rpclisten=0.0.0.0:10009\n\
|
||||
restlisten=0.0.0.0:8080\n\
|
||||
bitcoin.active=true\n\
|
||||
bitcoin.mainnet=true\n\
|
||||
bitcoin.node=bitcoind\n\
|
||||
bitcoind.rpchost=bitcoin-knots:8332\n\
|
||||
bitcoind.rpcuser=archipelago\n\
|
||||
bitcoind.rpcpass={}\n\
|
||||
bitcoind.rpcpolling=true\n\
|
||||
bitcoind.estimatemode=ECONOMICAL\n",
|
||||
rpc_pass
|
||||
);
|
||||
|
||||
write_config_atomically(paths, &conf).await?;
|
||||
|
||||
Ok(EnsureOutcome::Written)
|
||||
}
|
||||
|
||||
pub async fn ensure_wallet_initialized() -> Result<()> {
|
||||
let admin_macaroon = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
|
||||
let wallet_db = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/wallet.db";
|
||||
if file_exists_as_root(wallet_db).await {
|
||||
if file_exists_as_root(admin_macaroon).await && lnd_getinfo_ready(admin_macaroon).await {
|
||||
return Ok(());
|
||||
}
|
||||
unlock_existing_wallet().await?;
|
||||
wait_for_admin_macaroon(admin_macaroon).await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
init_wallet_via_rest().await?;
|
||||
wait_for_admin_macaroon(admin_macaroon).await
|
||||
}
|
||||
|
||||
async fn file_exists_as_root(path: &str) -> bool {
|
||||
if std::path::Path::new(path).exists() {
|
||||
return true;
|
||||
}
|
||||
tokio::process::Command::new("sudo")
|
||||
.args(["test", "-f", path])
|
||||
.status()
|
||||
.await
|
||||
.map(|status| status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
async fn read_file_as_root(path: &str) -> Result<Vec<u8>> {
|
||||
match fs::read(path).await {
|
||||
Ok(bytes) => Ok(bytes),
|
||||
Err(direct_err) => {
|
||||
let out = tokio::process::Command::new("sudo")
|
||||
.args(["cat", path])
|
||||
.output()
|
||||
.await
|
||||
.with_context(|| format!("reading {path} via sudo"))?;
|
||||
if out.status.success() {
|
||||
Ok(out.stdout)
|
||||
} else {
|
||||
anyhow::bail!(
|
||||
"reading {path} failed (direct: {direct_err}; sudo: {})",
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn unlock_existing_wallet() -> Result<()> {
|
||||
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");
|
||||
cmd.args(["exec", "-i", "lnd", "lncli", "unlock", "--stdin"]);
|
||||
cmd.stdin(std::process::Stdio::piped());
|
||||
cmd.stdout(std::process::Stdio::piped());
|
||||
cmd.stderr(std::process::Stdio::piped());
|
||||
|
||||
let mut child = cmd.spawn().context("spawning lncli wallet unlock")?;
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
stdin
|
||||
.write_all(format!("{}\n", WALLET_PASSWORD).as_bytes())
|
||||
.await
|
||||
.context("writing lncli password")?;
|
||||
}
|
||||
let out = child
|
||||
.wait_with_output()
|
||||
.await
|
||||
.context("waiting for lncli")?;
|
||||
if out.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let msg = format!("{stderr}{stdout}");
|
||||
if msg.contains("wallet already unlocked") || msg.contains("already unlocked") {
|
||||
return Ok(());
|
||||
}
|
||||
last_err = Some(msg);
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
}
|
||||
anyhow::bail!(
|
||||
"lncli wallet unlock failed: {}",
|
||||
last_err.unwrap_or_else(|| "unknown error".to_string())
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GenSeedResponse {
|
||||
cipher_seed_mnemonic: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum UnlockerResponse<T> {
|
||||
Value(T),
|
||||
WalletAlreadyExists,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct InitWalletRequest {
|
||||
wallet_password: String,
|
||||
cipher_seed_mnemonic: Vec<String>,
|
||||
}
|
||||
|
||||
async fn init_wallet_via_rest() -> Result<()> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(20))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.context("building LND REST client")?;
|
||||
|
||||
let seed: GenSeedResponse = match get_lnd_unlocker_json(&client, "/v1/genseed")
|
||||
.await
|
||||
.context("generating LND wallet seed")?
|
||||
{
|
||||
UnlockerResponse::Value(seed) => seed,
|
||||
UnlockerResponse::WalletAlreadyExists => {
|
||||
unlock_existing_wallet().await?;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
if seed.cipher_seed_mnemonic.is_empty() {
|
||||
anyhow::bail!("LND genseed returned no seed words");
|
||||
}
|
||||
|
||||
let wallet_password = base64::engine::general_purpose::STANDARD.encode(WALLET_PASSWORD);
|
||||
let req = InitWalletRequest {
|
||||
wallet_password,
|
||||
cipher_seed_mnemonic: seed.cipher_seed_mnemonic,
|
||||
};
|
||||
match post_lnd_unlocker_json::<serde_json::Value>(
|
||||
&client,
|
||||
"/v1/initwallet",
|
||||
serde_json::to_value(req)?,
|
||||
)
|
||||
.await
|
||||
.context("initializing LND wallet")?
|
||||
{
|
||||
UnlockerResponse::Value(_) => {}
|
||||
UnlockerResponse::WalletAlreadyExists => unlock_existing_wallet().await?,
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_lnd_unlocker_json<T: for<'de> Deserialize<'de>>(
|
||||
client: &reqwest::Client,
|
||||
path: &str,
|
||||
) -> Result<UnlockerResponse<T>> {
|
||||
let url = format!("{LND_REST_BASE_URL}{path}");
|
||||
let mut last_err = None;
|
||||
for _ in 0..60 {
|
||||
match client.get(&url).send().await {
|
||||
Ok(resp) => match decode_lnd_unlocker_response(resp, path).await {
|
||||
Ok(value) => return Ok(value),
|
||||
Err(e) => last_err = Some(e.to_string()),
|
||||
},
|
||||
Err(e) => last_err = Some(e.to_string()),
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
}
|
||||
anyhow::bail!(
|
||||
"LND REST {path} unavailable: {}",
|
||||
last_err.unwrap_or_else(|| "unknown error".to_string())
|
||||
)
|
||||
}
|
||||
|
||||
async fn post_lnd_unlocker_json<T: for<'de> Deserialize<'de>>(
|
||||
client: &reqwest::Client,
|
||||
path: &str,
|
||||
body: serde_json::Value,
|
||||
) -> Result<UnlockerResponse<T>> {
|
||||
let url = format!("{LND_REST_BASE_URL}{path}");
|
||||
let mut last_err = None;
|
||||
for _ in 0..60 {
|
||||
match client.post(&url).json(&body).send().await {
|
||||
Ok(resp) => match decode_lnd_unlocker_response(resp, path).await {
|
||||
Ok(value) => return Ok(value),
|
||||
Err(e) => last_err = Some(e.to_string()),
|
||||
},
|
||||
Err(e) => last_err = Some(e.to_string()),
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
}
|
||||
anyhow::bail!(
|
||||
"LND REST {path} unavailable: {}",
|
||||
last_err.unwrap_or_else(|| "unknown error".to_string())
|
||||
)
|
||||
}
|
||||
|
||||
async fn decode_lnd_unlocker_response<T: for<'de> Deserialize<'de>>(
|
||||
resp: reqwest::Response,
|
||||
path: &str,
|
||||
) -> Result<UnlockerResponse<T>> {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
if status.is_success() {
|
||||
let value = serde_json::from_str(&text)
|
||||
.with_context(|| format!("parsing LND REST response from {path}"))?;
|
||||
return Ok(UnlockerResponse::Value(value));
|
||||
}
|
||||
if text.contains("wallet already exists") {
|
||||
return Ok(UnlockerResponse::WalletAlreadyExists);
|
||||
}
|
||||
anyhow::bail!("LND REST {path} returned {status}: {text}")
|
||||
}
|
||||
|
||||
async fn lnd_getinfo_ready(admin_macaroon: &str) -> bool {
|
||||
let Ok(macaroon) = read_file_as_root(admin_macaroon).await else {
|
||||
return false;
|
||||
};
|
||||
let Ok(client) = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/getinfo"))
|
||||
.header("Grpc-Metadata-macaroon", hex::encode(macaroon))
|
||||
.send()
|
||||
.await
|
||||
.map(|resp| resp.status().is_success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
async fn wait_for_admin_macaroon(admin_macaroon: &str) -> Result<()> {
|
||||
for _ in 0..60 {
|
||||
if file_exists_as_root(admin_macaroon).await {
|
||||
return Ok(());
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
}
|
||||
anyhow::bail!("LND admin macaroon not created after wallet init")
|
||||
}
|
||||
|
||||
async fn write_config_atomically(paths: &EnsurePaths, conf: &str) -> Result<()> {
|
||||
let tmp = paths.conf_path.with_extension("tmp");
|
||||
match fs::write(&tmp, conf).await {
|
||||
Ok(()) => {
|
||||
fs::rename(&tmp, &paths.conf_path).await.with_context(|| {
|
||||
format!(
|
||||
"renaming {} -> {}",
|
||||
tmp.display(),
|
||||
paths.conf_path.display()
|
||||
)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
|
||||
let script = format!(
|
||||
"set -eu\ncat > '{}' <<'LNDCONF'\n{}LNDCONF\n",
|
||||
shell_quote(&paths.conf_path.to_string_lossy()),
|
||||
conf
|
||||
);
|
||||
let status = host_sudo(&["sh", "-lc", &script])
|
||||
.await
|
||||
.context("writing lnd.conf via sudo")?;
|
||||
if !status.success() {
|
||||
anyhow::bail!("writing lnd.conf via sudo exited with {status}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e).with_context(|| format!("writing tmp {}", tmp.display())),
|
||||
}
|
||||
}
|
||||
|
||||
fn shell_quote(s: &str) -> String {
|
||||
s.replace('\'', "'\\''")
|
||||
}
|
||||
|
||||
fn has_required_lnd_flags(conf: &str, 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))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn ensure_config_writes_required_bitcoin_network_flags() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let paths = EnsurePaths {
|
||||
data_dir: tmp.path().join("lnd"),
|
||||
conf_path: tmp.path().join("lnd/lnd.conf"),
|
||||
};
|
||||
|
||||
let out = ensure_config(&paths, "secret").await.unwrap();
|
||||
assert_eq!(out, EnsureOutcome::Written);
|
||||
let conf = fs::read_to_string(&paths.conf_path).await.unwrap();
|
||||
assert!(conf.contains("bitcoin.active=true"));
|
||||
assert!(conf.contains("bitcoin.mainnet=true"));
|
||||
assert!(conf.contains("bitcoin.node=bitcoind"));
|
||||
assert!(conf.contains("bitcoind.rpchost=bitcoin-knots:8332"));
|
||||
assert!(conf.contains("bitcoind.rpcpass=secret"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ensure_config_repairs_rpc_password_drift() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let paths = EnsurePaths {
|
||||
data_dir: tmp.path().join("lnd"),
|
||||
conf_path: tmp.path().join("lnd/lnd.conf"),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
ensure_config(&paths, "first").await.unwrap(),
|
||||
EnsureOutcome::Written
|
||||
);
|
||||
assert_eq!(
|
||||
ensure_config(&paths, "second").await.unwrap(),
|
||||
EnsureOutcome::Written
|
||||
);
|
||||
let conf = fs::read_to_string(&paths.conf_path).await.unwrap();
|
||||
assert!(conf.contains("bitcoind.rpcpass=second"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ensure_config_repairs_incomplete_existing_config() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let paths = EnsurePaths {
|
||||
data_dir: tmp.path().join("lnd"),
|
||||
conf_path: tmp.path().join("lnd/lnd.conf"),
|
||||
};
|
||||
fs::create_dir_all(&paths.data_dir).await.unwrap();
|
||||
fs::write(&paths.conf_path, "debuglevel=info\n")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
ensure_config(&paths, "repaired").await.unwrap(),
|
||||
EnsureOutcome::Written
|
||||
);
|
||||
let conf = fs::read_to_string(&paths.conf_path).await.unwrap();
|
||||
assert!(conf.contains("bitcoin.mainnet=true"));
|
||||
assert!(conf.contains("bitcoind.rpcpass=repaired"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wallet_password_is_valid_for_lncli() {
|
||||
assert!(WALLET_PASSWORD.len() > 8);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,19 @@
|
||||
pub mod bitcoin_ui;
|
||||
pub mod boot_reconciler;
|
||||
pub mod companion;
|
||||
pub mod data_manager;
|
||||
pub mod dev_orchestrator;
|
||||
pub mod docker_packages;
|
||||
pub mod filebrowser;
|
||||
pub mod image_versions;
|
||||
pub mod lnd;
|
||||
pub mod prod_orchestrator;
|
||||
pub mod quadlet;
|
||||
pub mod registry;
|
||||
pub mod traits;
|
||||
|
||||
pub use boot_reconciler::{BootReconciler, DEFAULT_INTERVAL as RECONCILER_DEFAULT_INTERVAL};
|
||||
pub use dev_orchestrator::DevContainerOrchestrator;
|
||||
pub use docker_packages::DockerPackageScanner;
|
||||
pub use prod_orchestrator::ProdContainerOrchestrator;
|
||||
pub use traits::ContainerOrchestrator;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -8,14 +8,15 @@ use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use tokio::fs;
|
||||
use tracing::{debug, info};
|
||||
|
||||
const REGISTRY_FILE: &str = "config/registries.json";
|
||||
const OVH_REGISTRY_URL: &str = "146.59.87.168:3000/lfg2025";
|
||||
const TX1138_REGISTRY_URL: &str = "git.tx1138.com/lfg2025";
|
||||
|
||||
/// A single container registry.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Registry {
|
||||
/// Registry URL (e.g., "git.tx1138.com/lfg2025" or "23.182.128.160:3000/lfg2025").
|
||||
/// Registry URL (e.g., "git.tx1138.com/lfg2025" or "146.59.87.168:3000/lfg2025").
|
||||
pub url: String,
|
||||
/// Human-readable name.
|
||||
pub name: String,
|
||||
@@ -44,26 +45,19 @@ impl Default for RegistryConfig {
|
||||
Self {
|
||||
registries: vec![
|
||||
Registry {
|
||||
url: "23.182.128.160:3000/lfg2025".to_string(),
|
||||
name: "Server 1 (VPS)".to_string(),
|
||||
url: OVH_REGISTRY_URL.to_string(),
|
||||
name: "Server 1 (OVH)".to_string(),
|
||||
tls_verify: false,
|
||||
enabled: true,
|
||||
priority: 0,
|
||||
},
|
||||
Registry {
|
||||
url: "git.tx1138.com/lfg2025".to_string(),
|
||||
url: TX1138_REGISTRY_URL.to_string(),
|
||||
name: "Server 2 (tx1138)".to_string(),
|
||||
tls_verify: true,
|
||||
enabled: true,
|
||||
priority: 10,
|
||||
},
|
||||
Registry {
|
||||
url: "146.59.87.168:3000/lfg2025".to_string(),
|
||||
name: "Server 3 (OVH)".to_string(),
|
||||
tls_verify: false,
|
||||
enabled: true,
|
||||
priority: 20,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -78,15 +72,14 @@ impl RegistryConfig {
|
||||
}
|
||||
|
||||
/// Rewrite an image reference to use a specific registry.
|
||||
/// E.g., "git.tx1138.com/lfg2025/bitcoin-knots:latest" with registry "23.182.128.160:3000/lfg2025"
|
||||
/// becomes "23.182.128.160:3000/lfg2025/bitcoin-knots:latest".
|
||||
/// E.g., "git.tx1138.com/lfg2025/bitcoin-knots:latest" with registry "146.59.87.168:3000/lfg2025"
|
||||
/// becomes "146.59.87.168:3000/lfg2025/bitcoin-knots:latest".
|
||||
pub fn rewrite_image(&self, image: &str, registry: &Registry) -> String {
|
||||
// Extract the image name (last component after the org/namespace)
|
||||
// Handles: "registry/org/image:tag" -> "image:tag"
|
||||
let image_name = extract_image_name(image);
|
||||
format!("{}/{}", registry.url, image_name)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// Extract the image name from a full image reference.
|
||||
@@ -114,6 +107,19 @@ pub async fn load_registries(data_dir: &Path) -> Result<RegistryConfig> {
|
||||
let mut config: RegistryConfig =
|
||||
serde_json::from_str(&content).unwrap_or_else(|_| RegistryConfig::default());
|
||||
|
||||
// One-time migration: the Hetzner VPS at 23.182.128.160 was
|
||||
// decommissioned 2026-04-23. Existing nodes have it baked into
|
||||
// their saved registry list (was the original Server 1). Strip it
|
||||
// on load so every container pull doesn't pay a connection-refused
|
||||
// timeout against a dead host. Exception to the usual "explicit
|
||||
// removals stick" rule: the user never chose to add this — it
|
||||
// was a default.
|
||||
let before = config.registries.len();
|
||||
config
|
||||
.registries
|
||||
.retain(|r| !r.url.contains("23.182.128.160"));
|
||||
let mut changed = config.registries.len() != before;
|
||||
|
||||
// Migrate: any default registry URL that isn't already in the
|
||||
// saved list gets appended at the end (so existing priority order
|
||||
// is preserved for anything the operator already configured).
|
||||
@@ -126,22 +132,65 @@ pub async fn load_registries(data_dir: &Path) -> Result<RegistryConfig> {
|
||||
.map(|r| r.priority)
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
let mut added = false;
|
||||
for (i, def) in defaults.registries.iter().enumerate() {
|
||||
if !known.contains(&def.url) {
|
||||
let mut cloned = def.clone();
|
||||
cloned.priority = max_priority.saturating_add(10 + i as u32);
|
||||
config.registries.push(cloned);
|
||||
added = true;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if added {
|
||||
let before_order: Vec<(String, bool, u32)> = config
|
||||
.registries
|
||||
.iter()
|
||||
.map(|r| (r.url.clone(), r.enabled, r.priority))
|
||||
.collect();
|
||||
force_ovh_registry_primary(&mut config);
|
||||
changed = changed
|
||||
|| before_order
|
||||
!= config
|
||||
.registries
|
||||
.iter()
|
||||
.map(|r| (r.url.clone(), r.enabled, r.priority))
|
||||
.collect::<Vec<_>>();
|
||||
if changed {
|
||||
// Persist so the next load doesn't have to re-merge.
|
||||
let _ = save_registries(data_dir, &config).await;
|
||||
}
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn force_ovh_registry_primary(config: &mut RegistryConfig) {
|
||||
let defaults = RegistryConfig::default();
|
||||
for def in defaults.registries {
|
||||
if !config.registries.iter().any(|r| r.url == def.url) {
|
||||
config.registries.push(def);
|
||||
}
|
||||
}
|
||||
|
||||
for registry in config.registries.iter_mut() {
|
||||
match registry.url.as_str() {
|
||||
OVH_REGISTRY_URL => {
|
||||
registry.name = "Server 1 (OVH)".to_string();
|
||||
registry.tls_verify = false;
|
||||
registry.enabled = true;
|
||||
registry.priority = 0;
|
||||
}
|
||||
TX1138_REGISTRY_URL => {
|
||||
registry.name = "Server 2 (tx1138)".to_string();
|
||||
registry.tls_verify = true;
|
||||
registry.enabled = true;
|
||||
registry.priority = 10;
|
||||
}
|
||||
_ => {
|
||||
if registry.priority <= 10 {
|
||||
registry.priority = registry.priority.saturating_add(20);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Save registry config to disk.
|
||||
pub async fn save_registries(data_dir: &Path, config: &RegistryConfig) -> Result<()> {
|
||||
let dir = data_dir.join("config");
|
||||
@@ -178,12 +227,12 @@ mod tests {
|
||||
#[test]
|
||||
fn test_rewrite_image() {
|
||||
let config = RegistryConfig::default();
|
||||
// Default primary is now VPS (index 0). A tx1138-hardcoded image
|
||||
// rewrites to VPS when asked for the primary mirror.
|
||||
// Default primary is now the OVH VPS (index 0). A tx1138-hardcoded
|
||||
// image rewrites to OVH when asked for the primary mirror.
|
||||
let primary = &config.registries[0];
|
||||
assert_eq!(
|
||||
config.rewrite_image("git.tx1138.com/lfg2025/bitcoin-knots:latest", primary),
|
||||
"23.182.128.160:3000/lfg2025/bitcoin-knots:latest"
|
||||
"146.59.87.168:3000/lfg2025/bitcoin-knots:latest"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -191,16 +240,15 @@ mod tests {
|
||||
fn test_active_registries_sorted() {
|
||||
let config = RegistryConfig::default();
|
||||
let active = config.active_registries();
|
||||
assert_eq!(active.len(), 3);
|
||||
assert_eq!(active.len(), 2);
|
||||
assert!(active[0].priority <= active[1].priority);
|
||||
assert!(active[1].priority <= active[2].priority);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_load_default() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let config = load_registries(tmp.path()).await.unwrap();
|
||||
assert_eq!(config.registries.len(), 3);
|
||||
assert_eq!(config.registries.len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -216,6 +264,6 @@ mod tests {
|
||||
});
|
||||
save_registries(tmp.path(), &config).await.unwrap();
|
||||
let loaded = load_registries(tmp.path()).await.unwrap();
|
||||
assert_eq!(loaded.registries.len(), 4);
|
||||
assert_eq!(loaded.registries.len(), 3);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
//! Orchestrator trait — the shared surface the RPC layer talks to.
|
||||
//!
|
||||
//! Step 4 of the rust-orchestrator migration. Unifies the container lifecycle
|
||||
//! surface of `DevContainerOrchestrator` and `ProdContainerOrchestrator` so
|
||||
//! `RpcHandler` can hold `Arc<dyn ContainerOrchestrator>` and stop caring
|
||||
//! which mode it is in.
|
||||
//!
|
||||
//! The trait takes `app_id: &str` everywhere (never a manifest path). Dev and
|
||||
//! Prod both resolve app_id → manifest internally. The legacy
|
||||
//! `container-install { manifest_path }` RPC shape is preserved as a concrete
|
||||
//! `install_container_from_path` method on `DevContainerOrchestrator` only,
|
||||
//! since that ad-hoc workflow is a dev convenience and has no prod meaning.
|
||||
//!
|
||||
//! See `docs/rust-orchestrator-migration.md`.
|
||||
|
||||
use anyhow::Result;
|
||||
use archipelago_container::ContainerStatus;
|
||||
use async_trait::async_trait;
|
||||
|
||||
/// Lifecycle + query operations every orchestrator exposes to the RPC layer.
|
||||
#[async_trait]
|
||||
pub trait ContainerOrchestrator: Send + Sync {
|
||||
/// Build-or-pull the image, create the container, and start it. Returns the
|
||||
/// podman container name that was created. Assumes the app_id corresponds
|
||||
/// to a manifest the orchestrator already knows about.
|
||||
async fn install(&self, app_id: &str) -> Result<String>;
|
||||
|
||||
/// Start an already-created container.
|
||||
async fn start(&self, app_id: &str) -> Result<()>;
|
||||
|
||||
/// Stop a running container. No-op on Prod if already stopped.
|
||||
async fn stop(&self, app_id: &str) -> Result<()>;
|
||||
|
||||
/// Stop-then-start. Best-effort: ignores stop failure.
|
||||
async fn restart(&self, app_id: &str) -> Result<()>;
|
||||
|
||||
/// Remove the container. `preserve_data = true` keeps the volumes; `false`
|
||||
/// is honored on a best-effort basis (Dev cleans, Prod leaves the volume
|
||||
/// management to the data layer).
|
||||
async fn remove(&self, app_id: &str, preserve_data: bool) -> Result<()>;
|
||||
|
||||
/// Pull/rebuild the image and recreate the container from scratch.
|
||||
async fn upgrade(&self, app_id: &str) -> Result<()>;
|
||||
|
||||
/// Current state of a single container.
|
||||
async fn status(&self, app_id: &str) -> Result<ContainerStatus>;
|
||||
|
||||
/// All containers this orchestrator knows about.
|
||||
async fn list(&self) -> Result<Vec<ContainerStatus>>;
|
||||
|
||||
/// Tail the container's stdout+stderr.
|
||||
async fn logs(&self, app_id: &str, lines: u32) -> Result<Vec<String>>;
|
||||
|
||||
/// Coarse health summary: "healthy", "unhealthy", "starting", "paused", "unknown".
|
||||
async fn health(&self, app_id: &str) -> Result<String>;
|
||||
}
|
||||
@@ -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() {
|
||||
@@ -412,6 +398,19 @@ pub async fn start_stopped_containers(data_dir: &Path) -> RecoveryReport {
|
||||
};
|
||||
}
|
||||
|
||||
let names: Vec<String> = names
|
||||
.into_iter()
|
||||
.filter(|n| should_auto_start_stopped_container(n))
|
||||
.collect();
|
||||
|
||||
if names.is_empty() {
|
||||
return RecoveryReport {
|
||||
total: 0,
|
||||
recovered: 0,
|
||||
failed: Vec::new(),
|
||||
};
|
||||
}
|
||||
|
||||
// Sort by startup tier: databases first, then core, then dependent services, then apps
|
||||
let mut records: Vec<RunningContainerRecord> = names
|
||||
.iter()
|
||||
@@ -430,6 +429,13 @@ pub async fn start_stopped_containers(data_dir: &Path) -> RecoveryReport {
|
||||
recover_containers(&records).await
|
||||
}
|
||||
|
||||
fn should_auto_start_stopped_container(name: &str) -> bool {
|
||||
// Keep generic boot recovery narrow. The Rust manifest reconciler owns
|
||||
// managed app stacks; starting every exited Podman container here races
|
||||
// it and resurrects legacy/orphan helper containers.
|
||||
matches!(name, "filebrowser" | "nostr-rs-relay")
|
||||
}
|
||||
|
||||
/// Simple tier ordering for boot recovery (mirrors health_monitor tiers).
|
||||
fn container_boot_tier(name: &str) -> u8 {
|
||||
let id = name.strip_prefix("archy-").unwrap_or(name);
|
||||
@@ -461,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 {
|
||||
@@ -603,4 +617,13 @@ mod tests {
|
||||
let result = check_for_crash(tmp.path()).await.unwrap();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generic_boot_recovery_skips_manifest_owned_and_legacy_stacks() {
|
||||
assert!(should_auto_start_stopped_container("filebrowser"));
|
||||
assert!(should_auto_start_stopped_container("nostr-rs-relay"));
|
||||
assert!(!should_auto_start_stopped_container("bitcoin-knots"));
|
||||
assert!(!should_auto_start_stopped_container("lnd"));
|
||||
assert!(!should_auto_start_stopped_container("indeedhub-postgres"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,9 +129,21 @@ pub fn is_revoked(vc: &VerifiableCredential) -> bool {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Create a tempdir with a dummy `identity/node_key` so that the
|
||||
/// credential store's encrypt/decrypt path can derive a key.
|
||||
/// Returns the tempdir guard (drop it to clean up).
|
||||
fn test_dir_with_node_key() -> tempfile::TempDir {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let identity_dir = dir.path().join("identity");
|
||||
std::fs::create_dir_all(&identity_dir).unwrap();
|
||||
// 32 bytes of deterministic test material; never a real key.
|
||||
std::fs::write(identity_dir.join("node_key"), [0xAB; 32]).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_issue_credential_w3c_format() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let dir = test_dir_with_node_key();
|
||||
let vc = issue_credential(
|
||||
dir.path(),
|
||||
"did:key:issuer",
|
||||
@@ -163,7 +175,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_issue_credential_serializes_as_jsonld() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let dir = test_dir_with_node_key();
|
||||
let vc = issue_credential(
|
||||
dir.path(),
|
||||
"did:key:issuer",
|
||||
@@ -185,7 +197,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_save_and_load_roundtrip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let dir = test_dir_with_node_key();
|
||||
issue_credential(
|
||||
dir.path(),
|
||||
"did:key:a",
|
||||
@@ -205,7 +217,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_issue_credential_sign_fn_failure_propagates() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let dir = test_dir_with_node_key();
|
||||
let result = issue_credential(
|
||||
dir.path(),
|
||||
"did:key:issuer",
|
||||
@@ -257,7 +269,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_revoke_credential() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let dir = test_dir_with_node_key();
|
||||
let vc = issue_credential(
|
||||
dir.path(),
|
||||
"did:key:issuer",
|
||||
@@ -288,7 +300,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_revoke_nonexistent_credential_fails() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let dir = test_dir_with_node_key();
|
||||
let result = revoke_credential(dir.path(), "urn:uuid:does-not-exist").await;
|
||||
assert!(result.is_err());
|
||||
assert!(result
|
||||
@@ -299,7 +311,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_credentials_no_filter() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let dir = test_dir_with_node_key();
|
||||
issue_credential(
|
||||
dir.path(),
|
||||
"did:key:a",
|
||||
@@ -329,7 +341,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_credentials_filter_by_did() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let dir = test_dir_with_node_key();
|
||||
issue_credential(
|
||||
dir.path(),
|
||||
"did:key:alice",
|
||||
|
||||
@@ -143,7 +143,11 @@ pub struct PackageDataEntry {
|
||||
/// pipeline so the UI can show real progress instead of a generic
|
||||
/// "Uninstalling…" spinner. Cleared after the package entry is
|
||||
/// removed.
|
||||
#[serde(rename = "uninstall-stage", skip_serializing_if = "Option::is_none", default)]
|
||||
#[serde(
|
||||
rename = "uninstall-stage",
|
||||
skip_serializing_if = "Option::is_none",
|
||||
default
|
||||
)]
|
||||
pub uninstall_stage: Option<String>,
|
||||
/// Pinned image version from image-versions.sh when it differs from running version
|
||||
#[serde(rename = "available-update", skip_serializing_if = "Option::is_none")]
|
||||
@@ -245,6 +249,44 @@ pub enum ServiceStatus {
|
||||
pub struct InstallProgress {
|
||||
pub size: u64,
|
||||
pub downloaded: u64,
|
||||
/// High-level pipeline phase. Preferred by the UI over the byte
|
||||
/// counters (podman pull doesn't emit parseable progress on a piped
|
||||
/// stderr, so `size`/`downloaded` are often 0). Each phase maps to
|
||||
/// a fixed UI percentage and a descriptive label.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub phase: Option<InstallPhase>,
|
||||
/// Optional explicit message — used to surface install failures so
|
||||
/// the UI can keep the app card visible with an error description
|
||||
/// instead of silently removing the entry on fail. UI's PHASE_INFO
|
||||
/// label takes precedence when phase is set.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
/// Phases of the install / update pipeline, surfaced to the UI so users
|
||||
/// see where the pipeline is rather than a stuck 0% bar.
|
||||
///
|
||||
/// Ordered so each variant's index roughly corresponds to pipeline time.
|
||||
/// Serialized as kebab-case: "preparing", "pulling-image", …
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum InstallPhase {
|
||||
/// Validating params, checking deps, writing dynamic configs.
|
||||
Preparing,
|
||||
/// `podman pull` in progress (the longest phase — up to several
|
||||
/// minutes for large images on slow networks).
|
||||
PullingImage,
|
||||
/// Creating data directories, writing app-specific configs
|
||||
/// (bitcoin.conf, lnd.conf, searxng settings.yml, chown).
|
||||
CreatingContainer,
|
||||
/// `podman run` has returned; container is transitioning to running.
|
||||
StartingContainer,
|
||||
/// Post-start loop waiting up to 60s for `State.Status == running`.
|
||||
WaitingHealthy,
|
||||
/// Running post-install hooks (chain init, wallet setup, …).
|
||||
PostInstall,
|
||||
/// Pipeline finished successfully. Terminal phase, UI clears entry.
|
||||
Done,
|
||||
}
|
||||
|
||||
/// WebSocket message sent to clients
|
||||
|
||||
@@ -30,9 +30,11 @@ async fn bitcoin_rpc_auth() -> String {
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ElectrsSyncStatus {
|
||||
pub indexed_height: u64,
|
||||
pub bitcoin_height: u64,
|
||||
pub network_height: u64,
|
||||
pub progress_pct: f64,
|
||||
pub status: String,
|
||||
pub stale: bool,
|
||||
pub error: Option<String>,
|
||||
/// Index data size in human-readable format (e.g. "11.2 GB")
|
||||
pub index_size: Option<String>,
|
||||
@@ -44,9 +46,11 @@ impl Default for ElectrsSyncStatus {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
indexed_height: 0,
|
||||
bitcoin_height: 0,
|
||||
network_height: 0,
|
||||
progress_pct: 0.0,
|
||||
status: "starting".to_string(),
|
||||
stale: false,
|
||||
error: None,
|
||||
index_size: None,
|
||||
tor_onion: None,
|
||||
@@ -64,15 +68,31 @@ fn cache() -> &'static RwLock<ElectrsSyncStatus> {
|
||||
/// Spawn background task that refreshes ElectrumX status every CACHE_REFRESH_SECS.
|
||||
pub fn spawn_status_cache() {
|
||||
tokio::spawn(async {
|
||||
// Initial delay — let services start up before first query
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(CACHE_REFRESH_SECS));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let fresh = fetch_electrs_sync_status().await;
|
||||
let mut fresh = fetch_electrs_sync_status().await;
|
||||
let mut cached = cache().write().await;
|
||||
if fresh.indexed_height == 0
|
||||
&& cached.indexed_height > 0
|
||||
&& matches!(fresh.status.as_str(), "indexing" | "waiting")
|
||||
{
|
||||
fresh.indexed_height = cached.indexed_height;
|
||||
if fresh.network_height == 0 {
|
||||
fresh.network_height = cached.network_height;
|
||||
}
|
||||
if fresh.bitcoin_height == 0 {
|
||||
fresh.bitcoin_height = cached.bitcoin_height;
|
||||
}
|
||||
if fresh.progress_pct <= 0.0 {
|
||||
fresh.progress_pct = cached.progress_pct;
|
||||
}
|
||||
fresh.stale = true;
|
||||
fresh.error = Some(fresh.error.unwrap_or_else(|| {
|
||||
"ElectrumX is reconnecting; showing last known indexed height.".to_string()
|
||||
}));
|
||||
}
|
||||
*cached = fresh;
|
||||
drop(cached);
|
||||
tokio::time::sleep(Duration::from_secs(CACHE_REFRESH_SECS)).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -187,13 +207,69 @@ async fn electrumx_indexed_height() -> Result<u64> {
|
||||
Ok(height)
|
||||
}
|
||||
|
||||
/// Fetch Bitcoin network height via JSON-RPC.
|
||||
async fn bitcoin_network_height() -> Result<u64> {
|
||||
fn parse_electrumx_height_from_logs(logs: &str) -> Option<u64> {
|
||||
let mut height = None;
|
||||
|
||||
for line in logs.lines() {
|
||||
if let Some(idx) = line.find("BlockProcessor:our height:") {
|
||||
let rest = &line[idx + "BlockProcessor:our height:".len()..];
|
||||
if let Some(parsed) = parse_first_u64_token(rest) {
|
||||
height = Some(parsed);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(idx) = line.find("DB:height:") {
|
||||
let rest = &line[idx + "DB:height:".len()..];
|
||||
if let Some(parsed) = parse_first_u64_token(rest) {
|
||||
height = Some(parsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
height
|
||||
}
|
||||
|
||||
fn parse_first_u64_token(input: &str) -> Option<u64> {
|
||||
let token: String = input
|
||||
.trim_start()
|
||||
.chars()
|
||||
.take_while(|c| c.is_ascii_digit() || *c == ',')
|
||||
.filter(|c| *c != ',')
|
||||
.collect();
|
||||
|
||||
if token.is_empty() {
|
||||
None
|
||||
} else {
|
||||
token.parse().ok()
|
||||
}
|
||||
}
|
||||
|
||||
async fn electrumx_log_indexed_height() -> Result<u64> {
|
||||
let output = tokio::process::Command::new("podman")
|
||||
.args(["logs", "--tail", "500", "electrumx"])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to read ElectrumX logs")?;
|
||||
|
||||
if !output.status.success() {
|
||||
anyhow::bail!(
|
||||
"podman logs electrumx failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
);
|
||||
}
|
||||
|
||||
let logs = String::from_utf8_lossy(&output.stdout);
|
||||
parse_electrumx_height_from_logs(&logs).context("No ElectrumX indexed height in logs")
|
||||
}
|
||||
|
||||
/// Fetch Bitcoin local block height and best-known network header height via JSON-RPC.
|
||||
async fn bitcoin_chain_heights() -> Result<(u64, u64)> {
|
||||
let client = reqwest::Client::new();
|
||||
let body = serde_json::json!({
|
||||
"jsonrpc": "1.0",
|
||||
"id": "electrs-status",
|
||||
"method": "getblockcount",
|
||||
"method": "getblockchaininfo",
|
||||
"params": []
|
||||
});
|
||||
let resp = client
|
||||
@@ -211,11 +287,18 @@ async fn bitcoin_network_height() -> Result<u64> {
|
||||
}
|
||||
|
||||
let json: serde_json::Value = resp.json().await?;
|
||||
let height = json
|
||||
let result = json
|
||||
.get("result")
|
||||
.and_then(|r| r.as_u64())
|
||||
.context("Missing result in Bitcoin RPC")?;
|
||||
Ok(height)
|
||||
let blocks = result
|
||||
.get("blocks")
|
||||
.and_then(|h| h.as_u64())
|
||||
.context("Missing blocks in Bitcoin RPC")?;
|
||||
let headers = result
|
||||
.get("headers")
|
||||
.and_then(|h| h.as_u64())
|
||||
.unwrap_or(blocks);
|
||||
Ok((blocks, headers.max(blocks)))
|
||||
}
|
||||
|
||||
/// Fetch fresh ElectrumX sync status (called by background cache task).
|
||||
@@ -260,15 +343,22 @@ async fn fetch_electrs_sync_status() -> ElectrsSyncStatus {
|
||||
onion
|
||||
};
|
||||
|
||||
let network_height = match bitcoin_network_height().await {
|
||||
Ok(h) => h,
|
||||
let (bitcoin_blocks, network_height) = match bitcoin_chain_heights().await {
|
||||
Ok(heights) => heights,
|
||||
Err(e) => {
|
||||
warn!("ElectrumX status: Bitcoin RPC failed: {}", e);
|
||||
let err_msg = e.to_string();
|
||||
if is_transient_error(&err_msg) {
|
||||
tracing::debug!("ElectrumX status: Bitcoin RPC transient: {}", err_msg);
|
||||
} else {
|
||||
warn!("ElectrumX status: Bitcoin RPC failed: {}", err_msg);
|
||||
}
|
||||
return ElectrsSyncStatus {
|
||||
indexed_height: 0,
|
||||
bitcoin_height: 0,
|
||||
network_height: 0,
|
||||
progress_pct: 0.0,
|
||||
status: "waiting".to_string(),
|
||||
stale: false,
|
||||
error: Some("Waiting for Bitcoin node...".to_string()),
|
||||
index_size,
|
||||
tor_onion,
|
||||
@@ -278,21 +368,25 @@ async fn fetch_electrs_sync_status() -> ElectrsSyncStatus {
|
||||
|
||||
let indexed_height = match electrumx_indexed_height().await {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
let err_msg = e.to_string();
|
||||
if is_transient_error(&err_msg) {
|
||||
// ElectrumX is starting up or busy — estimate from data size
|
||||
let progress_pct = if data_bytes > 0 {
|
||||
((data_bytes as f64 / ESTIMATED_FULL_INDEX_BYTES) * 100.0).min(99.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let size_str = index_size.clone().unwrap_or_else(|| "0 MB".to_string());
|
||||
return ElectrsSyncStatus {
|
||||
Err(e) => match electrumx_log_indexed_height().await {
|
||||
Ok(h) if h > 0 => h,
|
||||
_ => {
|
||||
let err_msg = e.to_string();
|
||||
if is_transient_error(&err_msg) {
|
||||
// ElectrumX is starting up or busy — estimate from data size
|
||||
let progress_pct = if data_bytes > 0 {
|
||||
((data_bytes as f64 / ESTIMATED_FULL_INDEX_BYTES) * 100.0).min(99.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let size_str = index_size.clone().unwrap_or_else(|| "0 MB".to_string());
|
||||
return ElectrsSyncStatus {
|
||||
indexed_height: 0,
|
||||
bitcoin_height: bitcoin_blocks,
|
||||
network_height,
|
||||
progress_pct,
|
||||
status: "indexing".to_string(),
|
||||
stale: false,
|
||||
error: Some(format!(
|
||||
"Building index ({} / ~130 GB estimated). Electrum RPC will be available when complete.",
|
||||
size_str
|
||||
@@ -300,40 +394,90 @@ async fn fetch_electrs_sync_status() -> ElectrsSyncStatus {
|
||||
index_size,
|
||||
tor_onion,
|
||||
};
|
||||
}
|
||||
// Genuine unexpected error
|
||||
warn!("ElectrumX status: unexpected error: {}", err_msg);
|
||||
return ElectrsSyncStatus {
|
||||
indexed_height: 0,
|
||||
bitcoin_height: bitcoin_blocks,
|
||||
network_height,
|
||||
progress_pct: 0.0,
|
||||
status: "error".to_string(),
|
||||
stale: false,
|
||||
error: Some(format!("ElectrumX: {}", err_msg)),
|
||||
index_size,
|
||||
tor_onion,
|
||||
};
|
||||
}
|
||||
// Genuine unexpected error
|
||||
warn!("ElectrumX status: unexpected error: {}", err_msg);
|
||||
return ElectrsSyncStatus {
|
||||
indexed_height: 0,
|
||||
network_height,
|
||||
progress_pct: 0.0,
|
||||
status: "error".to_string(),
|
||||
error: Some(format!("ElectrumX: {}", err_msg)),
|
||||
index_size,
|
||||
tor_onion,
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
let progress_pct = if network_height > 0 {
|
||||
(indexed_height as f64 / network_height as f64) * 100.0
|
||||
let observed_header_height = network_height.max(indexed_height);
|
||||
let bitcoin_catching_up = bitcoin_blocks > 0 && bitcoin_blocks < observed_header_height;
|
||||
let electrum_waiting_on_bitcoin =
|
||||
bitcoin_catching_up && indexed_height >= bitcoin_blocks.saturating_sub(1);
|
||||
let sync_target_height = if bitcoin_blocks > 0 {
|
||||
bitcoin_blocks
|
||||
} else {
|
||||
observed_header_height
|
||||
};
|
||||
|
||||
let progress_pct = if electrum_waiting_on_bitcoin && observed_header_height > 0 {
|
||||
((bitcoin_blocks as f64 / observed_header_height as f64) * 100.0).min(99.9)
|
||||
} else if sync_target_height > 0 {
|
||||
((indexed_height as f64 / sync_target_height as f64) * 100.0).min(100.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let status = if indexed_height >= network_height.saturating_sub(1) {
|
||||
let status = if sync_target_height == 0 {
|
||||
"waiting"
|
||||
} else if electrum_waiting_on_bitcoin {
|
||||
"waiting"
|
||||
} else if indexed_height >= sync_target_height.saturating_sub(1) {
|
||||
"synced"
|
||||
} else {
|
||||
"syncing"
|
||||
};
|
||||
|
||||
let error = if electrum_waiting_on_bitcoin {
|
||||
Some(format!(
|
||||
"ElectrumX is indexed to {:}; waiting for the local Bitcoin node to catch up from {:} to known header {:}.",
|
||||
indexed_height, bitcoin_blocks, observed_header_height
|
||||
))
|
||||
} else if status == "syncing" && bitcoin_blocks < observed_header_height {
|
||||
Some(format!(
|
||||
"Indexing local Bitcoin node height {:} of {:}. Bitcoin node is still catching up to known header {:}.",
|
||||
indexed_height, bitcoin_blocks, observed_header_height
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
ElectrsSyncStatus {
|
||||
indexed_height,
|
||||
network_height,
|
||||
bitcoin_height: bitcoin_blocks,
|
||||
network_height: observed_header_height,
|
||||
progress_pct,
|
||||
status: status.to_string(),
|
||||
error: None,
|
||||
stale: false,
|
||||
error,
|
||||
index_size,
|
||||
tor_onion,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::parse_electrumx_height_from_logs;
|
||||
|
||||
#[test]
|
||||
fn parses_latest_electrumx_progress_height_from_logs() {
|
||||
let logs = r#"
|
||||
INFO:DB:height: 228,238
|
||||
INFO:BlockProcessor:our height: 228,248 daemon: 731,568 UTXOs 1MB hist 1MB
|
||||
INFO:BlockProcessor:our height: 232,117 daemon: 732,108 UTXOs 281MB hist 83MB
|
||||
"#;
|
||||
assert_eq!(parse_electrumx_height_from_logs(logs), Some(232_117));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,8 +80,7 @@ pub async fn record_peer_transport(
|
||||
let mut modified = false;
|
||||
for node in nodes.iter_mut() {
|
||||
let did_match = did.is_some_and(|d| d == node.did);
|
||||
let onion_match = onion_target
|
||||
.is_some_and(|t| node.onion.trim_end_matches(".onion") == t);
|
||||
let onion_match = onion_target.is_some_and(|t| node.onion.trim_end_matches(".onion") == t);
|
||||
if did_match || onion_match {
|
||||
node.last_transport = Some(transport.to_string());
|
||||
node.last_transport_at = Some(now.clone());
|
||||
@@ -182,9 +181,7 @@ pub async fn update_node_state(data_dir: &Path, did: &str, state: NodeStateSnaps
|
||||
// routing over FIPS on the very next sync. Refresh if the peer
|
||||
// rotated their FIPS key, too.
|
||||
if let Some(ref npub) = state.own_fips_npub {
|
||||
if !npub.is_empty()
|
||||
&& node.fips_npub.as_deref().map(str::trim) != Some(npub.trim())
|
||||
{
|
||||
if !npub.is_empty() && node.fips_npub.as_deref().map(str::trim) != Some(npub.trim()) {
|
||||
node.fips_npub = Some(npub.clone());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,7 @@ use anyhow::{Context, Result};
|
||||
use std::path::Path;
|
||||
|
||||
use super::storage::update_node_state;
|
||||
use super::types::{
|
||||
AppStatus, FederatedNode, FederationPeerHint, NodeStateSnapshot, TrustLevel,
|
||||
};
|
||||
use super::types::{AppStatus, FederatedNode, FederationPeerHint, NodeStateSnapshot, TrustLevel};
|
||||
use crate::fips::dial::PeerRequest;
|
||||
|
||||
/// Sync state with a single federated peer. Tries FIPS first; falls back
|
||||
@@ -68,9 +66,7 @@ pub async fn sync_with_peer(
|
||||
// hop. Only runs when the source is Trusted — Observer-level peers
|
||||
// don't get to expand our federation on their own authority.
|
||||
if peer.trust_level == TrustLevel::Trusted {
|
||||
if let Err(e) =
|
||||
merge_transitive_peers(data_dir, &peer.did, &state.federated_peers).await
|
||||
{
|
||||
if let Err(e) = merge_transitive_peers(data_dir, &peer.did, &state.federated_peers).await {
|
||||
tracing::warn!(
|
||||
peer_did = %peer.did,
|
||||
error = %e,
|
||||
@@ -87,10 +83,7 @@ pub async fn sync_with_peer(
|
||||
/// call sync_with_peer. Used by transitive-discovery code paths where
|
||||
/// the caller only knows the peer's DID (e.g. the peer-joined RPC's
|
||||
/// follow-up task).
|
||||
pub async fn sync_with_peer_by_did(
|
||||
data_dir: &Path,
|
||||
peer_did: &str,
|
||||
) -> Result<NodeStateSnapshot> {
|
||||
pub async fn sync_with_peer_by_did(data_dir: &Path, peer_did: &str) -> Result<NodeStateSnapshot> {
|
||||
let nodes = super::storage::load_nodes(data_dir).await?;
|
||||
let peer = nodes
|
||||
.into_iter()
|
||||
@@ -98,8 +91,7 @@ pub async fn sync_with_peer_by_did(
|
||||
.ok_or_else(|| anyhow::anyhow!("Unknown federation peer: {}", peer_did))?;
|
||||
|
||||
let identity_dir = data_dir.join("identity");
|
||||
let node_identity =
|
||||
crate::identity::NodeIdentity::load_or_create(&identity_dir).await?;
|
||||
let node_identity = crate::identity::NodeIdentity::load_or_create(&identity_dir).await?;
|
||||
let local_pubkey_hex = node_identity.pubkey_hex();
|
||||
let local_did = crate::identity::did_key_from_pubkey_hex(&local_pubkey_hex)?;
|
||||
|
||||
@@ -258,7 +250,11 @@ pub async fn deploy_to_peer(
|
||||
.context("Failed to reach federated peer for deploy")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
anyhow::bail!("Remote node returned HTTP {} (via {})", resp.status(), transport);
|
||||
anyhow::bail!(
|
||||
"Remote node returned HTTP {} (via {})",
|
||||
resp.status(),
|
||||
transport
|
||||
);
|
||||
}
|
||||
|
||||
let result: serde_json::Value = resp.json().await.context("Invalid response from peer")?;
|
||||
@@ -355,10 +351,7 @@ mod tests {
|
||||
last_transport_at: None,
|
||||
},
|
||||
];
|
||||
let state = build_local_state(
|
||||
vec![],
|
||||
0.0, 0, 0, 0, 0, 0, true, None, None, None, &peers,
|
||||
);
|
||||
let state = build_local_state(vec![], 0.0, 0, 0, 0, 0, 0, true, None, None, None, &peers);
|
||||
assert_eq!(state.federated_peers.len(), 1);
|
||||
assert_eq!(state.federated_peers[0].did, "did:key:zTrusted");
|
||||
assert_eq!(
|
||||
|
||||
@@ -70,8 +70,8 @@ pub async fn load(data_dir: &Path) -> Result<Vec<SeedAnchor>> {
|
||||
let bytes = tokio::fs::read(&path)
|
||||
.await
|
||||
.with_context(|| format!("read {}", path.display()))?;
|
||||
let anchors: Vec<SeedAnchor> = serde_json::from_slice(&bytes)
|
||||
.with_context(|| format!("parse {}", path.display()))?;
|
||||
let anchors: Vec<SeedAnchor> =
|
||||
serde_json::from_slice(&bytes).with_context(|| format!("parse {}", path.display()))?;
|
||||
Ok(anchors)
|
||||
}
|
||||
|
||||
@@ -125,12 +125,7 @@ pub async fn apply(anchors: &[SeedAnchor]) -> Vec<ApplyResult> {
|
||||
let mut results = Vec::with_capacity(anchors.len());
|
||||
for anchor in anchors {
|
||||
let out = Command::new("fipsctl")
|
||||
.args([
|
||||
"connect",
|
||||
&anchor.npub,
|
||||
&anchor.address,
|
||||
&anchor.transport,
|
||||
])
|
||||
.args(["connect", &anchor.npub, &anchor.address, &anchor.transport])
|
||||
.output()
|
||||
.await;
|
||||
let result = match out {
|
||||
|
||||
@@ -13,7 +13,9 @@ use anyhow::{Context, Result};
|
||||
use std::path::Path;
|
||||
use tokio::process::Command;
|
||||
|
||||
use super::{DAEMON_CONFIG_PATH, DAEMON_KEY_PATH, DAEMON_PUB_PATH, DEFAULT_TCP_PORT, DEFAULT_UDP_PORT};
|
||||
use super::{
|
||||
DAEMON_CONFIG_PATH, DAEMON_KEY_PATH, DAEMON_PUB_PATH, DEFAULT_TCP_PORT, DEFAULT_UDP_PORT,
|
||||
};
|
||||
|
||||
/// Write the FIPS daemon config based on the local npub and default
|
||||
/// transports. Overwrites any existing file — callers are expected to
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user