Compare commits

..
Author SHA1 Message Date
archipelago 6a30ff11bd chore: release v1.7.84-alpha 2026-06-11 04:44:58 -04:00
archipelago 22df3f8f5f chore: release v1.7.83-alpha 2026-06-11 03:03:32 -04:00
archipelago 87853fc29c frontend: keep mobile app tabs singular 2026-06-11 02:54:34 -04:00
archipelago b7c2fd081f settings: update whats new for v1.7.83 2026-06-11 02:49:07 -04:00
archipelago 809b76526e docs: prepare v1.7.83 alpha release notes 2026-06-11 02:40:04 -04:00
archipelago 760796f650 frontend: polish mesh release layout 2026-06-11 02:39:24 -04:00
archipelago 10e4f218a6 deploy: bound indeedhub fixups and polish bitcoin ui 2026-06-11 02:32:10 -04:00
archipelago 84b283f5b6 deploy: exclude archived image build outputs 2026-06-11 02:01:55 -04:00
archipelago 8f2e03df2a deploy: exclude codex scratch artifacts 2026-06-11 01:46:38 -04:00
archipelago c79afa9541 frontend: fix strict production build typing 2026-06-11 01:30:49 -04:00
archipelago f818f1dcc1 app-platform: remove unsupported saleor release surface 2026-06-11 01:16:21 -04:00
archipelago de60f7e21e app-platform: remove revoked onlyoffice app 2026-06-11 01:03:45 -04:00
archipelago 881478a873 app-platform: type manifest launch interfaces 2026-06-11 00:52:16 -04:00
archipelago 755ba5562d app-platform: derive launch URLs from manifests 2026-06-11 00:33:24 -04:00
archipelago 182f18ecf3 docs: capture 1.8 app migration release plan 2026-06-11 00:24:54 -04:00
archipelago 1a3d726eac frontend: polish app launch and release experience 2026-06-11 00:24:40 -04:00
archipelago c393b96da3 backend: harden rootless app lifecycle orchestration 2026-06-11 00:24:32 -04:00
archipelago 09ec64932f app-platform: generate catalog from app manifests 2026-06-11 00:24:20 -04:00
archipelago 9079d404d6 chore: ignore local build scratch artifacts 2026-06-11 00:23:42 -04:00
archipelago af9d531a00 chore: sync cargo lock for v1.7.82-alpha 2026-05-22 17:24:42 -04:00
archipelago 136eda16c9 chore: release v1.7.82-alpha 2026-05-22 17:19:45 -04:00
archipelago 626a89bdbc fix(apps): proxy saleor storefront media 2026-05-22 17:08:03 -04:00
archipelago 68784be4db chore: sync cargo lock for v1.7.81-alpha 2026-05-21 21:48:46 -04:00
archipelago 853d51ae14 chore: release v1.7.81-alpha 2026-05-21 21:44:14 -04:00
archipelago a578834462 fix(apps): repair saleor storefront startup 2026-05-21 21:33:51 -04:00
archipelago c31c3765f4 chore: sync cargo lock for v1.7.80-alpha 2026-05-21 00:39:53 -04:00
archipelago bdd5a2c43e chore: release v1.7.80-alpha 2026-05-21 00:38:57 -04:00
archipelago 8eb03d106e fix(apps): repair saleor storefront graphql origin 2026-05-21 00:30:22 -04:00
archipelago 4da6e3b43c chore: sync cargo lock for v1.7.79-alpha 2026-05-20 23:17:04 -04:00
archipelago 7be7420c4f chore: release v1.7.79-alpha 2026-05-20 23:11:54 -04:00
archipelago 34c4e87d14 feat(apps): add saleor storefront 2026-05-20 23:02:57 -04:00
archipelago e61c757633 chore: release v1.7.78-alpha 2026-05-20 20:53:23 -04:00
archipelago cc1f8fba72 fix(apps): stabilize saleor and netbird release paths 2026-05-20 20:38:52 -04:00
archipelago 556f2e7cac chore: release v1.7.77-alpha 2026-05-20 01:03:48 -04:00
archipelago 0898c54765 chore: bump version to v1.7.77-alpha 2026-05-20 00:38:26 -04:00
archipelago f4368785f0 fix(apps): unblock saleor and netbird first-use flows 2026-05-20 00:28:30 -04:00
archipelago 608f4c17f0 chore: release v1.7.76-alpha 2026-05-19 21:55:48 -04:00
archipelago 92c58141af fix(apps): stabilize saleor and netbird launch 2026-05-19 21:45:17 -04:00
archipelago 7b2f4cb05f chore: sync cargo lock for v1.7.75-alpha 2026-05-19 20:27:34 -04:00
archipelago e65e76cd9d chore: release v1.7.75-alpha 2026-05-19 20:19:24 -04:00
archipelago 6d03ed5a69 docs: add v1.7.75-alpha changelog 2026-05-19 20:11:41 -04:00
archipelago 522c046525 feat(apps): add saleor and harden netbird repair 2026-05-19 20:11:22 -04:00
archipelago 56f956973e chore: release v1.7.74-alpha 2026-05-19 19:29:15 -04:00
archipelago bd69ef41d5 fix(apps): repair netbird login and iframe focus 2026-05-19 19:21:43 -04:00
archipelago eeb08fc78f chore: release v1.7.73-alpha 2026-05-19 18:40:10 -04:00
archipelago 1836b035b4 fix(mobile): improve app store search and launches 2026-05-19 18:29:04 -04:00
archipelago 3e01e57c8d chore: release v1.7.72-alpha 2026-05-19 17:42:11 -04:00
archipelago ca3e2ee0ca fix(settings): update whats new release notes 2026-05-19 17:33:45 -04:00
archipelago 5859ef77e7 chore: release v1.7.71-alpha 2026-05-19 17:30:20 -04:00
archipelago f0bd49d03d fix(apps): repair netbird install and app icons 2026-05-19 17:20:32 -04:00
archipelago cede77f3bc chore: update release lockfile 2026-05-19 16:17:13 -04:00
archipelago dd8a6cd9d7 chore: release v1.7.70-alpha 2026-05-19 16:10:43 -04:00
archipelago ab96c97cb9 fix(apps): self-host netbird and stabilize app sessions 2026-05-19 16:02:35 -04:00
archipelago 881779005a chore: update release lockfile 2026-05-19 14:45:20 -04:00
archipelago 20bc9f250c chore: release v1.7.69-alpha 2026-05-19 14:39:15 -04:00
archipelago 87be717f40 fix(apps): keep slow installs visible 2026-05-19 14:29:20 -04:00
archipelago 75d147b69f fix(release): verify published OTA artifacts 2026-05-19 12:10:42 -04:00
archipelago edaece8716 chore: update release lockfile 2026-05-19 09:41:57 -04:00
archipelago ab27fb97f8 chore: release v1.7.68-alpha 2026-05-19 09:37:47 -04:00
archipelago d736364ad7 fix(apps): stabilize btcpay and public proxy launch flows 2026-05-19 09:26:43 -04:00
archipelago e9898ead76 chore: update release lockfile 2026-05-18 11:55:20 -04:00
archipelago b25d41c5c6 chore: release v1.7.67-alpha 2026-05-18 11:54:57 -04:00
archipelago 32902d3891 fix(ui): stabilize system status metrics 2026-05-18 11:47:12 -04:00
archipelago 92c578d3d9 chore: update release lockfile 2026-05-18 10:17:20 -04:00
archipelago 6240064acf chore: release v1.7.66-alpha 2026-05-18 10:15:56 -04:00
archipelago 19dbf60f03 fix(apps): detect stale npm created containers 2026-05-18 10:04:22 -04:00
archipelago b49d8f1f8a chore: update release lockfile 2026-05-18 09:31:57 -04:00
archipelago ec36ac7e2c chore: release v1.7.65-alpha 2026-05-18 09:31:41 -04:00
archipelago 7104ba0cbf fix(apps): repair orchestrator starts before launch 2026-05-18 09:20:12 -04:00
archipelago d0b08d2790 chore: update release lockfile 2026-05-17 23:25:16 -04:00
archipelago 76288f541e chore: release v1.7.64-alpha 2026-05-17 23:24:39 -04:00
archipelago b701e125b4 fix(update): relax apply rate limit 2026-05-17 23:15:07 -04:00
archipelago 837ba63466 chore: update release lockfile 2026-05-17 23:03:44 -04:00
archipelago 8191d92bed chore: release v1.7.63-alpha 2026-05-17 23:03:06 -04:00
archipelago ae8359da4b fix(release): rebuild backend artifacts 2026-05-17 22:54:37 -04:00
archipelago d91b858d9b chore: release v1.7.62-alpha 2026-05-17 22:40:36 -04:00
archipelago 19f2125a4d fix(apps): repair stale nginx proxy manager ports 2026-05-17 22:38:04 -04:00
archipelago a992abcd06 chore: release v1.7.61-alpha 2026-05-17 22:13:21 -04:00
archipelago 4d6b4f76af chore: release v1.7.60-alpha 2026-05-17 20:45:56 -04:00
archipelago 0a94c0097f chore: release v1.7.59-alpha 2026-05-17 19:44:54 -04:00
archipelago 413d50116e fix(apps): restore mobile and website launching 2026-05-17 19:22:18 -04:00
archipelago daad50325b chore(release): require curated release notes 2026-05-17 18:59:12 -04:00
archipelago e05e356d64 chore: release v1.7.58-alpha 2026-05-17 18:40:50 -04:00
archipelago cfb304a001 feat(mesh): add meshtastic serial radio support 2026-05-17 18:07:40 -04:00
archipelago 7804223152 chore: release v1.7.57-alpha 2026-05-17 17:30:04 -04:00
archipelago a322b04021 fix(iso): avoid polkit in live debootstrap seed 2026-05-15 18:32:14 -04:00
archipelago 645cf69ed7 chore(release): refresh v1.7.56-alpha manifest after wifi fix 2026-05-15 18:26:17 -04:00
archipelago 01ec0565a6 fix: restore wifi setup and ssh password updates 2026-05-15 18:15:06 -04:00
archipelago 30505f41ff chore(release): refresh v1.7.56-alpha notes and artifacts 2026-05-15 17:54:32 -04:00
Dorian 5818541721 chore: release v1.7.56-alpha 2026-05-14 09:13:58 -04:00
Dorian b8053c00ca fix: clear stale health notifications 2026-05-14 08:57:54 -04:00
Dorian f95e9a1cd0 fix: quote quadlet environment values 2026-05-14 01:15:22 -04:00
Dorian be50dc3235 fix: avoid bootstrap bitcoin restarts 2026-05-14 00:03:16 -04:00
Dorian 2ff47f88a7 fix: harden container reconcile and launch behavior 2026-05-13 22:59:55 -04:00
Dorian 835c525218 chore(release): stage v1.7.55-alpha 2026-05-13 15:09:22 -04:00
Dorian 3202b79e41 chore(release): move artifacts to gitea releases 2026-05-13 14:11:42 -04:00
archipelago c0751e2551 chore(release): stage v1.7.54-alpha 2026-05-06 09:23:57 -04:00
archipelago 1a0d8a432c chore(release): stage v1.7.53-alpha 2026-05-05 13:59:50 -04:00
archipelago 745cb1c626 chore(release): stage v1.7.52-alpha 2026-05-05 11:29:18 -04:00
archipelagoandClaude Opus 4.7 10fbb8f87c docs(testing): track Phase 3.4 race fix + drift-sync hook
* L0 unit count: 630 → 631 (translate_health_check_http_does_not_double_prefix_scheme)
* Phase 3 row: add TimeoutStartSec=600 race fix (44f275ed) + drift-sync hook (0889367d)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 11:53:18 -04:00
archipelagoandClaude Opus 4.7 aad0ba5234 feat(orchestrator): drift-sync existing Quadlet units on each reconcile
When a Quadlet unit file already exists for an orchestrator-managed
backend, sync its on-disk bytes against what the current renderer
produces. write_if_changed makes this idempotent — when bytes match,
no IO; when they differ (post-deploy of a renderer change), the file
is rewritten and systemctl --user daemon-reload runs once.

We deliberately do NOT restart the .service when the file changes:
running containers keep their current config until the operator
restarts them. That's the right tradeoff — file updates are cheap and
non-destructive; service restarts are the SIGKILL cascade we're
trying to eliminate.

Why this matters: pre-this-commit, every renderer change required a
fresh package.install RPC per app to take effect. Observed live on
.228 2026-05-02 — the TimeoutStartSec=600 fix shipped in code but
existing units stayed on the old format because nothing triggered a
re-render. Combined with state.json being empty (so the reconciler's
auto-install path didn't fire either), the fix was invisible until
manual unit deletion.

Companions (UI_APP_IDS) are skipped — companion.rs renders those units
with a different shape; syncing here would clobber them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 11:43:18 -04:00
archipelagoandClaude Opus 4.7 281e65e697 fix(quadlet): TimeoutStartSec=600 when Notify=healthy is set
Bug surfaced live on .228 2026-05-02 — every backend Quadlet unit
(lnd, electrumx, fedimint, btcpay-server, mempool-api, bitcoin-knots)
hit systemd's default 90s start timeout because Notify=healthy makes
systemctl wait for the first green health probe, but
HealthInterval=30s × HealthRetries=3 = 90s minimum even on a healthy
service. Race: timeout fires the moment the third probe MIGHT succeed.

Result was three different post-states (inactive+running, failed+missing,
inactive+stopped) depending on whether systemd's ExecStopPost ran
podman rm before the orchestrator's adoption logic re-grabbed the
container.

Fix: when health is set, render TimeoutStartSec=600 (10 minutes) into
[Service]. Long enough for slow-starting backends (electrumx index
replay, lnd wallet unlock) without being so long that a truly stuck
unit hangs forever. Companions stay unchanged (no health → no override,
default 90s applies).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 07:14:48 -04:00
archipelagoandClaude Opus 4.7 384f12de7a fix(quadlet): http:// double-prefix + companion migration race
Two bugs surfaced by the first real-node validation of Phase 3.2-3.4
on .228 (2026-05-02), both caught before flipping the default.

Bug 1 — translate_health_check double-prefixed http://. Manifests in
the wild carry the scheme inside the endpoint string ("http://localhost:8175"),
and we were prepending another http:// unconditionally. Result on .228:
every backend HealthCmd read `curl -fsS -m 5 http://http://localhost...`,
every probe failed, fedimint hit a 14-restart loop. Now we accept either
form and skip appending hc.path when the endpoint already carries one.
Regression test asserts no double-prefix and that an in-endpoint path
is honoured.

Bug 2 — Phase 3.3 migration ran for UI companions (bitcoin-ui /
electrs-ui / lnd-ui) that have shipped via Quadlet since v1.7.41.
Migration tore down the running companion + raced companion.rs render,
producing "Phase 3.3: re-install archy-bitcoin-ui via Quadlet" reconcile
errors and leaving archy-bitcoin-ui down. Companions now short-circuit
out of migrate_to_quadlet_if_needed before any IO. Also: when try_exists
returns Err for an unrelated reason (permissions, EIO), we now skip
migration instead of treating "I can't tell" as "go ahead and migrate" —
migrating on top of a possibly-existing unit is destructive.

What this does not fix yet:
  * the orchestrator's reconciler iterating every manifest in
    /opt/archipelago/apps/, not just installed apps. Pre-existing
    behavior (also affects the legacy path) — separate scope.
  * fedimint /data UID mismatch surfaced when Quadlet started fedimint
    fresh. Likely orthogonal — defer.
  * no rollback when install_via_quadlet fails after a remove_container.
    Tracked as Phase 3.3.1 — defer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 06:37:37 -04:00
archipelagoandClaude Opus 4.7 bd96c0475d feat(config): ARCHIPELAGO_USE_QUADLET_BACKENDS env override
Adds an env-var lever for Phase 3.2's use_quadlet_backends flag so the
20× harness can flip the path on per-node without a config.json edit
(which would require an archipelago.service restart — and that triggers
FM3 cgroup cascade until Phase 3.5 ships, so we can't ask anyone to
reconfigure live nodes that way today).

Truthy parsing centralised in `parse_truthy_env` (1, true, yes, on —
case-insensitive, whitespace-trimmed). Anything else is false. The
helper is unit-tested so future env-var flags can reuse the same shape.

Also adds a default-off regression test for use_quadlet_backends so
flipping the default ahead of the 20× verification fires immediately.

TESTING.md documents the Environment= snippet for the systemd drop-in
so the next operator can flip the flag on a debug node without
re-deriving the recipe.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 05:44:09 -04:00
archipelagoandClaude Opus 4.7 9a89a000d4 test(lifecycle): post-condition gate for use_quadlet_backends path
A six-test bats suite that validates what install_via_quadlet (Phase 3.2)
is supposed to leave behind:

  * `.container` unit on disk in $XDG_CONFIG_HOME/containers/systemd/
    with [Container] / [Service] / [Install] sections, Image= present,
    and Restart=on-failure (the backend invariant — companions use Always)
  * Phase 3.4 cross-check: any unit with HealthCmd= must also emit
    Notify=healthy, otherwise systemctl start won't gate on health
  * `systemctl --user is-active` returns 0 for the .service
  * podman shows the container running
  * the container's cgroup is under user.slice/, NOT under
    archipelago.service — the kernel-level proof that FM3 cgroup
    cascade SIGKILL is structurally fixed for this container

Auto-skips on every test when no backend Quadlet units exist (today's
default state, use_quadlet_backends=false) — so the suite is a no-op
on current fleet boxes and turns into a hard regression gate the
moment anyone flips the flag and reinstalls.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 05:34:47 -04:00
archipelagoandClaude Opus 4.7 97ce23d773 feat(quadlet): Phase 3.4 — health-gated startup via Notify=healthy
QuadletUnit gains an optional HealthSpec; from_manifest translates the
manifest's health_check (tcp/http/cmd) into a HealthCmd= directive and
emits Notify=healthy alongside it. systemctl start <unit>.service then
blocks until the container's first green probe — eliminating the
"container up but RPC not ready" race the orchestrator currently papers
over with post-start polling.

Translation policy:
* tcp,  endpoint "host:port"        -> nc -z host port
* http, endpoint "host:port", path  -> curl -fsS -m 5 http://endpoint<path>
* cmd,  endpoint "<shell command>"  -> verbatim
* unknown type / malformed endpoint -> None (skip Notify=healthy rather
  than emit a HealthCmd that hangs the unit start forever)

Companion units leave health: None and remain byte-identical to before
this PR — the renderer only emits the Health* / Notify= block when set.

+4 quadlet unit tests (19 total). Dropped a never-used test setter that
was generating a dead_code warning.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 05:21:57 -04:00
archipelagoandClaude Opus 4.7 65576bd755 feat(orchestrator): Phase 3.3 — in-place migration to Quadlet
When use_quadlet_backends flips from off → on, existing fleet boxes
have backend containers parented under archipelago.service's cgroup
(the bad shape that triggers FM3 cascade SIGKILL on every archipelago
restart). ensure_running now notices and corrects this:

* If there's already a `<name>.container` unit on disk → no-op
  (subsequent reconcile ticks take this fast path).
* Else if a podman container with that name exists → it's a pre-3.3
  artifact. Stop+remove it (volumes survive — bind mounts are not
  touched by `podman rm`), then write the Quadlet unit, daemon-reload,
  and start the new managed service.
* Else → fall through to install_fresh, which already routes through
  install_via_quadlet when the flag is on.

The migration is idempotent and self-healing: if a fleet box is
half-migrated (unit on disk but no service active, or service active
but stale unit), the next reconcile tick converges. Bitcoin chain
data, lnd wallet state, and electrumx index all live on host bind
mounts and are unaffected by the container-record swap.

Volume safety audited per backend in `uses_orchestrator_install_flow`
allowlist — every entry mounts its data dir as a host bind mount.

Default still off. To migrate a node:
  /etc/archipelago/config.toml: use_quadlet_backends = true
followed by `systemctl restart archipelago` — the next reconcile tick
walks every managed app and migrates each in turn.

Tests: 624 passing, 0 cargo warnings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 17:27:59 -04:00
archipelagoandClaude Opus 4.7 5b2e02bd43 feat(orchestrator): Phase 3.2 — wire Quadlet path behind feature flag
prod_orchestrator::install_fresh now branches on the new
Config::use_quadlet_backends flag (default false):

* off (today's production behavior) — unchanged: runtime.create_container
  + start_container, container parented under archipelago.service's
  cgroup, FM3 cascade SIGKILL on every archipelago restart.
* on  — install_via_quadlet renders the manifest as a Quadlet unit via
  QuadletUnit::from_manifest, writes it atomically into
  ~/.config/containers/systemd/, calls daemon-reload, and starts the
  generated <name>.service. Container ends up under user.slice — no
  more cgroup parented under archipelago, so archipelago restarts
  don't touch the container's lifetime.

Default off so this commit is structurally safe to ship: nothing
changes at runtime until an operator opts in. Flip the default once
tests/lifecycle/run-20x.sh has gone green against the new path on
.228 + .198 (the v1.7.52 release gate).

Plumbing:
* config.rs — `use_quadlet_backends: bool` w/ Default false
* prod_orchestrator.rs — flag stored on the struct, threaded through
  new(), with set_use_quadlet_backends(bool) test setter
* prod_orchestrator.rs — install_via_quadlet helper
* dropped the Phase-3.1 #[allow(dead_code)] markers on from_manifest /
  parse_memory_mib / RestartPolicy::OnFailure now that the call path
  exists; if a future revert removes the wiring, the warnings come back.

Tests: 624 passing, cargo check clean (0 warnings). Existing companion
behavior unaffected — render_skips_backend_directives_when_default
still passes byte-equal to before quadlet.rs grew the new fields.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 17:22:10 -04:00
archipelagoandClaude Opus 4.7 9becafafd3 feat(quadlet): backend-manifest renderer (Phase 3.1 of v1.7.52)
The QuadletUnit struct now covers everything a backend manifest needs
(ports, environment, devices, add_hosts, entrypoint+command, read-only
root, no_new_privileges, cpu_quota, restart policy choice). Adds
QuadletUnit::from_manifest(&AppManifest, name) that translates a parsed
manifest into a unit, plus parse_memory_mib for "1g"/"512m"/raw-MiB
forms. The renderer skips empty/false directives so existing companion
units render byte-identically — no behavior change for shipping
companions; the backend renderer is dead code until Phase 3.2 wires it
into the orchestrator.

Eight new unit tests cover:
* parse_memory_mib forms (1024, 512m, 2g, garbage)
* shell_join quoting (whitespace, embedded quotes)
* RestartPolicy → systemd string mapping
* render emits backend directives when set
* render skips them when defaulted (companion regression gate)
* from_manifest happy path on a bitcoin-knots-shaped manifest
* from_manifest read-only volume detection
* from_manifest tmpfs filtering
* end-to-end manifest → render bytes assertion

Tests: 615 → 624 (+9 net; one pre-existing parse_memory_mib path was
implicitly covered before but is now explicit). Cargo warnings: 0.

`from_manifest`, `parse_memory_mib`, and `RestartPolicy::OnFailure` are
marked allow(dead_code) with explicit references to Phase 3.2 — if
3.2 doesn't wire them, the dead-code warning resurfaces.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 17:09:50 -04:00
archipelagoandClaude Opus 4.7 5074572373 test(lifecycle): add btcpay + fedimint + mempool suites
Brings L1 (RPC API) + L3 (lifecycle survival) parity coverage to the
three multi-app stacks that were previously only touched by
required-stack.bats. Combined with bitcoin-knots / lnd / electrumx
already shipping, the six core apps now have dedicated bats files.

Each suite is shaped like the existing single-container suites
(bitcoin-knots / lnd / electrumx) and gates every assertion on the
backing container actually being present, so a node without the stack
installed gets clean skip messages instead of false fails.

* btcpay.bats — 9 tests, including stack-wide presence and a
  "supporting containers don't cascade-restart" guard
* fedimint.bats — 8 tests, single container
* mempool.bats — 9 tests, mixed legacy + orchestrator-managed stack;
  reuses the :8999 mempool-api probe from required-stack for parity

Total bats now: 88 (was 53 → +35).
TESTING.md matrix advances 23 → 50 of 110 cells.
UI URL coverage for these three apps already lives in
ui-coverage.bats, so this PR doesn't duplicate proxy-path probes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 16:55:31 -04:00
archipelagoandClaude Opus 4.7 ec1dce93a9 docs(testing): canonical scorecard for container subsystem testing
Single source of truth for "where are we, where are we going" on the
v1.7.52 container excellence work. Replaces ad-hoc tracking in chat.

Sections:
* Test layers L0..L6 with toolchain + per-iteration latency
* Per-app × per-state coverage matrix (23 of 110 cells today; goal 110)
* Layer-by-layer status (L0+L1+L2 ●; L3 ◐; L4..L6 ○)
* Run commands (single suite / full suite / 20×)
* LoC budget — -270 committed, ~1,616 more possible if Phase 3 ships
* Performance KPIs (TBD — measure first, target second)
* Release gates — 8 boxes that must tick before v1.7.52 ships

The file lives in-repo so PR diffs to it answer "what did this commit
improve?". If you can't tick the box, the change isn't ready.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 16:52:42 -04:00
archipelagoandClaude Opus 4.7 b9eb6eb18a test(lifecycle): add UI surface coverage — HTTPS proxy + iframe URLs
Closes the coverage gap where existing bats suites would report green
on a node whose dashboard tiles 502 because the proxy upstream is dead.
First pass against .198 caught real prod issues immediately:
  /app/lnd/       → 502 (lnd container exited)
  /app/mempool/   → 502 (mempool container exited)
  /app/fedimint/  → 502 (fedimint container exited)
while existing tests reported only "container is up: false" with no
404/502 distinction.

* lib/ui-probes.bash — sourced helper. probe_https_200,
  probe_app_url (skip-if-container-down else assert-200),
  probe_dashboard_shell (asserts the Vue SPA HTML, not nginx default —
  catches the layout regression from feedback_release_tarball_layout.md),
  probe_dashboard_catalog (asserts /catalog.json non-empty).
* bats/ui-coverage.bats — 9 @test cases covering the dashboard +
  bitcoin-ui :8334 + the seven HTTPS_PROXY_PATHS most users hit
  (lnd, electrumx, mempool, fedimint, btcpay, filebrowser).

URL list mirrors HTTPS_PROXY_PATHS in
neode-ui/src/views/appSession/appSessionConfig.ts. Divergence between
the two is the exact bug class we're guarding against.

Loops clean under run-20x.sh. Container-state oracle is via local
podman inspect, so the suite must run on the archy host (same as
companion-survives-archipelago-restart.bats).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 16:49:30 -04:00
archipelagoandClaude Opus 4.7 c55a4f4e86 test(bootstrap): regression gate for the heal_podman_state socket bug
Extracted the heal_podman_state cleanup list as a module-level
HEAL_RUNTIME_SUBDIRS const so a unit test can structurally enforce
the invariant: the list must contain "containers" + "libpod" but
must NOT contain "podman" (which holds systemd's podman.sock
listener and was the bug fixed in commit bb421803).

If anyone re-adds "podman" — accidentally, by reverting, or by
copy-paste from old plan memory — this test fires before we ship,
not on the next deploy when it nukes the orchestrator's HTTP path.

Total tests: 614 → 615.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 16:32:59 -04:00
archipelagoandClaude Opus 4.7 01f416ae5d test(lifecycle): regression gate for FM3 cgroup-cascade SIGKILL
Sister suite to companion-survives-archipelago-restart.bats. That one
tests the same property for UI companions, which already ship via
Quadlet (commit 6e716f68) and so already pass.

This new suite tests the property for backend containers (bitcoin-knots
/ bitcoin-core / lnd / electrumx). Until v1.7.52 Phase 3 ships these
under Quadlet too, the suite is EXPECTED TO FAIL on fleet boxes — it's
the executable definition of "FM3 fixed".

Observed live on .198 on 2026-05-01: `sudo systemctl stop archipelago`
killed every container in archipelago.service's cgroup. The dedicated
"backends survive archipelago restart" test catches exactly that, and
also verifies the SAME container instance survives (compares pre/post
.Id), so an orchestrator that recreates a fresh container after the
SIGKILL doesn't read as pass.

Three @test cases:
* destructive gate (skip-marker for the suite)
* baseline: at least one backend installed + running
* backends survive: same .Id pre + post archipelago restart

Don't gate releases on this passing until Phase 3 lands; before then
treat it as a "expected to fail / shows progress" indicator.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 16:17:27 -04:00
archipelagoandClaude Opus 4.7 f80daff8ba test(lifecycle): add dedicated electrumx.bats suite
Same shape as bitcoin-knots.bats and lnd.bats so the 20× release-gate
exercises electrumx through the same state matrix it uses for the other
two core apps. electrumx previously had a single TCP-port check inside
required-stack.bats; this adds destructive + cascade-destructive tiers.

10 @test cases:
* read-only: presence, valid state, TCP port (50001) reachable, no
  orphan containers beyond {electrumx, archy-electrs-ui}
* destructive: stop, start, restart, TCP port recovers within 120s of
  cold restart (longer than bitcoind because electrumx replays its
  index against bitcoind on start)
* cascade: uninstall, reinstall (240s timeout for index rebuild)

With this suite, the three single-container core apps (bitcoin-knots,
lnd, electrumx) now have parity coverage. Multi-container stacks
(btcpay, mempool, fedimint) come next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 16:11:02 -04:00
archipelagoandClaude Opus 4.7 1103c2c710 test(lifecycle): add dedicated lnd.bats suite
Mirrors bitcoin-knots.bats so the 20× release-gate run exercises lnd
through the same state matrix. lnd previously had only a single
read-only check inside required-stack.bats; this adds the destructive
and cascade-destructive tiers that match what we already test for
bitcoin-knots.

10 @test cases:
* read-only: presence, valid state, lncli getinfo, no orphan containers
* destructive (ARCHY_ALLOW_DESTRUCTIVE=1): stop, start, restart,
  RPC recovers within 90s of cold restart (longer than bitcoind
  because the wallet has to unlock first)
* cascade (ARCHY_ALLOW_CASCADE_DESTRUCTIVE=1): uninstall, reinstall

Reuses the same lncli invocation as required-stack.bats so divergence
shows up clearly if either test breaks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 16:09:43 -04:00
archipelagoandClaude Opus 4.7 1b6c500657 test(lifecycle): add setup-teardown + run-20x harness scaffolding
Phase 4 of the v1.7.52 container excellence plan: a release-gate harness
that loops the bats suite N times in a row, with teardown between
iterations, and reports a pass/fail tally.

* setup-teardown.sh — clears /tmp/archy-rpc-session-* between runs so
  iteration N+1 doesn't reuse a logged-out cookie from iteration N.
  Idempotent; safe to run anytime. Designed to grow as we add suites
  that leave other transient state.
* run-20x.sh — wraps run.sh in a loop of ARCHY_ITERATIONS (default 20).
  Tracks per-iteration pass/fail with wall-clock timing, prints a
  results block, exits non-zero on any failure. Honors ARCHY_FAIL_FAST
  for short-circuit during dev.

Suggested release-gate command:
  ARCHY_PASSWORD=password123 ARCHY_ALLOW_DESTRUCTIVE=1 \
    tests/lifecycle/run-20x.sh

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 16:06:09 -04:00
archipelagoandClaude Opus 4.7 5be2febe13 fix(bootstrap): don't nuke podman socket dir during runtime self-heal
Observed live on .198: heal_podman_state was removing
$XDG_RUNTIME_DIR/podman/ alongside containers/ and libpod/. That dir
holds the systemd-bound podman.sock — the listener systemd creates for
socket-activated podman.service. Removing it broke every libpod HTTP
call from the orchestrator until `systemctl --user restart
podman.socket` ran. Far worse than any wedge it was trying to repair.

Drop podman/ from the cleanup list. The runtime state we actually want
to clean for FM6 (bolt_state.db drift) lives in containers/ and
libpod/ only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:57:15 -04:00
archipelagoandClaude Opus 4.7 6bbe1b96cf refactor: drop dead code surfaced by cargo
cargo check was showing five real warnings, all genuinely dead:

* container/mod.rs   — re-exports compute_container_name, AdoptionReport,
                       ReconcileAction, ReconcileReport were unused outside
                       prod_orchestrator. Drop from the pub use line.
* prod_orchestrator  — with_runtime + insert_manifest_for_test only exist
                       for the test module in the same file. Mark them
                       #[cfg(test)] so they don't appear in release builds.
* async_lifecycle    — remove_package_entry has no callers; doc claims
                       "used for install-failure cleanup" but nothing
                       cleans up. Delete (10 lines).
* registry.rs        — `use tracing::{debug, info};` had no consumers.
* fips.rs            — unused-assignment chain on last_status. The poll
                       loop always sets it on every break path, so the
                       initial `None` and the unwrap_or_else fallback
                       were both dead. Refactored to `let after = loop
                       { ...; break s; };`.

cargo check is now clean. cargo test --workspace --bins: 614 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:34:02 -04:00
archipelagoandClaude Opus 4.7 8f13298805 fix(bootstrap): self-heal wedged podman runtime state at startup
Closes FM6 (podman bolt_state.db / runtime drift) — observed live on
.198 today: bitcoind was running for several minutes, but podman's
state DB reported the container as Exited. The reconciler then tried
to "restart" it, racing the still-bound port 8332 and failing in a
loop.

heal_podman_state() runs as the last bootstrap stage, BEFORE the
orchestrator's reconcile loop ticks. It probes `podman info` with a
5s timeout; on failure it removes the runtime-state dirs under
$XDG_RUNTIME_DIR and re-probes. Persistent storage under
~/.local/share/containers/storage/ is never touched, so containers
re-discover from manifests on next call.

Cleanup never includes `podman system reset` or `system renumber` —
those are destructive and must stay operator-only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:23:36 -04:00
archipelagoandClaude Opus 4.7 ba2eece9aa refactor(container): drop unused dependency_resolver module
DependencyResolver had zero call sites in prod or tests outside the
module itself. The actual install-time dependency check lives in
install.rs::detect_running_deps + check_install_deps; this DAG-walk
solver was never wired up. -268 LoC.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:22:07 -04:00
archipelagoandClaude Opus 4.7 6603227874 fix(install): auto-clean stuck OTHER-variant bitcoin container
If bitcoin-core was installed but never started (e.g. port 8332 already
bound by bitcoin-knots), the container sticks in `created` state forever.
The old conflict check refused EVERY future bitcoin install — including
re-install of the running variant — leaving no UI path to recovery.

Now the check distinguishes states:
  - missing                       → no conflict, continue
  - running                       → real conflict, refuse install
  - created/exited/configured/... → stuck; auto-remove and continue

Volumes are untouched; only the dead container record goes away.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 14:59:11 -04:00
archipelagoandClaude Opus 4.7 27ff1d5b52 fix(install): generate bitcoin RPC password before orchestrator install
Bitcoin containers were exiting in ms after start because the orchestrator
install path skipped the credential-materialisation step the legacy path
did. resolve_secret_env then failed to read
/var/lib/archipelago/secrets/bitcoin-rpc-password, the container started
with no password, and bitcoind crashed before logs were useful.

Two changes:

1. install.rs — call bitcoin_rpc_credentials() for bitcoin/bitcoin-core/
   bitcoin-knots before any install branch runs. The function generates +
   persists on first call (OnceCell-cached), so this is idempotent.

2. manifest.rs::resolve_secret_env — return ManifestError::Invalid when a
   resolved secret trims to empty, instead of silently producing
   `KEY=` env vars that crash auth.

Adds a unit test for the empty-secret rejection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 14:39:56 -04:00
archipelagoandClaude Opus 4.7 f9e34fd0c6 refactor(install): route orchestrator-managed apps through orchestrator first
Phase 3a of the install path consolidation. Two coupled changes:

1. install.rs handle_package_install: gate the legacy "container exists →
   adopt + return" probe on !orchestrator_managed. Apps the orchestrator
   knows about (bitcoin-knots, bitcoin-core, lnd, electrumx, fedimint,
   filebrowser, btcpay-server stack apps, mempool stack apps, plus the
   companion UIs that just moved to Quadlet) skip the legacy probe and
   fall straight into the orchestrator branch.

   The legacy adopt block was returning success on a bare `podman start`
   exit-0 — even when the process inside the container crashed seconds
   later. That's the .228 "running but unreachable" failure mode. The
   orchestrator's ensure_running honors the manifest's health check and
   pre-start hooks (e.g. re-renders bitcoin-ui's nginx.conf if the RPC
   password rotated), so this is a behavioral upgrade, not just a
   refactor.

2. ProdContainerOrchestrator::install: make idempotent. Previously it
   blindly called install_fresh which would fail on `podman create` if
   the container name already existed. Now it delegates to ensure_running:
     - Container Running + healthy → no-op (refresh hooks, restart if
       config rewritten)
     - Container Stopped/Exited → start (with hook refresh)
     - Container missing → install_fresh
     - Container in wedged state (Created/Paused/Unknown) → force-recreate

   Without this, change #1 would regress every "container already exists"
   case for the 18 orchestrator-managed app IDs. With it, install becomes
   the single source of truth for "make app X be in the desired state."

Tests: 654 passed across the workspace (614 unit + 37 orchestration + 3
rpc), 0 failures. The 20 prod_orchestrator tests cover the install /
ensure_running / reconcile paths the new install delegates through.

Net delta: install.rs grows by ~30 lines (gating wrapper + comments),
prod_orchestrator.rs grows by ~30 lines (idempotent install body). Both
are temporary — the larger deletions (~1700 lines) come once every app
has been verified through the orchestrator path in subsequent phases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 11:12:52 -04:00
archipelagoandClaude Opus 4.7 23c4e7441f refactor(container): move companion UIs to systemd via Quadlet
Companion UI containers (archy-bitcoin-ui, archy-lnd-ui,
archy-electrs-ui) used to be launched as fire-and-forget tokio::spawn
blocks from install.rs. If archipelago crashed mid-spawn or the
container's cgroup was reaped, companions vanished from podman ps -a
and only a manual rm/run could bring them back (the .228 incident).

Now each companion is rendered as a Quadlet .container unit under
~/.config/containers/systemd/, daemon-reloaded, and started via
systemctl --user. systemd owns supervision from that point on:

- archipelago can crash, restart, or be uninstalled without touching
  any companion.
- Quadlet's Restart=always + RestartSec=10 handles container exits.
- A 30s reconcile tick in boot_reconciler enumerates expected
  companion units and re-installs any whose unit file or service
  vanished — defense-in-depth against external tampering.

New module layout:
- container/quadlet.rs: pure unit renderer + atomic write_if_changed
  + systemctl helpers (daemon_reload_user / enable_now / disable_remove
  / is_active). 6 unit tests, no I/O in the renderer.
- container/companion.rs: per-app companion specs, install/remove/
  reconcile, image presence (build local first, fall back to insecure
  registry only via image_uses_insecure_registry whitelist). 2 tests.

install.rs handle_package_install now ends with a single call to
companion::install_for(package_id), replacing 287 lines of spawn-and-
hope shellouts plus a ~120-line nginx auth-injector helper that worked
around per-node RPC password baking. The helper is gone too — the
pre-start hook renders the per-node nginx.conf to /var/lib/archipelago/
bitcoin-ui/nginx.conf and the Quadlet unit bind-mounts it read-only.

runtime.rs handle_package_uninstall now disables companions before
the container rm loop. Otherwise systemd's Restart=always would
respawn each companion within ~10s of removal.

Tests: 53 container tests pass, including 6 quadlet renderer tests
(host network, bridge network, capability set, atomic write idempotence)
and 2 companion specs (per-app companion lookup, build_unit shape).
boot_reconciler tests gain a #[cfg(test)] without_companion_stage()
flag so the paused-clock fixtures don't race the real systemctl I/O.

A bats regression test (companion-survives-archipelago-restart.bats,
gated on ARCHY_ALLOW_DESTRUCTIVE=1) asserts the .228 failure mode
cannot recur: every installed companion has a unit file, services
stay active across systemctl --user restart archipelago, and a
deleted unit file is recreated within one reconcile tick.

Net delta: +941 / -363, but the +941 is mostly tests (~440 lines)
and the new declarative layer; the imperative tokio::spawn block and
its nginx-auth helper are gone, removing two failure classes
(orphan companions on archipelago crash, and post-start exec races
under tightly-confined cgroups) that previously needed manual SSH
recovery.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 10:45:07 -04:00
archipelagoandClaude Opus 4.7 2bf8181110 refactor(security): tighten capability + TLS-bypass surface
Three small, focused tightenings:

- core/container/src/podman_client.rs: drop the legacy Hetzner
  23.182.128.160:3000 mirror from image_uses_insecure_registry().
  It was decommissioned in v1.7.x and is stripped from active
  registry config at load time; leaving it in the bypass list let
  a stale config still skip TLS. Replace the inline match with a
  named INSECURE_REGISTRY_HOSTS slice so future entries are one
  line. Test now also pins the spoofing-immune semantics
  ("evil.example/146.59.87.168:3000/x" must NOT match).

- core/archipelago/src/api/rpc/package/config.rs: split bitcoin
  from lnd in get_app_capabilities(). bitcoind never opens raw
  sockets — drop CAP_NET_RAW from bitcoin/bitcoin-core/bitcoin-knots.
  lnd/fedimint/fedimint-gateway keep it because they enumerate
  network interfaces during cert generation.

- core/archipelago/src/bootstrap.rs: tighten_secrets_dir()
  enforces 0700 on /var/lib/archipelago/secrets and 0600 on every
  file inside on each startup. The dir-mode is the load-bearing
  isolation boundary against rootless container escapes (their UID
  maps to >=100000, can't traverse uid=1000/0700). The per-file
  sweep is defense-in-depth against any installer that wrote 0644.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 08:59:11 -04:00
archipelagoandClaude Opus 4.7 0684491072 chore: baseline codex hardening before lifecycle refactor
Snapshots the in-flight hardening work so subsequent reconcile/Quadlet
phases land on a clean before/after diff.

Changes:
- core/container/src/podman_client.rs: image_uses_insecure_registry()
  whitelist for the OVH (146.59.87.168:3000) and legacy Hetzner
  (23.182.128.160:3000) HTTP mirrors; podman_network_settings() lifts
  custom networks into the Networks map so containers can join them.
- core/archipelago/src/container/prod_orchestrator.rs:
  ensure_container_network() creates per-manifest networks on demand;
  apply_data_uid() now goes through host_sudo for mkdir -p + chown so
  bind-mount roots get created and chowned without password prompts.
- core/archipelago/src/api/rpc/package/{install,update,stacks}.rs:
  podman pull adds --tls-verify=false only for whitelisted registries.
- core/archipelago/src/bootstrap.rs: removes stale dev-mode systemd
  override on startup (live nodes carried it from old installers).
- core/archipelago/src/config.rs: ignore ARCHIPELAGO_DEV_MODE in prod
  binaries — it had been silently rerouting volumes to /tmp.
- apps/bitcoin-{core,knots}/manifest.yml: locate bitcoind at runtime
  so image-layout differences don't break entrypoint.
- scripts/app-catalog-image-smoke-test.py: production catalog/image
  smoke test that probes a target node before users click Install.
- .gitignore: cover .codex, .pnpm-store, __pycache__, *.bak.

Removes filebrowser.rs.bak and two stale catalog.json.bak files
(verified identical to live counterparts).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 08:52:29 -04:00
archipelago 05e6c2e738 fix: release v1.7.51-alpha install hardening 2026-05-01 05:02:39 -04:00
archipelago be9f9528c3 fix: release v1.7.50-alpha OTA runtime repair 2026-05-01 03:14:07 -04:00
archipelago 7ab788d178 chore: release v1.7.49-alpha 2026-04-30 16:37:54 -04:00
archipelagoandClaude Opus 4.7 f507b847ef chore: release v1.7.48-alpha
Hotfix: archipelago.service ExecStartPre now mkdirs /run/containers and
/var/lib/containers before the unit's mount-namespace setup tries to bind
them. Without this, fresh nodes that don't have /run/containers (e.g.
nodes provisioned without a prior podman session) fail at the namespace
step with:

  Failed to set up mount namespacing: /run/containers: No such file or directory
  Failed at step NAMESPACE spawning /bin/bash: No such file or directory

Existing nodes don't pick up systemd unit changes via OTA — they need a
one-time `systemctl edit archipelago` adding the same mkdir. ISO installs
from this version forward have the fix baked in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 16:27:22 -04:00
archipelagoandClaude Opus 4.7 8a2899ab4a chore: release v1.7.47-alpha
Sync-perf tuning for bitcoin/bitcoin-core/bitcoin-knots/electrumx.

- Drop the --cpus=2 cap on bitcoin/electrumx variants. Script verification
  is parallelizable; the cap halved IBD speed on 4-8 core machines.
- Bump bitcoin --memory 4g→8g so dbcache=4096 has headroom for mempool +
  connection buffers + I/O. 4g was OOM-prone during heavy IBD.
- Bump electrumx --memory 1g→2g + add CACHE_MB=2048 + MAX_SEND=10MB.
- bitcoin-core CLI args gain -dbcache=4096 -par=0 -maxconnections=125.
- bitcoin-knots manifest matched (1024MB pruned / 4096MB full + par=0).

Future v2: host-RAM-aware dbcache scaling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 15:47:51 -04:00
archipelagoandClaude Opus 4.7 992b673b20 chore: release v1.7.46-alpha
Follow-up to v1.7.45-alpha closing the remaining tasks identified by the
resilience sweeps + the new bitcoin orphan / install-fail-vanish bugs.

User-visible:
- Health monitor: stop paging on orphaned containers from variant switches
- Install fail: card stays visible (was vanishing) with error message
- Stack pull progress: interpolate 20→70% (was stuck at 20%)
- docker.io → lfg2025 mirror: bitcoin/gitea/nextcloud/valkey

Internal:
- Resilience harness — install-wait uses expected_containers_for, ui+auth
  probes retry with 60s backoff, dep-snapshot fix
- InstallProgress gains optional `message` field (frontend renders it
  when phase is None)

binary  $(stat -c %s releases/v1.7.46-alpha/archipelago)  sha256:$(sha256sum releases/v1.7.46-alpha/archipelago | awk '{print $1}')
tarball $(stat -c %s releases/v1.7.46-alpha/archipelago-frontend-1.7.46-alpha.tar.gz)  sha256:$(sha256sum releases/v1.7.46-alpha/archipelago-frontend-1.7.46-alpha.tar.gz | awk '{print $1}')

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 14:50:33 -04:00
358 changed files with 37657 additions and 4753 deletions
+8
View File
@@ -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
+19
View File
@@ -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/
@@ -77,3 +82,17 @@ web/
# Resilience harness reports (generated, contains session cookies)
scripts/resilience/reports/
# Codex / pnpm / python caches / editor backups
.codex
.codex-target-*/
.codex-tmp/
.pnpm-store/
**/__pycache__/
*.bak
.claude/scheduled_tasks.lock
# Local evidence screenshots; intentional UI screenshots should live under an
# app/docs asset path with a descriptive filename.
Screenshot *.png
uploads/
@@ -132,6 +132,16 @@ fun WebViewScreen(
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { context ->
fun openExternalUrl(url: String) {
try {
val intent = android.content.Intent(
android.content.Intent.ACTION_VIEW,
android.net.Uri.parse(url),
)
context.startActivity(intent)
} catch (_: Exception) {}
}
WebView(context).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
@@ -220,13 +230,7 @@ fun WebViewScreen(
// Keep navigation within the Archipelago server
if (url.startsWith(serverUrl)) return false
// Open external URLs in the system browser
try {
val intent = android.content.Intent(
android.content.Intent.ACTION_VIEW,
android.net.Uri.parse(url),
)
context.startActivity(intent)
} catch (_: Exception) {}
openExternalUrl(url)
return true
}
}
@@ -243,18 +247,30 @@ fun WebViewScreen(
isUserGesture: Boolean,
resultMsg: android.os.Message?,
): Boolean {
// Extract the URL from the hit test
val data = view?.hitTestResult?.extra
if (data != null) {
try {
val intent = android.content.Intent(
android.content.Intent.ACTION_VIEW,
android.net.Uri.parse(data),
)
context.startActivity(intent)
} catch (_: Exception) {}
val transport = resultMsg?.obj as? WebView.WebViewTransport
?: return false
val popup = WebView(context).apply {
settings.javaScriptEnabled = true
webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(
view: WebView?,
request: WebResourceRequest?,
): Boolean {
val url = request?.url?.toString() ?: return true
openExternalUrl(url)
return true
}
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
if (url != null) openExternalUrl(url)
view?.stopLoading()
}
}
}
return false
transport.webView = popup
resultMsg.sendToTarget()
return true
}
}
+301
View File
@@ -1,5 +1,306 @@
# Changelog
## v1.7.84-alpha (2026-06-11)
- Bitcoin trusted-node relay approvals now generate restricted `txrelay` RPC credentials when needed and restart the active Bitcoin backend so bitcoind loads the new `rpcauth` whitelist.
- Bitcoin Core now matches Bitcoin Knots for restricted relay RPC support, including the txrelay secret injection and sendrawtransaction-focused whitelist.
- The Bitcoin UI companion image is pinned to `1.7.84-alpha` across release metadata and the Quadlet fallback path, avoiding stale `latest` detection during OTA updates.
- Container scanning now uses an RAII in-flight guard so timeout and error paths cannot leave the scanner stuck in a permanently busy state.
- Validation passed with `cargo fmt`, `cargo check -p archipelago`, `git diff --check`, and focused source review of the relay message/approval path.
## v1.7.83-alpha (2026-06-11)
- App launch metadata now derives more consistently from app manifests, with typed launch interfaces and catalog generation updates that keep packaged apps aligned with their runtime ports and launch surfaces.
- Revoked or unsupported app surfaces were removed from the catalog and release path, including OnlyOffice and the unvalidated Saleor surface, so the Marketplace no longer exposes apps that cannot be safely supported in this release.
- The frontend production build now passes strict TypeScript checks after tightening app details, Web5, cloud refresh, and credential test typing.
- Mobile and desktop app surfaces received release polish: improved mobile app layout, safer mesh desktop/tablet scrolling, and the Home system card now routes directly to monitoring.
- Bitcoin UI status rendering now avoids false stale/reconnecting states when fresh block snapshots advance, and guards optional DOM updates so the standalone Bitcoin UI is more resilient.
- Deploy tooling now excludes local Codex scratch output, archived image-build artifacts, and upload screenshots from target syncs, and bounded optional IndeedHub fixups so a stuck Podman helper cannot hold the deploy.
- Validation passed with `npm run type-check`, production `npm run build`, backend `cargo build --release`, catalog/release manifest checks, focused frontend tests, and live `.198` deploy verification through the frontend/service restart phase.
## v1.7.82-alpha (2026-05-22)
- Saleor storefront proxying now forwards `X-Forwarded-Host`, fixing Next.js Server Actions requests that compared the browser origin with the internal `storefront-app:3000` upstream host.
- Saleor storefront media now routes `/thumbnail/` and `/media/` through the same `9011` proxy to the Saleor API, fixing product image optimizer failures caused by `localhost:8000` media URLs.
- The Saleor storefront container receives an explicit internal media origin so rewritten media URLs resolve inside the Podman network without exposing private API ports to browsers.
- Validation passed with `cargo fmt --all --check --manifest-path core/Cargo.toml`, `cargo check -p archipelago --manifest-path core/Cargo.toml`, and live checks on `100.114.134.21` for storefront HTML, static assets, GraphQL, media redirects, and optimized product images.
## v1.7.81-alpha (2026-05-21)
- Saleor storefront installs now use the prebuilt registry image instead of building the Next.js app on-device, avoiding Podman build failures during stack installation.
- Existing Saleor stacks are repaired on adoption by recreating missing storefront containers, forcing the storefront app to bind `0.0.0.0:3000`, and resolving nginx upstreams dynamically after container restarts.
- The shipped Saleor storefront image now includes public assets and omits Vercel-only Speed Insights injection, fixing broken static asset responses and the local `/_vercel/speed-insights/script.js` browser warning.
- Validation passed with `cargo fmt --all --check --manifest-path core/Cargo.toml`, `cargo check -p archipelago --manifest-path core/Cargo.toml`, and live checks on `100.114.134.21` for `9011` storefront, static assets, and proxied GraphQL.
## v1.7.80-alpha (2026-05-21)
- Saleor storefront proxying now falls back to the direct request scheme when no forwarded protocol header is present, fixing direct `http://node:9011` launches that could generate an invalid same-origin GraphQL URL.
- The Saleor storefront release path keeps public proxy support intact by still honoring forwarded HTTPS headers for Nginx Proxy Manager domains while repairing local/direct port launches.
- Validation passed with `cargo fmt --check` and `cargo check` for the Archipelago backend before release staging.
## v1.7.79-alpha (2026-05-20)
- Saleor now installs the official Saleor Storefront as part of the stack, built from the pinned `saleor/storefront` source and served as the customer-facing shop on port `9011`.
- Saleor app launches now open the storefront while the admin dashboard remains available on port `9010` with the generated `admin@example.com` credentials shown in Archipelago.
- Public Nginx Proxy Manager hosts forwarding to the Saleor storefront also expose same-origin `/graphql/`, so public storefront domains can talk to the local Saleor API without mixed-content or private-LAN reachability failures.
- Saleor stack metadata, marketplace descriptions, catalog ports, scanner exclusions, and app-session routing now describe the storefront/dashboard/API split explicitly.
## v1.7.78-alpha (2026-05-20)
- Public Nginx Proxy Manager hosts for Saleor now keep browser GraphQL calls same-origin at `/graphql/` and proxy them to the local API on `8000`, fixing `Failed to fetch` when a public domain such as `noderunner.shop` was loaded from devices that cannot reach the node's private LAN/tailnet API address.
- Saleor's validated stack changes are now release-ready: dashboard origins on port `9010` are explicitly allowed for dashboard/API calls, preserving the working test-node install path for production nodes.
- NetBird launches now stay pinned to the unified dashboard/proxy origin on port `8087` instead of following stale runtime-discovered server URLs on `8086`.
- NetBird's local nginx proxy now routes browser API, OAuth, relay, and WebSocket traffic through `host.containers.internal:8086` instead of a hard-coded rootless Podman gateway IP, and includes the upstream `management.ProxyService` gRPC path.
- The mobile credentials interstitial now keeps credential lists scrollable and action buttons reachable in both My Apps and the mobile app icon grid.
- Android WebView popup windows now hand external popup URLs to the system browser, covering app login/signup flows that open secondary windows.
- Validation passed with `git diff --check`, `cargo check -p archipelago`, and the focused `npm test -- src/views/appSession/__tests__/appSessionConfig.test.ts` suite.
## v1.7.77-alpha (2026-05-20)
- Saleor first-use now exposes generated credentials through Archipelago instead of leaving users at an unexplained dashboard login: App Details shows copyable `admin@example.com` credentials, and My Apps/mobile icon launches show a pre-launch credentials modal.
- Saleor installs now create or repair the `admin@example.com` staff account idempotently after sample data loads, use the correct dashboard mount path, and re-check stack containers after startup so stopped containers are caught.
- NetBird embedded login now uses the upstream-compatible IdP signing-key behavior and sends ID tokens from the dashboard to the management API, fixing the post-signup `Unauthenticated` state while preserving the unified local proxy/logout routes.
- Transient unnamed Podman helper containers created during app install tasks are hidden from My Apps, so generated names like `eager_keldysh` no longer appear as user applications.
- Validation passed with catalog/release JSON checks, `npm run type-check`, and `cargo fmt --all --check --manifest-path core/Cargo.toml`; live checks on `100.114.134.21` confirmed Saleor dashboard/API availability, generated Saleor admin login, NetBird OAuth availability, and NetBird logout redirects.
## v1.7.76-alpha (2026-05-20)
- Saleor installs now use dashboard port `9010`, avoiding the existing Portainer `9000` binding on the test node while keeping API `8000`, Mailpit `8025`, and Jaeger `16686` unchanged.
- Saleor's Valkey cache no longer bind-mounts `/var/lib/archipelago/saleor-cache`, and the dashboard container has the minimal rootless nginx capabilities it needs to chown cache files, bind port 80 inside the container, and drop workers to the nginx user.
- NetBird's browser proxy now sends API, OAuth, relay, WebSocket, and management traffic through the stable host-published server port at `169.254.1.2:8086`, avoiding stale rootless Podman DNS/IPs after `netbird-server` restarts.
- Mobile App Store category chips now stay visible above the tab bar, Discover is available on mobile, and category selection updates the page route/query so the selected category is actually shown.
- Apps that require a real browser tab now open directly from the app icon tap instead of first entering an in-shell app-session route, including BTCPay, Grafana, Home Assistant, Vaultwarden, Nextcloud, Portainer, OnlyOffice, Tailscale, Uptime Kuma, Gitea, and Nginx Proxy Manager.
- Validation passed with catalog JSON checks, `npm run type-check`, `cargo fmt --all --check --manifest-path core/Cargo.toml`, and `cargo check -p archipelago --manifest-path core/Cargo.toml`; live checks on `100.70.96.88` confirmed Saleor dashboard `9010`/API `8000` and NetBird API/OAuth routes survive `netbird-server` restart.
## v1.7.75-alpha (2026-05-19)
- Saleor is now published as a recommended commerce app with catalog metadata, icon, direct app-session launch on port `9000`, scanner metadata, image pins, and a full stack installer for dashboard, API, worker, PostgreSQL, Valkey, Mailpit, and Jaeger.
- Existing NetBird installs are repaired more aggressively by rewriting unified-origin config, recreating the dashboard/proxy containers, restarting the server, preserving data, and handling exact `/api` and `/oauth2` routes plus dashboard logout redirects through the local proxy.
- Desktop dashboard scrolling now hands focus back from the sidebar to the main content when the pointer or wheel moves over the main pane, preventing the sidebar scroll area from trapping wheel input on short screens.
- Validation passed with catalog JSON checks, `npm run type-check`, `cargo fmt --all --check --manifest-path core/Cargo.toml`, and `cargo check -p archipelago --manifest-path core/Cargo.toml` before release.
## v1.7.74-alpha (2026-05-19)
- App-session right panels now re-focus the iframe after load and when the frame area is activated, so wheel/touch scrolling works immediately after switching tabs or selecting an app on shorter screens.
- NetBird now launches through a unified local origin on port `8087` that proxies the dashboard plus `/oauth2`, `/api`, relay, WebSocket, and gRPC routes to `netbird-server`, fixing the embedded login flow that previously ended in `Unauthenticated` or `404 page not found` after logout.
- Existing NetBird installs are repaired on adopt/start by rewriting `config.yaml`, `dashboard.env`, and the local nginx proxy config, then creating the missing `netbird-dashboard` and `netbird` proxy containers when needed while preserving NetBird data.
- Saleor is still pending and is not included in this release; its registry/installer work remains local until it can be validated separately.
- Validation passed with catalog JSON 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.73-alpha (2026-05-19)
- Mobile app launches for iframe-blocked apps now open the direct app URL in a new browser tab immediately instead of landing in a broken in-shell webview that requires a second tap.
- Mobile My Apps/Websites tabs now react to route query changes, App Store pages label the mobile view as Discover, mobile filters have safe bottom spacing, and App Store search ignores the current category so searches cover all available apps.
- My Apps search now surfaces matching App Store entries when the app is not installed, making it possible to jump directly from a failed My Apps search to the installable app details.
- NetBird self-host installs now prefer a `100.x` tailnet/CGNAT address for dashboard, management, relay, STUN, and auth redirect origins when one is present; live repair on `100.89.209.89` updated the existing stack from LAN origins to `100.89.209.89` and restored `netbird-server`.
- App-session iframe frames now focus automatically and wrap the iframe in a scroll host so wheel/touch scrolling works in the active right frame without requiring an initial click.
## v1.7.72-alpha (2026-05-19)
- Settings What's New now includes the missing release notes for `v1.7.68-alpha` through `v1.7.71-alpha`, so the modal reflects the current OTA history instead of stopping at `v1.7.67-alpha`.
- The follow-up release carries the NetBird install fix, Gitea icon polish, mobile app-session fallback updates, and rounder app icon masks from `v1.7.71-alpha` with the Settings modal notes included.
- The local Cargo lockfile version metadata is kept in sync with the release bump after the previous release build updated it.
## v1.7.71-alpha (2026-05-19)
- NetBird stack installs now pre-create `/var/lib/archipelago/netbird/data` before binding it into `netbird-server`, fixing the failed install/start path seen on `100.70.96.88` where Podman rejected the missing host directory.
- NetBird start/restart ordering now starts `netbird-server` before the dashboard container so lifecycle actions bring the control plane up before the UI.
- App-session invalid IDs and panel-mode fallbacks now return to `/dashboard/apps`, avoiding the stale `/apps` route that could render a 404.
- Mobile launches for apps that block iframes now stay inside the Archipelago app-session fallback instead of automatically opening an external browser tab.
- Installed Gitea containers now report the packaged Gitea icon, and app icon masks use a rounder radius on mobile grids, app cards, and detail headers.
- Validation passed with `npm run type-check`, focused Vitest app-session/app-grid tests, `cargo fmt --all --check --manifest-path core/Cargo.toml`, and `cargo check -p archipelago --manifest-path core/Cargo.toml`.
## 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.
+1 -1
View File
@@ -21,7 +21,7 @@
- **Fedimint** federation guardian and gateway
### Self-Hosted Apps (30)
Bitcoin (ThunderHub), Storage (FileBrowser, Immich, Nextcloud), Productivity (Penpot, OnlyOffice, Vaultwarden), Media (Jellyfin, PhotoPrism), Search (SearXNG), AI (Ollama), Network (Tailscale, Nginx Proxy Manager), Home (Home Assistant), Nostr (nostr-rs-relay, Nostrudel), Dev (Grafana, Portainer), and more.
Bitcoin (ThunderHub), Storage (FileBrowser, Immich, Nextcloud), Productivity (Penpot, Vaultwarden), Media (Jellyfin, PhotoPrism), Search (SearXNG), AI (Ollama), Network (Tailscale, Nginx Proxy Manager), Home (Home Assistant), Nostr (nostr-rs-relay, Nostrudel), Dev (Grafana, Portainer), and more.
### Decentralized Identity
- Ed25519 node identity with DID Documents (did:key)
+302 -37
View File
@@ -14,7 +14,7 @@
"id": "bitcoin-knots",
"title": "Bitcoin Knots",
"version": "28.1.0",
"description": "Run a full Bitcoin node. Validate and relay blocks and transactions.",
"description": "Full Bitcoin Knots node with dynamic prune/full-mode startup based on host disk.",
"icon": "/assets/img/app-icons/bitcoin-knots.webp",
"author": "Bitcoin Knots",
"category": "money",
@@ -25,20 +25,20 @@
{
"id": "bitcoin-core",
"title": "Bitcoin Core",
"version": "28.4",
"description": "Reference implementation of the Bitcoin protocol. Run a full node validating and relaying blocks.",
"version": "28.4.0",
"description": "Reference Bitcoin Core node with dynamic prune/full-mode startup based on host disk.",
"icon": "/assets/img/app-icons/bitcoin-core.svg",
"author": "Bitcoin Core contributors",
"category": "money",
"tier": "optional",
"dockerImage": "docker.io/bitcoin/bitcoin:28.4",
"dockerImage": "146.59.87.168:3000/lfg2025/bitcoin:28.4",
"repoUrl": "https://github.com/bitcoin/bitcoin"
},
{
"id": "lnd",
"title": "LND",
"version": "0.18.4",
"description": "Lightning Network Daemon. Fast Bitcoin payments through Lightning.",
"description": "Lightning Network implementation by Lightning Labs. Enables instant, low-cost Bitcoin payments.",
"icon": "/assets/img/app-icons/lnd.svg",
"author": "Lightning Labs",
"category": "money",
@@ -52,13 +52,13 @@
{
"id": "btcpay-server",
"title": "BTCPay Server",
"version": "1.13.7",
"description": "Self-hosted Bitcoin payment processor.",
"version": "2.3.9",
"description": "Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries.",
"icon": "/assets/img/app-icons/btcpay-server.png",
"author": "BTCPay Server Foundation",
"category": "commerce",
"tier": "core",
"dockerImage": "146.59.87.168:3000/lfg2025/btcpayserver:1.13.7",
"dockerImage": "docker.io/btcpayserver/btcpayserver:2.3.9",
"repoUrl": "https://github.com/btcpayserver/btcpayserver",
"requires": [
"bitcoin-knots"
@@ -68,7 +68,7 @@
"id": "mempool",
"title": "Mempool Explorer",
"version": "3.0.0",
"description": "Self-hosted Bitcoin blockchain and mempool visualizer.",
"description": "Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.",
"icon": "/assets/img/app-icons/mempool.webp",
"author": "Mempool",
"category": "money",
@@ -84,8 +84,8 @@
"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",
"description": "Electrum server indexing Bitcoin chain data for lightweight wallet queries.",
"icon": "/assets/img/app-icons/electrumx.png",
"author": "Luke Childs",
"category": "money",
"tier": "core",
@@ -99,7 +99,7 @@
"id": "indeedhub",
"title": "IndeeHub",
"version": "1.0.0",
"description": "Bitcoin documentary streaming with Nostr identity.",
"description": "Bitcoin documentary streaming platform featuring God Bless Bitcoin and other educational content about Bitcoin, sovereignty, and decentralized technology. Sign in with your Nostr identity.",
"icon": "/assets/img/app-icons/indeedhub.png",
"author": "IndeeHub",
"category": "community",
@@ -110,35 +110,134 @@
"id": "botfights",
"title": "BotFights",
"version": "1.1.0",
"description": "Bot arena + 2-player arcade fighter with controller support and Adventure Mode.",
"description": "Bot competition arena with 2-player arcade fighting mode. AI bots battle in trivia challenges while humans duke it out with controllers. Built for Bitcoiners.",
"icon": "/assets/img/app-icons/botfights.svg",
"author": "BotFights",
"category": "community",
"dockerImage": "146.59.87.168:3000/lfg2025/botfights:1.1.0",
"repoUrl": "https://botfights.net"
"repoUrl": "https://botfights.net",
"containerConfig": {
"ports": [
"9100:9100"
],
"volumes": [
"/var/lib/archipelago/botfights:/app/server/data"
],
"env": [
"NODE_ENV=production",
"PORT=9100",
"FIGHT_LOOP_ENABLED=true",
"ARCHY_EMBEDDED=1"
]
}
},
{
"id": "gitea",
"title": "Gitea",
"version": "1.23",
"description": "Self-hosted Git service with container registry, CI/CD, issue tracking.",
"description": "Self-hosted Git service with built-in container registry, CI/CD, and package hosting.",
"icon": "/assets/img/app-icons/gitea.svg",
"author": "Gitea",
"category": "development",
"dockerImage": "docker.io/gitea/gitea:1.23",
"repoUrl": "https://gitea.com"
"repoUrl": "https://gitea.com",
"containerConfig": {
"ports": [
"3001:3000",
"2222:22"
],
"volumes": [
"/var/lib/archipelago/gitea/data:/data",
"/var/lib/archipelago/gitea/config:/etc/gitea"
],
"env": [
"GITEA__database__DB_TYPE=sqlite3",
"GITEA__server__SSH_PORT=2222",
"GITEA__server__SSH_LISTEN_PORT=22",
"GITEA__server__LFS_START_SERVER=true",
"GITEA__packages__ENABLED=true",
"GITEA__repository__ENABLE_PUSH_CREATE_USER=true",
"GITEA__repository__ENABLE_PUSH_CREATE_ORG=true",
"GITEA__security__X_FRAME_OPTIONS="
]
},
"tier": "optional"
},
{
"id": "filebrowser",
"title": "File Browser",
"version": "2.27.0",
"description": "Web-based file manager.",
"description": "Baseline Archipelago file manager service.",
"icon": "/assets/img/app-icons/file-browser.webp",
"author": "File Browser",
"category": "data",
"tier": "core",
"dockerImage": "146.59.87.168:3000/lfg2025/filebrowser:v2.27.0",
"repoUrl": "https://github.com/filebrowser/filebrowser"
"dockerImage": "git.tx1138.com/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": "nostr-rs-relay",
"title": "Nostr Relay (Rust)",
"version": "0.8.0",
"description": "High-performance Nostr relay written in Rust. Host your own decentralized social media relay and earn networking profits.",
"icon": "/assets/img/app-icons/nostr.svg",
"author": "Nostr RS Relay",
"category": "community",
"tier": "recommended",
"dockerImage": "scsibug/nostr-rs-relay:0.8.9",
"repoUrl": "https://github.com/scsibug/nostr-rs-relay",
"containerConfig": {
"ports": [
"8081:8080"
],
"volumes": [
"/var/lib/archipelago/nostr-relay:/usr/src/app/db"
],
"env": [
"RELAY_NAME=Archipelago Nostr Relay",
"RELAY_DESCRIPTION=Self-hosted Nostr relay on Archipelago"
]
}
},
{
"id": "meshtastic",
"title": "Meshtastic",
"version": "2-daily-alpine",
"description": "Open-source mesh networking for LoRa radios. Create decentralized communication networks.",
"icon": "/assets/img/app-icons/meshcore.svg",
"author": "Meshtastic",
"category": "networking",
"tier": "recommended",
"dockerImage": "docker.io/meshtastic/meshtasticd:daily-alpine",
"repoUrl": "https://github.com/meshtastic/firmware",
"containerConfig": {
"ports": [
"4403:4403"
],
"volumes": [
"/var/lib/archipelago/meshtastic:/var/lib/meshtasticd"
],
"env": [
"MESHTASTIC_PORT=/dev/ttyUSB0",
"MESHTASTIC_SERIAL=true"
],
"notes": "Requires a LoRa radio device at /dev/ttyUSB0. The config file is rendered from the app manifest before container start."
}
},
{
"id": "vaultwarden",
@@ -150,31 +249,68 @@
"category": "data",
"tier": "recommended",
"dockerImage": "146.59.87.168:3000/lfg2025/vaultwarden:1.30.0-alpine",
"repoUrl": "https://github.com/dani-garcia/vaultwarden"
"repoUrl": "https://github.com/dani-garcia/vaultwarden",
"containerConfig": {
"ports": [
"8082:80"
],
"volumes": [
"/var/lib/archipelago/vaultwarden:/data"
]
}
},
{
"id": "searxng",
"title": "SearXNG",
"version": "2024.1.0",
"description": "Privacy-respecting metasearch engine.",
"version": "1.0.0",
"description": "Privacy-respecting metasearch engine. Search the web without tracking.",
"icon": "/assets/img/app-icons/searxng.png",
"author": "SearXNG",
"category": "data",
"tier": "recommended",
"dockerImage": "146.59.87.168:3000/lfg2025/searxng:latest",
"repoUrl": "https://github.com/searxng/searxng"
"repoUrl": "https://github.com/searxng/searxng",
"containerConfig": {
"ports": [
"8888:8080"
],
"volumes": [
"/var/lib/archipelago/searxng:/etc/searxng"
]
}
},
{
"id": "fedimint",
"title": "Fedimint",
"version": "0.10.0",
"description": "Federated Bitcoin mint with privacy through federated guardians.",
"description": "Federated Bitcoin minting service with built-in Guardian UI. Privacy-preserving Bitcoin custody.",
"icon": "/assets/img/app-icons/fedimint.png",
"author": "Fedimint",
"category": "money",
"dockerImage": "146.59.87.168:3000/lfg2025/fedimintd:v0.10.0",
"repoUrl": "https://github.com/fedimint/fedimint"
},
{
"id": "fedimint-gateway",
"title": "Fedimint Gateway",
"version": "0.10.0",
"description": "Fedimint gateway service with automatic LND-or-LDK backend selection.",
"icon": "/assets/img/app-icons/fedimint.png",
"author": "Fedimint",
"category": "money",
"dockerImage": "git.tx1138.com/lfg2025/gatewayd:v0.10.0",
"repoUrl": "https://github.com/fedimint/fedimint",
"containerConfig": {
"ports": [
"8176:8176",
"9737:9737"
],
"volumes": [
"/var/lib/archipelago/fedimint-gateway:/data",
"/var/lib/archipelago/lnd:/lnd:ro"
]
}
},
{
"id": "jellyfin",
"title": "Jellyfin",
@@ -184,7 +320,16 @@
"author": "Jellyfin",
"category": "data",
"dockerImage": "146.59.87.168:3000/lfg2025/jellyfin:10.8.13",
"repoUrl": "https://github.com/jellyfin/jellyfin"
"repoUrl": "https://github.com/jellyfin/jellyfin",
"containerConfig": {
"ports": [
"8096:8096"
],
"volumes": [
"/var/lib/archipelago/jellyfin/config:/config",
"/var/lib/archipelago/jellyfin/cache:/cache"
]
}
},
{
"id": "immich",
@@ -200,25 +345,48 @@
{
"id": "homeassistant",
"title": "Home Assistant",
"version": "2024.1",
"description": "Open-source home automation.",
"version": "2024.1.0",
"description": "Open source home automation platform. Control and monitor your smart home devices.",
"icon": "/assets/img/app-icons/homeassistant.png",
"author": "Home Assistant",
"category": "home",
"dockerImage": "146.59.87.168:3000/lfg2025/home-assistant:2024.1",
"repoUrl": "https://github.com/home-assistant/core"
"repoUrl": "https://github.com/home-assistant/core",
"containerConfig": {
"ports": [
"8123:8123"
],
"volumes": [
"/var/lib/archipelago/home-assistant:/config"
],
"env": [
"TZ=UTC"
]
}
},
{
"id": "grafana",
"title": "Grafana",
"version": "10.2.0",
"description": "Analytics and monitoring dashboards.",
"description": "Analytics and monitoring platform. Visualize metrics and create dashboards.",
"icon": "/assets/img/app-icons/grafana.png",
"author": "Grafana Labs",
"category": "data",
"tier": "recommended",
"dockerImage": "146.59.87.168:3000/lfg2025/grafana:10.2.0",
"repoUrl": "https://github.com/grafana/grafana"
"dockerImage": "grafana/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",
@@ -230,7 +398,68 @@
"category": "networking",
"tier": "recommended",
"dockerImage": "146.59.87.168:3000/lfg2025/tailscale:stable",
"repoUrl": "https://github.com/tailscale/tailscale"
"repoUrl": "https://github.com/tailscale/tailscale",
"containerConfig": {
"ports": [
"8240:8240"
],
"volumes": [
"/var/lib/archipelago/tailscale:/var/lib/tailscale"
],
"env": [
"TS_STATE_DIR=/var/lib/tailscale"
],
"args": [
"sh",
"-c",
"tailscaled --tun=userspace-networking & for i in $(seq 1 30); do [ -S /var/run/tailscale/tailscaled.sock ] && break; sleep 1; done; tailscale web --listen 0.0.0.0:8240 & wait"
]
}
},
{
"id": "portainer",
"title": "Portainer",
"version": "2.19.4",
"description": "Container management web UI for the local Podman socket.",
"icon": "/assets/img/app-icons/portainer.webp",
"author": "Portainer",
"category": "development",
"tier": "optional",
"dockerImage": "146.59.87.168:3000/lfg2025/portainer:latest",
"repoUrl": "https://github.com/portainer/portainer",
"containerConfig": {
"ports": [
"9000:9000"
],
"volumes": [
"/var/lib/archipelago/portainer:/data",
"/run/user/1000/podman/podman.sock:/var/run/docker.sock"
],
"notes": "Uses the manifest-owned Podman socket bind mount preparation path."
}
},
{
"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",
@@ -242,7 +471,23 @@
"category": "data",
"tier": "recommended",
"dockerImage": "146.59.87.168:3000/lfg2025/uptime-kuma:1",
"repoUrl": "https://github.com/louislam/uptime-kuma"
"repoUrl": "https://github.com/louislam/uptime-kuma",
"containerConfig": {
"ports": [
"3002:3001"
],
"volumes": [
"/var/lib/archipelago/uptime-kuma:/app/data"
],
"env": [
"TZ=UTC"
],
"args": [
"--",
"node",
"server/server.js"
]
}
},
{
"id": "photoprism",
@@ -253,18 +498,38 @@
"author": "PhotoPrism",
"category": "data",
"dockerImage": "146.59.87.168:3000/lfg2025/photoprism:240915",
"repoUrl": "https://github.com/photoprism/photoprism"
"repoUrl": "https://github.com/photoprism/photoprism",
"containerConfig": {
"ports": [
"2342:2342"
],
"volumes": [
"/var/lib/archipelago/photoprism:/photoprism/storage"
],
"env": [
"PHOTOPRISM_ADMIN_PASSWORD=archipelago",
"PHOTOPRISM_DEFAULT_LOCALE=en"
]
}
},
{
"id": "nextcloud",
"title": "Nextcloud",
"version": "28",
"version": "29",
"description": "Your own private cloud. File sync, calendars, contacts.",
"icon": "/assets/img/app-icons/nextcloud.webp",
"author": "Nextcloud",
"category": "data",
"dockerImage": "docker.io/nextcloud:28",
"repoUrl": "https://github.com/nextcloud/server"
"dockerImage": "146.59.87.168:3000/lfg2025/nextcloud:29",
"repoUrl": "https://github.com/nextcloud/server",
"containerConfig": {
"ports": [
"8085:80"
],
"volumes": [
"/var/lib/archipelago/nextcloud:/var/www/html"
]
}
}
]
}
-1
View File
@@ -33,7 +33,6 @@
| ollama | 11434 | v0.5.4 |
| grafana | 3001 | v10.2.0 |
| portainer | 9000 | v2.19.4 |
| onlyoffice | 8088 | v7.5.1 |
| penpot | 8089 | v2.4 |
## Building Apps
+1 -3
View File
@@ -17,9 +17,8 @@ This document lists all port assignments for Archipelago apps.
| mempool | 4080 | TCP | Web UI | 14080 |
| ollama | 11434 | TCP | API | 21434 |
| 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 |
@@ -47,7 +46,6 @@ In development mode, all ports are offset by 10000 to avoid conflicts with produ
| Mempool | http://localhost:14080 |
| Ollama | http://localhost:21434 |
| SearXNG | http://localhost:18888 |
| OnlyOffice | http://localhost:18088 |
| Penpot | http://localhost:18089 |
| LND REST | http://localhost:18080 |
| Core Lightning | http://localhost:19835 |
+1 -1
View File
@@ -23,7 +23,7 @@ Containerized applications for the Archipelago Bitcoin Node OS. All apps run in
### Self-Hosted Services
- **nextcloud** (v28), **jellyfin** (v10.8.13), **immich** (release), **photoprism** (v240915)
- **vaultwarden** (v1.30.0-alpine), **onlyoffice** (v7.5.1), **penpot** (v2.4)
- **vaultwarden** (v1.30.0-alpine), **penpot** (v2.4)
- **homeassistant** (v2024.1), **filebrowser** (v2.27.0), **searxng** (2024.11.17)
- **ollama** (v0.5.4), **grafana** (v10.2.0), **portainer** (v2.19.4)
-6
View File
@@ -27,12 +27,6 @@ app:
container: 8080
protocol: tcp
volumes:
- type: bind
source: /var/lib/archipelago/mempool/nginx.conf
target: /etc/nginx/conf.d/default.conf
options: [ro]
environment:
- FRONTEND_HTTP_PORT=8080
- BACKEND_MAINNET_HTTP_HOST=mempool-api
+5 -3
View File
@@ -47,15 +47,17 @@ app:
- NBXPLORER_BIND=0.0.0.0:32838
- NBXPLORER_BTCRPCURL=http://bitcoin-knots:8332
- NBXPLORER_BTCRPCUSER=archipelago
- NBXPLORER_POSTGRES=User ID=btcpay;Password=${BTCPAY_DB_PASS};Host=archy-btcpay-db;Port=5432;Database=nbxplorer;Include Error Detail=true
- NBXPLORER_BTCNODEENDPOINT=bitcoin-knots:8333
- NBXPLORER_NOAUTH=1
- NBXPLORER_POSTGRES=Username=btcpay;Password=${BTCPAY_DB_PASS};Host=archy-btcpay-db;Port=5432;Database=nbxplorer
health_check:
type: http
endpoint: http://localhost:32838
path: /
interval: 30s
timeout: 5s
retries: 3
timeout: 30s
retries: 5
bitcoin_integration:
rpc_access: read-only
+31 -7
View File
@@ -1,22 +1,44 @@
app:
id: bitcoin-core
name: Bitcoin Knots
name: Bitcoin Core
version: 28.4.0
description: Full Bitcoin Knots node with dynamic prune/full-mode startup based on host disk.
description: Reference Bitcoin Core node with dynamic prune/full-mode startup based on host disk.
container_name: bitcoin-knots
container_name: bitcoin-core
container:
image: 146.59.87.168:3000/lfg2025/bitcoin-knots:latest
image: 146.59.87.168:3000/lfg2025/bitcoin:28.4
pull_policy: if-not-present
network: archy-net
entrypoint: ["sh", "-lc"]
custom_args:
# Sync-speed flags: -par=0 uses every core (was capped at 2 by
# --cpus=2, now removed for bitcoin/electrumx). -dbcache sized to
# the IBD sweet spot - 4GB on full nodes, 1GB on pruned. Container
# --memory=8g (config.rs::get_memory_limit) leaves headroom for
# mempool + connections.
- >-
if [ "${DISK_GB:-0}" -lt 1000 ]; then
exec bitcoind -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=512 -rpcuser="${BITCOIN_RPC_USER}" -rpcpassword="${BITCOIN_RPC_PASS}";
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)";
RPC_TXRELAY_AUTH="$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)";
DISK_GB_VALUE="$(printenv DISK_GB || true)";
RPC_HEADROOM="-rpcthreads=16 -rpcworkqueue=256";
RPC_TXRELAY_FLAGS="-rpcwhitelistdefault=0";
if [ -n "$RPC_TXRELAY_AUTH" ]; then
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblockheader,getrawtransaction,decoderawtransaction,decodescript,estimatesmartfee";
fi;
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=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
else
exec bitcoind -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -rpcuser="${BITCOIN_RPC_USER}" -rpcpassword="${BITCOIN_RPC_PASS}";
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
fi
derived_env:
- key: DISK_GB
@@ -24,6 +46,8 @@ app:
secret_env:
- key: BITCOIN_RPC_PASS
secret_file: bitcoin-rpc-password
- key: BITCOIN_RPC_TXRELAY_RPCAUTH
secret_file: bitcoin-rpc-txrelay-rpcauth
data_uid: "100101:100101"
dependencies:
+94
View File
@@ -0,0 +1,94 @@
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)";
RPC_TXRELAY_AUTH="$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)";
DISK_GB_VALUE="$(printenv DISK_GB || true)";
RPC_HEADROOM="-rpcthreads=16 -rpcworkqueue=256";
RPC_TXRELAY_FLAGS="-rpcwhitelistdefault=0";
if [ -n "$RPC_TXRELAY_AUTH" ]; then
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblockheader,getrawtransaction,decoderawtransaction,decodescript,estimatesmartfee";
fi;
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 $RPC_HEADROOM $RPC_TXRELAY_FLAGS -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 $RPC_HEADROOM $RPC_TXRELAY_FLAGS -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
- key: BITCOIN_RPC_TXRELAY_RPCAUTH
secret_file: bitcoin-rpc-txrelay-rpcauth
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
+4 -2
View File
@@ -1,12 +1,12 @@
app:
id: botfights
name: BotFights
version: 1.0.0
version: 1.1.0
description: Bot competition arena with 2-player arcade fighting mode. AI bots battle in trivia challenges while humans duke it out with controllers. Built for Bitcoiners.
category: community
container:
image: git.tx1138.com/lfg2025/botfights:1.1.0
image: 146.59.87.168:3000/lfg2025/botfights:1.1.0
pull_policy: always
dependencies:
@@ -62,6 +62,8 @@ app:
metadata:
author: Dorian
repo: https://botfights.net
icon: /assets/img/app-icons/botfights.svg
license: MIT
tags:
- bitcoin
+21 -6
View File
@@ -1,11 +1,11 @@
app:
id: btcpay-server
name: BTCPay Server
version: 1.13.7
version: 2.3.9
description: Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries.
container:
image: git.tx1138.com/lfg2025/btcpayserver:1.13.7
image: docker.io/btcpayserver/btcpayserver:2.3.9
pull_policy: if-not-present
network: archy-net
secret_env:
@@ -13,6 +13,9 @@ app:
secret_file: bitcoin-rpc-password
- key: BTCPAY_DB_PASS
secret_file: btcpay-db-password
derived_env:
- key: BTCPAY_HOST
template: "{{HOST_IP}}:23000"
dependencies:
- app_id: bitcoin-core
@@ -46,20 +49,19 @@ app:
environment:
- ASPNETCORE_URLS=http://0.0.0.0:49392
- BTCPAY_PROTOCOL=http
- BTCPAY_HOST=127.0.0.1:23000
- BTCPAY_CHAINS=btc
- BTCPAY_BTCEXPLORERURL=http://archy-nbxplorer:32838
- BTCPAY_BTCRPCURL=http://bitcoin-knots:8332
- BTCPAY_BTCRPCUSER=archipelago
- BTCPAY_POSTGRES=User ID=btcpay;Password=${BTCPAY_DB_PASS};Host=archy-btcpay-db;Port=5432;Database=btcpay;Include Error Detail=true
- BTCPAY_POSTGRES=Username=btcpay;Password=${BTCPAY_DB_PASS};Host=archy-btcpay-db;Port=5432;Database=btcpay
health_check:
type: http
endpoint: http://localhost:49392
path: /
interval: 30s
timeout: 5s
retries: 3
timeout: 30s
retries: 5
bitcoin_integration:
rpc_access: read-only
@@ -68,3 +70,16 @@ app:
lightning_integration:
payment_processing: false
invoice_management: true
interfaces:
main:
name: Web UI
description: BTCPay Server dashboard
type: ui
port: 23000
protocol: http
path: /
metadata:
launch:
open_in_new_tab: true
+9 -5
View File
@@ -5,26 +5,27 @@ app:
description: Electrum server indexing Bitcoin chain data for lightweight wallet queries.
container:
image: git.tx1138.com/lfg2025/electrumx:v1.18.0
image: 146.59.87.168:3000/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:${BITCOIN_RPC_PASS}@bitcoin-knots:8332/";
export DAEMON_URL="http://archipelago:$(printenv BITCOIN_RPC_PASS)@bitcoin-knots:8332/";
exec electrumx_server
secret_env:
- key: BITCOIN_RPC_PASS
secret_file: bitcoin-rpc-password
dependencies:
- app_id: bitcoin-core
- app_id: bitcoin-knots
version: ">=26.0"
- storage: 50Gi
resources:
cpu_limit: 2
memory_limit: 2Gi
cpu_limit: 0
memory_limit: 4Gi
disk_limit: 50Gi
security:
@@ -47,6 +48,8 @@ app:
- COIN=Bitcoin
- DB_DIRECTORY=/data
- SERVICES=tcp://:50001,rpc://0.0.0.0:8000
- CACHE_MB=3072
- MAX_SEND=10000000
health_check:
type: tcp
@@ -58,3 +61,4 @@ app:
bitcoin_integration:
rpc_access: read-only
sync_required: true
pruning_support: false
+3 -3
View File
@@ -12,16 +12,16 @@ app:
custom_args:
- >-
if [ -f /lnd/tls.cert ] && [ -f /lnd/data/chain/bitcoin/mainnet/admin.macaroon ]; then
exec gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash "$FEDI_HASH" --network bitcoin --bitcoind-url http://bitcoin-knots:8332 --bitcoind-username "$FM_BITCOIND_USERNAME" --bitcoind-password "$FM_BITCOIND_PASSWORD" lnd --lnd-rpc-host lnd:10009 --lnd-tls-cert /lnd/tls.cert --lnd-macaroon /lnd/data/chain/bitcoin/mainnet/admin.macaroon;
exec gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash "$FEDI_HASH" --network bitcoin --bitcoind-url http://host.archipelago:8332 --bitcoind-username "$FM_BITCOIND_USERNAME" --bitcoind-password "$FM_BITCOIND_PASSWORD" lnd --lnd-rpc-host lnd:10009 --lnd-tls-cert /lnd/tls.cert --lnd-macaroon /lnd/data/chain/bitcoin/mainnet/admin.macaroon;
else
exec gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash "$FEDI_HASH" --network bitcoin --bitcoind-url http://bitcoin-knots:8332 --bitcoind-username "$FM_BITCOIND_USERNAME" --bitcoind-password "$FM_BITCOIND_PASSWORD" ldk --ldk-lightning-port 9737 --ldk-alias archipelago-gateway;
exec gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash "$FEDI_HASH" --network bitcoin --bitcoind-url http://host.archipelago:8332 --bitcoind-username "$FM_BITCOIND_USERNAME" --bitcoind-password "$FM_BITCOIND_PASSWORD" ldk --ldk-lightning-port 9737 --ldk-alias archipelago-gateway;
fi
secret_env:
- key: FM_BITCOIND_PASSWORD
secret_file: bitcoin-rpc-password
- key: FEDI_HASH
secret_file: fedimint-gateway-hash
data_uid: "100000:100000"
data_uid: "1000:1000"
dependencies:
- app_id: bitcoin-core
+22 -3
View File
@@ -5,9 +5,17 @@ app:
description: Federated Bitcoin minting service with built-in Guardian UI. Privacy-preserving Bitcoin custody.
container:
image: git.tx1138.com/lfg2025/fedimintd:v0.10.0
image: 146.59.87.168:3000/lfg2025/fedimintd:v0.10.0
pull_policy: if-not-present
network: archy-net
entrypoint: ["sh", "-lc"]
custom_args:
- |-
until state="$(curl -sS --connect-timeout 5 -m 45 -u "$FM_BITCOIND_USERNAME:$FM_BITCOIND_PASSWORD" -H "Content-Type: application/json" --data-binary '{"jsonrpc":"1.0","id":"fedimint-wait","method":"getblockchaininfo","params":[]}' "$FM_BITCOIND_URL/")" && echo "$state" | grep -q '"initialblockdownload":false'; do
echo "Waiting for Bitcoin RPC sync at $FM_BITCOIND_URL...";
sleep 30;
done;
exec fedimintd
derived_env:
- key: FM_P2P_URL
template: fedimint://{{HOST_MDNS}}:8173
@@ -16,7 +24,7 @@ app:
secret_env:
- key: FM_BITCOIND_PASSWORD
secret_file: bitcoin-rpc-password
data_uid: "100000:100000"
data_uid: "1000:1000"
dependencies:
- app_id: bitcoin-core
@@ -40,7 +48,9 @@ app:
- host: 8174
container: 8174
protocol: tcp
- host: 8175
# Public launch port 8175 is owned by archy-fedimint-ui, which serves a
# wait page while Bitcoin syncs and proxies here after fedimintd starts.
- host: 8177
container: 8175
protocol: tcp
@@ -67,6 +77,15 @@ app:
timeout: 5s
retries: 3
interfaces:
main:
name: Guardian UI
description: Fedimint Guardian wait/proxy UI
type: ui
port: 8175
protocol: http
path: /
bitcoin_integration:
rpc_access: admin
sync_required: true
+81 -47
View File
@@ -1,53 +1,87 @@
id: gitea
name: Gitea
version: "1.23"
description: Self-hosted Git service with built-in container registry, CI/CD, and package hosting.
category: development
icon: git-branch
port: 3000
internal_port: 3001
ssh_port: 2222
image: docker.io/gitea/gitea:1.23
tier: optional
app:
id: gitea
name: Gitea
version: "1.23"
description: Self-hosted Git service with built-in container registry, CI/CD, and package hosting.
category: development
requires:
memory_mb: 256
disk_mb: 500
container:
image: docker.io/gitea/gitea:1.23
pull_policy: if-not-present
volumes:
- host: /var/lib/archipelago/gitea/data
container: /data
- host: /var/lib/archipelago/gitea/config
container: /etc/gitea
dependencies:
- storage: 500Mi
environment:
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"
resources:
memory_limit: 256Mi
disk_limit: 500Mi
# 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.
nginx_proxy:
listen: 3000
proxy_pass: "http://127.0.0.1:3001"
extra_headers:
- "proxy_hide_header X-Frame-Options"
- "proxy_hide_header Content-Security-Policy"
security:
capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE, NET_BIND_SERVICE]
readonly_root: false
no_new_privileges: false
network_policy: bridge
health_check:
endpoint: /
interval: 120
timeout: 5
retries: 3
ports:
- host: 3001
container: 3000
protocol: tcp
- host: 2222
container: 22
protocol: tcp
features:
- Git repositories with web UI
- Built-in container/package registry
- Issue tracking and pull requests
- CI/CD via Gitea Actions
- Lightweight (SQLite, no external DB needed)
volumes:
- type: bind
source: /var/lib/archipelago/gitea/data
target: /data
options: [rw]
- type: bind
source: /var/lib/archipelago/gitea/config
target: /etc/gitea
options: [rw]
environment:
- 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
health_check:
type: http
endpoint: http://localhost:3000
path: /
interval: 120s
timeout: 30s
retries: 5
interfaces:
main:
name: Web UI
description: Gitea web interface
type: ui
port: 3001
protocol: http
path: /
metadata:
icon: /assets/img/app-icons/gitea.svg
repo: https://gitea.com
tier: optional
launch:
open_in_new_tab: true
features:
- Git repositories with web UI
- Built-in container/package registry
- Issue tracking and pull requests
- CI/CD via Gitea Actions
- Lightweight SQLite deployment
nginx_proxy:
listen: 3000
proxy_pass: http://127.0.0.1:3001
extra_headers:
- proxy_hide_header X-Frame-Options
- proxy_hide_header Content-Security-Policy
+10 -5
View File
@@ -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,13 +41,17 @@ 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
retries: 3
timeout: 30s
retries: 5
metadata:
launch:
open_in_new_tab: true
+26 -18
View File
@@ -1,29 +1,29 @@
app:
id: home-assistant
id: homeassistant
name: Home Assistant
version: 2024.1.0
description: Open source home automation platform. Control and monitor your smart home devices.
container:
image: homeassistant/home-assistant:2024.1
image_signature: cosign://...
image: 146.59.87.168:3000/lfg2025/home-assistant:2024.1
pull_policy: if-not-present
network: pasta
dependencies:
- storage: 10Gi
resources:
cpu_limit: 2
memory_limit: 2Gi
memory_limit: 512Mi
disk_limit: 10Gi
security:
capabilities: [NET_BIND_SERVICE]
capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE, NET_BIND_SERVICE, NET_RAW]
readonly_root: false # Home Assistant needs write access
no_new_privileges: true
user: 1000
seccomp_profile: default
network_policy: host # Requires host network for device discovery
network_policy: isolated
apparmor_profile: home-assistant
ports:
@@ -36,24 +36,32 @@ app:
source: /var/lib/archipelago/home-assistant
target: /config
options: [rw]
- type: bind
source: /var/run/dbus
target: /var/run/dbus
options: [ro]
devices:
- /dev/ttyUSB0 # Serial devices
- /dev/ttyACM0 # USB devices
devices: []
environment:
- TZ=UTC
- PUID=1000
- PGID=1000
health_check:
type: http
endpoint: http://localhost:8123
path: /api/
type: tcp
endpoint: localhost:8123
interval: 30s
timeout: 5s
retries: 3
interfaces:
main:
name: Web UI
description: Home Assistant dashboard
type: ui
port: 8123
protocol: http
path: /
metadata:
icon: /assets/img/app-icons/homeassistant.png
category: home
author: Home Assistant
repo: https://github.com/home-assistant/core
launch:
open_in_new_tab: true
+17 -9
View File
@@ -1,13 +1,14 @@
app:
id: indeedhub
name: Indeehub
version: 0.1.0
name: IndeeHub
version: 1.0.0
description: Bitcoin documentary streaming platform featuring God Bless Bitcoin and other educational content about Bitcoin, sovereignty, and decentralized technology. Sign in with your Nostr identity.
category: media
category: community
container:
image: 146.59.87.168:3000/lfg2025/indeedhub:latest
image: 146.59.87.168:3000/lfg2025/indeedhub:1.0.0
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,14 +64,15 @@ app:
name: Web UI
description: Stream Bitcoin documentaries with Nostr identity
type: ui
port: 7777
port: 7778
protocol: http
path: /
metadata:
author: Indeehub Team
icon: /assets/img/app-icons/indeedhub.png
website: https://indeedhub.com
source: https://github.com/indeedhub/indeedhub
repo: https://github.com/indeedhub/indeedhub
license: MIT
tags:
- bitcoin
+61
View File
@@ -0,0 +1,61 @@
app:
id: jellyfin
name: Jellyfin
version: 10.8.13
description: Free media server. Stream movies, music, and photos.
container:
image: 146.59.87.168:3000/lfg2025/jellyfin:10.8.13
pull_policy: if-not-present
network: pasta
dependencies:
- storage: 10Gi
resources:
memory_limit: 1Gi
disk_limit: 10Gi
security:
capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE]
readonly_root: false
network_policy: isolated
ports:
- host: 8096
container: 8096
protocol: tcp
volumes:
- type: bind
source: /var/lib/archipelago/jellyfin/config
target: /config
options: [rw]
- type: bind
source: /var/lib/archipelago/jellyfin/cache
target: /cache
options: [rw]
environment: []
health_check:
type: tcp
endpoint: localhost:8096
interval: 30s
timeout: 5s
retries: 3
interfaces:
main:
name: Web UI
description: Jellyfin media dashboard
type: ui
port: 8096
protocol: http
path: /
metadata:
icon: /assets/img/app-icons/jellyfin.webp
category: data
author: Jellyfin
repo: https://github.com/jellyfin/jellyfin
+3 -3
View File
@@ -25,9 +25,9 @@ app:
network_policy: bridge
# Bridge networking via archy-net. Container nginx listens on 80;
# host nginx proxies /app/lnd/ -> 127.0.0.1:8081 -> container:80.
# host nginx proxies /app/lnd/ -> 127.0.0.1:18083 -> container:80.
ports:
- host: 8081
- host: 18083
container: 80
protocol: tcp
@@ -37,7 +37,7 @@ app:
health_check:
type: http
endpoint: http://127.0.0.1:8081
endpoint: http://127.0.0.1:18083
path: /
interval: 30s
timeout: 5s
+3 -3
View File
@@ -1,11 +1,11 @@
app:
id: lnd
name: Lightning Network Daemon
name: LND
version: 0.18.4
description: Lightning Network implementation by Lightning Labs. Enables instant, low-cost Bitcoin payments.
container:
image: git.tx1138.com/lfg2025/lnd:v0.18.4-beta
image: 146.59.87.168:3000/lfg2025/lnd:v0.18.4-beta
pull_policy: if-not-present
network: archy-net
secret_env:
@@ -34,7 +34,7 @@ app:
- host: 10009
container: 10009
protocol: tcp
- host: 8080
- host: 18080
container: 8080
protocol: tcp
+3 -2
View File
@@ -15,7 +15,7 @@ app:
secret_file: mempool-db-password
dependencies:
- app_id: bitcoin-core
- app_id: bitcoin-knots
version: ">=26.0"
- app_id: electrumx
version: ">=1.18.0"
@@ -58,7 +58,7 @@ app:
health_check:
type: http
endpoint: http://localhost:8999
path: /
path: /api/v1/backend-info
interval: 30s
timeout: 5s
retries: 3
@@ -66,3 +66,4 @@ app:
bitcoin_integration:
rpc_access: read-only
sync_required: true
pruning_support: false
+3 -3
View File
@@ -1,11 +1,11 @@
app:
id: mempool
name: Mempool
version: 2.5.0
name: Mempool Explorer
version: 3.0.0
description: Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.
container:
image: mempool/mempool:v2.5.0
image: 146.59.87.168:3000/lfg2025/mempool-frontend:v3.0.0
image_signature: cosign://...
pull_policy: if-not-present
+23 -15
View File
@@ -1,13 +1,12 @@
app:
id: meshtastic
name: Meshtastic
version: 2.5.0
version: 2-daily-alpine
description: Open-source mesh networking for LoRa radios. Create decentralized communication networks.
container:
image: meshtastic/meshtasticd:2.5.6
image_signature: cosign://...
pull_policy: verify-signature
image: docker.io/meshtastic/meshtasticd:daily-alpine
pull_policy: if-not-present
dependencies:
- storage: 1Gi
@@ -29,33 +28,42 @@ app:
ports:
- host: 4403
container: 4403
protocol: tcp # HTTP API
- host: 1883
container: 1883
protocol: tcp # MQTT (optional)
protocol: tcp # Meshtastic TCP API
devices:
- /dev/ttyUSB0 # LoRa radio device (if connected)
- /dev/ttyACM0 # Alternative device path
volumes:
- type: bind
source: /var/lib/archipelago/meshtastic
target: /app/data
target: /var/lib/meshtasticd
options: [rw]
files:
- path: /var/lib/archipelago/meshtastic/config.yaml
content: |
General:
MACAddress: AA:BB:CC:DD:EE:01
Webserver:
Port: 4403
environment:
- MESHTASTIC_PORT=/dev/ttyUSB0
- MESHTASTIC_SERIAL=true
health_check:
type: http
endpoint: http://localhost:4403
path: /health
type: cmd
endpoint: test -f /var/lib/meshtasticd/config.yaml
interval: 30s
timeout: 5s
retries: 3
timeout: 30s
retries: 5
networking:
mesh_enabled: true
local_network_access: true
metadata:
icon: /assets/img/app-icons/meshcore.svg
category: networking
tier: recommended
repo: https://github.com/meshtastic/firmware
+59
View File
@@ -0,0 +1,59 @@
app:
id: nextcloud
name: Nextcloud
version: "29"
description: Your own private cloud. File sync, calendars, contacts.
container:
image: 146.59.87.168:3000/lfg2025/nextcloud:29
pull_policy: if-not-present
network: pasta
dependencies:
- storage: 10Gi
resources:
memory_limit: 1Gi
disk_limit: 10Gi
security:
capabilities: [CHOWN, SETUID, SETGID, DAC_OVERRIDE, NET_BIND_SERVICE]
readonly_root: false
network_policy: isolated
ports:
- host: 8085
container: 80
protocol: tcp
volumes:
- type: bind
source: /var/lib/archipelago/nextcloud
target: /var/www/html
options: [rw]
environment: []
health_check:
type: tcp
endpoint: localhost:80
interval: 30s
timeout: 5s
retries: 3
interfaces:
main:
name: Web UI
description: Nextcloud file and collaboration dashboard
type: ui
port: 8085
protocol: http
path: /
metadata:
icon: /assets/img/app-icons/nextcloud.webp
category: data
author: Nextcloud
repo: https://github.com/nextcloud/server
launch:
open_in_new_tab: true
+7 -6
View File
@@ -8,6 +8,7 @@ app:
image: scsibug/nostr-rs-relay:0.8.9
image_signature: cosign://...
pull_policy: verify-signature
data_uid: "1000:1000"
dependencies:
- storage: 10Gi # For event storage
@@ -27,14 +28,14 @@ app:
apparmor_profile: nostr-relay
ports:
- host: 8081
- host: 18081
container: 8080
protocol: tcp # HTTP/WebSocket
volumes:
- type: bind
source: /var/lib/archipelago/nostr-relay
target: /app/db
target: /usr/src/app/db
options: [rw]
environment:
@@ -45,11 +46,11 @@ app:
health_check:
type: http
endpoint: http://localhost:8081
path: /health
endpoint: http://localhost:8080
path: /
interval: 30s
timeout: 5s
retries: 3
timeout: 30s
retries: 5
nostr_integration:
relay_type: public
-5
View File
@@ -1,5 +0,0 @@
# OnlyOffice - uses official image
FROM onlyoffice/documentserver:7.5.0
# Default configuration is in the image
# No additional setup needed
-50
View File
@@ -1,50 +0,0 @@
app:
id: onlyoffice
name: OnlyOffice
version: 7.5.0
description: Office suite and document collaboration. Edit documents, spreadsheets, and presentations.
container:
image: onlyoffice/documentserver:7.5.0
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: false # OnlyOffice needs write access
no_new_privileges: true
user: 1000
seccomp_profile: default
network_policy: isolated
apparmor_profile: onlyoffice
ports:
- host: 8088
container: 80
protocol: tcp # Web UI
volumes:
- type: bind
source: /var/lib/archipelago/onlyoffice
target: /var/www/onlyoffice/Data
options: [rw]
environment:
- JWT_ENABLED=false
- JWT_SECRET=${ONLYOFFICE_JWT_SECRET}
health_check:
type: http
endpoint: http://localhost:8088
path: /healthcheck
interval: 30s
timeout: 5s
retries: 3
+60
View File
@@ -0,0 +1,60 @@
app:
id: photoprism
name: PhotoPrism
version: "240915"
description: AI-powered photo management with facial recognition.
container:
image: 146.59.87.168:3000/lfg2025/photoprism:240915
pull_policy: if-not-present
dependencies:
- storage: 10Gi
resources:
memory_limit: 1Gi
disk_limit: 10Gi
security:
capabilities: [CHOWN, SETUID, SETGID]
readonly_root: false
network_policy: isolated
ports:
- host: 2342
container: 2342
protocol: tcp
volumes:
- type: bind
source: /var/lib/archipelago/photoprism
target: /photoprism/storage
options: [rw]
environment:
- PHOTOPRISM_ADMIN_PASSWORD=archipelago
- PHOTOPRISM_DEFAULT_LOCALE=en
health_check:
type: tcp
endpoint: localhost:2342
interval: 60s
timeout: 5s
retries: 3
interfaces:
main:
name: Web UI
description: PhotoPrism photo library
type: ui
port: 2342
protocol: http
path: /
metadata:
icon: /assets/img/app-icons/photoprism.svg
category: data
author: PhotoPrism
repo: https://github.com/photoprism/photoprism
launch:
open_in_new_tab: true
+64
View File
@@ -0,0 +1,64 @@
app:
id: portainer
name: Portainer
version: 2.19.4
description: Container management web UI for the local Podman socket.
category: development
container:
image: 146.59.87.168:3000/lfg2025/portainer:latest
pull_policy: if-not-present
data_uid: "1000:1000"
dependencies:
- storage: 1Gi
resources:
memory_limit: 256Mi
disk_limit: 1Gi
security:
capabilities: [CHOWN, SETUID, SETGID, DAC_OVERRIDE]
readonly_root: false
no_new_privileges: true
network_policy: isolated
ports:
- host: 9000
container: 9000
protocol: tcp
volumes:
- type: bind
source: /var/lib/archipelago/portainer
target: /data
options: [rw]
- type: bind
source: /var/lib/archipelago/portainer/compose
target: /data/compose
options: [rw]
- type: bind
source: /run/user/1000/podman/podman.sock
target: /var/run/docker.sock
options: [rw]
environment: []
interfaces:
main:
name: Web UI
description: Portainer web interface
type: ui
port: 9000
protocol: http
path: /
metadata:
icon: /assets/img/app-icons/portainer.webp
tier: optional
launch:
open_in_new_tab: true
features:
- Container management dashboard
- Local Podman socket access
- Compose stack storage
+5 -6
View File
@@ -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,8 +42,8 @@ app:
health_check:
type: http
endpoint: http://localhost:8888
endpoint: http://localhost:8080
path: /
interval: 30s
timeout: 5s
retries: 3
timeout: 30s
retries: 5
+54
View File
@@ -0,0 +1,54 @@
app:
id: uptime-kuma
name: Uptime Kuma
version: 1.23.0
description: Self-hosted uptime monitoring.
container:
image: 146.59.87.168:3000/lfg2025/uptime-kuma:1
pull_policy: if-not-present
network: pasta
custom_args: ["--", "node", "server/server.js"]
dependencies:
- storage: 1Gi
resources:
memory_limit: 256Mi
disk_limit: 1Gi
security:
capabilities: [CHOWN, FOWNER, SETUID, SETGID]
readonly_root: false
network_policy: isolated
ports:
- host: 3002
container: 3001
protocol: tcp
volumes:
- type: bind
source: /var/lib/archipelago/uptime-kuma
target: /app/data
options: [rw]
environment:
- TZ=UTC
health_check:
type: http
endpoint: localhost:3001
path: /
interval: 30s
timeout: 5s
retries: 3
metadata:
icon: /assets/img/app-icons/uptime-kuma.webp
category: data
tier: recommended
author: Uptime Kuma
repo: https://github.com/louislam/uptime-kuma
launch:
open_in_new_tab: true
+60
View File
@@ -0,0 +1,60 @@
app:
id: vaultwarden
name: Vaultwarden
version: 1.30.0
description: Self-hosted password vault with zero-knowledge encryption.
container:
image: 146.59.87.168:3000/lfg2025/vaultwarden:1.30.0-alpine
pull_policy: if-not-present
network: pasta
dependencies:
- storage: 1Gi
resources:
memory_limit: 256Mi
disk_limit: 1Gi
security:
capabilities: [CHOWN, SETUID, SETGID, NET_BIND_SERVICE]
readonly_root: false
network_policy: isolated
ports:
- host: 8082
container: 80
protocol: tcp
volumes:
- type: bind
source: /var/lib/archipelago/vaultwarden
target: /data
options: [rw]
environment: []
health_check:
type: tcp
endpoint: localhost:80
interval: 30s
timeout: 5s
retries: 3
interfaces:
main:
name: Web UI
description: Vaultwarden web vault
type: ui
port: 8082
protocol: http
path: /
metadata:
icon: /assets/img/app-icons/vaultwarden.webp
category: data
tier: recommended
author: Vaultwarden
repo: https://github.com/dani-garcia/vaultwarden
launch:
open_in_new_tab: true
+1 -1
View File
@@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "archipelago"
version = "1.7.45-alpha"
version = "1.7.84-alpha"
dependencies = [
"anyhow",
"archipelago-container",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "archipelago"
version = "1.7.45-alpha"
version = "1.7.84-alpha"
edition = "2021"
description = "Archipelago Bitcoin Node OS - Native backend"
authors = ["Archipelago Team"]
+1
View File
@@ -429,6 +429,7 @@ impl ApiHandler {
// Electrs status — unauthenticated (read-only sync status)
(Method::GET, "/electrs-status") => Self::handle_electrs_status().await,
(Method::GET, "/bitcoin-status") => Self::handle_bitcoin_status().await,
// App-catalog proxy — fetches catalog.json from the configured
// upstream URLs server-side so the browser doesn't hit CORS
@@ -128,6 +128,22 @@ impl ApiHandler {
hyper::Body::from(r#"{"ok":true,"handled":"connection_accepted"}"#),
));
}
if let Some(handled) =
crate::api::rpc::bitcoin_relay::record_incoming_relay_message(
std::path::Path::new("/var/lib/archipelago"),
from,
incoming.from_name.as_deref(),
&val,
)
.await?
{
return Ok(build_response(
StatusCode::OK,
"application/json",
hyper::Body::from(format!(r#"{{"ok":true,"handled":"{}"}}"#, handled)),
));
}
}
let safe_from = sanitize_log_string(from);
+20 -6
View File
@@ -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();
+24
View File
@@ -189,6 +189,27 @@ impl RpcHandler {
.map(|f| f as u64)
.unwrap_or(0);
let latest = self.metrics_store.latest().await;
let (cpu_pct, mem_pct, disk_pct): (f64, f64, f64) = latest
.map(|s| {
let mem_total = s.system.mem_total_bytes as f64;
let disk_total = s.system.disk_total_bytes as f64;
(
s.system.cpu_percent,
if mem_total > 0.0 {
(s.system.mem_used_bytes as f64 / mem_total) * 100.0
} else {
0.0
},
if disk_total > 0.0 {
(s.system.disk_used_bytes as f64 / disk_total) * 100.0
} else {
0.0
},
)
})
.unwrap_or((0.0, 0.0, 0.0));
// Recent alerts from metrics store
let recent_alerts: Vec<serde_json::Value> = self
.metrics_store
@@ -210,6 +231,9 @@ impl RpcHandler {
"uptime_secs": uptime_secs,
"cpu_cores": cpu_cores,
"ram_mb": total_ram_mb,
"cpu_pct": (cpu_pct * 10.0).round() / 10.0,
"mem_pct": (mem_pct * 10.0).round() / 10.0,
"disk_pct": (disk_pct * 10.0).round() / 10.0,
"containers": containers,
"container_count": data.package_data.len(),
"running_count": data.package_data.values()
+8 -2
View File
@@ -79,7 +79,8 @@ impl RpcHandler {
.and_then(|v| v.as_bool())
.unwrap_or(true);
self.auth_manager
let outcome = self
.auth_manager
.change_password(current_password, new_password, also_change_ssh)
.await?;
@@ -88,7 +89,12 @@ impl RpcHandler {
self.session_store.invalidate_all_except(token).await;
}
Ok(serde_json::json!({ "success": true, "session_rotated": true }))
Ok(serde_json::json!({
"success": true,
"session_rotated": true,
"ssh_updated": outcome.ssh_updated,
"ssh_error": outcome.ssh_error,
}))
}
pub(super) async fn handle_auth_is_setup(&self) -> Result<serde_json::Value> {
@@ -0,0 +1,945 @@
use super::RpcHandler;
use crate::container::docker_packages;
use crate::data_model::{Notification, NotificationLevel};
use crate::{bitcoin_status, identity, peers};
use anyhow::{Context, Result};
use archipelago_container::ContainerState;
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
use hmac::{Hmac, Mac};
use rand::RngCore;
use serde::{Deserialize, Serialize};
use serde_json::json;
use sha2::Sha256;
use std::path::{Path, PathBuf};
use tokio::fs;
const RELAY_DIR: &str = "bitcoin-relay";
const RELAY_STATE_FILE: &str = "state.json";
const TXRELAY_USER: &str = "txrelay";
const TXRELAY_PASSWORD_FILE: &str = "bitcoin-rpc-txrelay-password";
const TXRELAY_RPCAUTH_FILE: &str = "bitcoin-rpc-txrelay-rpcauth";
const TXRELAY_CLIENT_ENV_FILE: &str = "bitcoin-rpc-txrelay-client.env";
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
struct BitcoinRelayState {
settings: BitcoinRelaySettings,
requests: Vec<BitcoinRelayRequest>,
updated_at: Option<String>,
}
impl Default for BitcoinRelayState {
fn default() -> Self {
Self {
settings: BitcoinRelaySettings::default(),
requests: Vec::new(),
updated_at: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
struct BitcoinRelaySettings {
enabled_for_peers: bool,
allow_peer_requests: bool,
allow_http: bool,
allow_https: bool,
allow_tor: bool,
selected_peer_pubkey: Option<String>,
http_endpoint: Option<String>,
https_endpoint: Option<String>,
tor_endpoint: Option<String>,
}
impl Default for BitcoinRelaySettings {
fn default() -> Self {
Self {
enabled_for_peers: false,
allow_peer_requests: false,
allow_http: false,
allow_https: true,
allow_tor: false,
selected_peer_pubkey: None,
http_endpoint: None,
https_endpoint: None,
tor_endpoint: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct BitcoinRelayRequest {
id: String,
direction: RelayRequestDirection,
status: RelayRequestStatus,
peer_pubkey: String,
peer_onion: String,
peer_name: Option<String>,
message: Option<String>,
approved_endpoint: Option<String>,
credential_secret_path: Option<String>,
created_at: String,
updated_at: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
enum RelayRequestDirection {
Incoming,
Outbound,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
enum RelayRequestStatus {
Pending,
Approved,
Rejected,
}
#[derive(Debug, Serialize)]
struct TrustedRelayPeer {
pubkey: String,
onion: String,
name: Option<String>,
relay_approved: bool,
}
#[derive(Debug, Clone)]
struct TxRelayCredentials {
username: String,
password: String,
}
impl RpcHandler {
pub(super) async fn handle_bitcoin_relay_status(&self) -> Result<serde_json::Value> {
let mut state = load_relay_state(&self.config.data_dir).await?;
hydrate_tor_endpoint(&self.config.data_dir, &mut state).await;
let known_peers = peers::load_peers(&self.config.data_dir)
.await
.unwrap_or_default();
let trusted_nodes = trusted_relay_peers(&known_peers, &state);
let local_node = local_sync_status().await;
let credential_status = txrelay_credential_status(&self.config.data_dir).await;
Ok(json!({
"settings": state.settings,
"trusted_nodes": trusted_nodes,
"requests": state.requests,
"local_node": local_node,
"credentials": credential_status,
}))
}
pub(super) async fn handle_bitcoin_relay_update_settings(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.unwrap_or_default();
let mut state = load_relay_state(&self.config.data_dir).await?;
let known_peers = peers::load_peers(&self.config.data_dir)
.await
.unwrap_or_default();
update_bool(
&params,
"enabled_for_peers",
&mut state.settings.enabled_for_peers,
);
update_bool(
&params,
"allow_peer_requests",
&mut state.settings.allow_peer_requests,
);
update_bool(&params, "allow_http", &mut state.settings.allow_http);
update_bool(&params, "allow_https", &mut state.settings.allow_https);
update_bool(&params, "allow_tor", &mut state.settings.allow_tor);
update_endpoint(&params, "http_endpoint", &mut state.settings.http_endpoint)?;
update_endpoint(
&params,
"https_endpoint",
&mut state.settings.https_endpoint,
)?;
update_endpoint(&params, "tor_endpoint", &mut state.settings.tor_endpoint)?;
if state.settings.enabled_for_peers {
let credentials_were_ready = txrelay_credentials_available(&self.config.data_dir).await;
ensure_txrelay_credentials(&self.config.data_dir).await?;
if !credentials_were_ready {
self.restart_bitcoin_backends_for_txrelay().await;
}
}
if params.get("selected_peer_pubkey").is_some() {
let selected = params
.get("selected_peer_pubkey")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty());
if let Some(pubkey) = selected {
if !known_peers.iter().any(|p| p.pubkey == pubkey) {
anyhow::bail!("Selected relay peer is not in trusted nodes");
}
state.settings.selected_peer_pubkey = Some(pubkey.to_string());
} else {
state.settings.selected_peer_pubkey = None;
}
}
state.updated_at = Some(now());
save_relay_state(&self.config.data_dir, &state).await?;
self.notify(
"Bitcoin relay settings updated",
"Transaction relay sharing preferences were saved.",
)
.await;
self.handle_bitcoin_relay_status().await
}
pub(super) async fn handle_bitcoin_relay_request_peer(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.unwrap_or_default();
let peer_pubkey = params
.get("peer_pubkey")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: peer_pubkey"))?;
let message = params
.get("message")
.and_then(|v| v.as_str())
.map(sanitize_optional_text)
.transpose()?;
let peer = peers::load_peers(&self.config.data_dir)
.await
.unwrap_or_default()
.into_iter()
.find(|p| p.pubkey == peer_pubkey)
.ok_or_else(|| anyhow::anyhow!("Peer is not in trusted nodes"))?;
let mut state = load_relay_state(&self.config.data_dir).await?;
let existing = state.requests.iter_mut().find(|r| {
r.direction == RelayRequestDirection::Outbound
&& r.peer_pubkey == peer.pubkey
&& r.status == RelayRequestStatus::Pending
});
let request_id = if let Some(req) = existing {
req.message = message.clone();
req.updated_at = now();
req.id.clone()
} else {
let timestamp = now();
let req = BitcoinRelayRequest {
id: uuid::Uuid::new_v4().to_string(),
direction: RelayRequestDirection::Outbound,
status: RelayRequestStatus::Pending,
peer_pubkey: peer.pubkey.clone(),
peer_onion: peer.onion.clone(),
peer_name: peer.name.clone(),
message: message.clone(),
approved_endpoint: None,
credential_secret_path: None,
created_at: timestamp.clone(),
updated_at: timestamp,
};
let id = req.id.clone();
state.requests.push(req);
id
};
state.updated_at = Some(now());
save_relay_state(&self.config.data_dir, &state).await?;
if let Err(e) = self
.send_relay_peer_message(
&peer,
json!({
"type": "bitcoin_relay_request",
"request_id": request_id,
"message": message,
}),
)
.await
{
tracing::warn!(peer = %peer.onion, error = %e, "Failed to send Bitcoin relay request");
}
self.notify(
"Bitcoin relay request sent",
"A trusted peer was asked to approve transaction relay access.",
)
.await;
Ok(json!({ "ok": true, "request_id": request_id }))
}
pub(super) async fn handle_bitcoin_relay_approve_request(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
self.update_relay_request_status(params, RelayRequestStatus::Approved)
.await
}
pub(super) async fn handle_bitcoin_relay_reject_request(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
self.update_relay_request_status(params, RelayRequestStatus::Rejected)
.await
}
pub(super) async fn handle_bitcoin_relay_create_tor_service(
&self,
) -> Result<serde_json::Value> {
let params = json!({
"name": "bitcoin-rpc",
"local_port": 80,
"remote_port": 80,
});
let created = match self.handle_tor_create_service(Some(params)).await {
Ok(v) => v,
Err(e) if e.to_string().contains("already exists") => {
self.handle_tor_get_onion_address(Some(json!({ "name": "bitcoin-rpc" })))
.await?
}
Err(e) => return Err(e),
};
let onion = created
.get("onion_address")
.and_then(|v| v.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
if let Some(onion) = onion {
let mut state = load_relay_state(&self.config.data_dir).await?;
state.settings.allow_tor = true;
state.settings.tor_endpoint = Some(format!("http://{onion}/"));
state.updated_at = Some(now());
save_relay_state(&self.config.data_dir, &state).await?;
}
self.notify(
"Bitcoin relay Tor service enabled",
"A Tor endpoint was created for Bitcoin transaction relay access.",
)
.await;
Ok(created)
}
async fn update_relay_request_status(
&self,
params: Option<serde_json::Value>,
status: RelayRequestStatus,
) -> Result<serde_json::Value> {
let params = params.unwrap_or_default();
let request_id = params
.get("id")
.or_else(|| params.get("request_id"))
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
let mut state = load_relay_state(&self.config.data_dir).await?;
let serving_endpoint = if status == RelayRequestStatus::Approved {
preferred_endpoint(&state.settings)
} else {
None
};
let request_direction = state
.requests
.iter()
.find(|r| r.id == request_id)
.ok_or_else(|| anyhow::anyhow!("Request not found: {}", request_id))?
.direction;
if status == RelayRequestStatus::Approved
&& request_direction == RelayRequestDirection::Incoming
&& serving_endpoint.is_none()
{
anyhow::bail!(
"Configure an HTTP, HTTPS, or Tor relay endpoint before approving access"
);
}
let credentials = if status == RelayRequestStatus::Approved {
let credentials = ensure_txrelay_credentials(&self.config.data_dir).await?;
if request_direction == RelayRequestDirection::Incoming {
self.restart_bitcoin_backends_for_txrelay().await;
}
Some(credentials)
} else {
None
};
let (peer_pubkey, peer_onion, peer_name, direction) = {
let req = state
.requests
.iter_mut()
.find(|r| r.id == request_id)
.ok_or_else(|| anyhow::anyhow!("Request not found: {}", request_id))?;
req.status = status;
req.updated_at = now();
if let Some(endpoint) = &serving_endpoint {
req.approved_endpoint = Some(endpoint.clone());
}
(
req.peer_pubkey.clone(),
req.peer_onion.clone(),
req.peer_name.clone(),
req.direction,
)
};
let peer = peers::load_peers(&self.config.data_dir)
.await
.unwrap_or_default()
.into_iter()
.find(|p| p.pubkey == peer_pubkey);
let peer_name = peer_name.unwrap_or_else(|| peer_onion.clone());
state.updated_at = Some(now());
save_relay_state(&self.config.data_dir, &state).await?;
if let Some(peer) = peer {
let message_type = match status {
RelayRequestStatus::Approved => "bitcoin_relay_approved",
RelayRequestStatus::Rejected => "bitcoin_relay_rejected",
RelayRequestStatus::Pending => "bitcoin_relay_pending",
};
if let Err(e) = self
.send_relay_peer_message(
&peer,
relay_response_payload(
message_type,
request_id,
direction,
serving_endpoint.as_deref(),
credentials.as_ref(),
),
)
.await
{
tracing::warn!(peer = %peer.onion, error = %e, "Failed to send Bitcoin relay response");
}
}
let title = match status {
RelayRequestStatus::Approved => "Bitcoin relay request approved",
RelayRequestStatus::Rejected => "Bitcoin relay request rejected",
RelayRequestStatus::Pending => "Bitcoin relay request updated",
};
self.notify(
title,
&format!("Relay access request for {peer_name} was updated."),
)
.await;
Ok(json!({ "ok": true, "request_id": request_id }))
}
async fn send_relay_peer_message(
&self,
peer: &peers::KnownPeer,
mut payload: serde_json::Value,
) -> Result<()> {
let (data, _) = self.state_manager.get_snapshot().await;
let my_pubkey = data.server_info.pubkey.clone();
let my_did = identity::did_key_from_pubkey_hex(&my_pubkey).ok();
let my_onion = docker_packages::read_tor_address("archipelago")
.await
.unwrap_or_default();
payload["from_did"] = my_did.map(serde_json::Value::String).unwrap_or_default();
payload["from_pubkey"] = serde_json::Value::String(my_pubkey.clone());
payload["from_onion"] = serde_json::Value::String(my_onion);
payload["from_name"] = data
.server_info
.name
.clone()
.map(serde_json::Value::String)
.unwrap_or_default();
let to_fips_npub =
crate::federation::fips_npub_for_onion(&self.config.data_dir, &peer.onion).await;
let identity_dir = self.config.data_dir.join("identity");
let signing_key = crate::identity::NodeIdentity::load_or_create(&identity_dir)
.await
.ok();
crate::node_message::send_to_peer(
&peer.onion,
to_fips_npub.as_deref(),
&my_pubkey,
&payload.to_string(),
signing_key.as_ref().map(|i| i.signing_key()),
Some(&peer.pubkey),
data.server_info.name.as_deref(),
)
.await
}
async fn notify(&self, title: &str, message: &str) {
let (mut data, _) = self.state_manager.get_snapshot().await;
data.notifications.push(Notification {
id: format!("bitcoin-relay-{}", uuid::Uuid::new_v4()),
level: NotificationLevel::Info,
title: title.to_string(),
message: message.to_string(),
timestamp: now(),
app_id: Some("bitcoin-knots".to_string()),
});
let len = data.notifications.len();
if len > 30 {
data.notifications.drain(0..len - 30);
}
self.state_manager.update_data(data).await;
}
async fn restart_bitcoin_backends_for_txrelay(&self) {
let Some(orchestrator) = self.orchestrator.as_ref().cloned() else {
tracing::debug!("Skipping txrelay backend restart; orchestrator unavailable");
return;
};
tokio::spawn(async move {
for app_id in ["bitcoin-knots", "bitcoin-core"] {
let Ok(status) = orchestrator.status(app_id).await else {
continue;
};
if status.state != ContainerState::Running {
continue;
}
match orchestrator.restart(app_id).await {
Ok(()) => tracing::info!(
app_id,
"Restarted Bitcoin backend to load txrelay RPC credentials"
),
Err(e) => tracing::warn!(
app_id,
error = %e,
"Failed to restart Bitcoin backend after txrelay credential update"
),
}
}
});
}
}
pub(crate) async fn record_incoming_relay_message(
data_dir: &Path,
from_pubkey: &str,
from_name: Option<&str>,
payload: &serde_json::Value,
) -> Result<Option<&'static str>> {
let msg_type = payload.get("type").and_then(|v| v.as_str()).unwrap_or("");
match msg_type {
"bitcoin_relay_request" => {
let from_onion = payload
.get("from_onion")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let message = payload
.get("message")
.and_then(|v| v.as_str())
.map(sanitize_optional_text)
.transpose()?;
let remote_request_id = payload
.get("request_id")
.and_then(|v| v.as_str())
.unwrap_or_default();
let mut state = load_relay_state(data_dir).await?;
if !state.settings.allow_peer_requests {
return Ok(Some("bitcoin_relay_request_disabled"));
}
if !state.requests.iter().any(|r| {
r.direction == RelayRequestDirection::Incoming
&& r.peer_pubkey == from_pubkey
&& r.status == RelayRequestStatus::Pending
}) {
let timestamp = now();
state.requests.push(BitcoinRelayRequest {
id: if remote_request_id.is_empty() {
uuid::Uuid::new_v4().to_string()
} else {
remote_request_id.to_string()
},
direction: RelayRequestDirection::Incoming,
status: RelayRequestStatus::Pending,
peer_pubkey: from_pubkey.to_string(),
peer_onion: from_onion,
peer_name: from_name.map(String::from),
message,
approved_endpoint: None,
credential_secret_path: None,
created_at: timestamp.clone(),
updated_at: timestamp,
});
state.updated_at = Some(now());
save_relay_state(data_dir, &state).await?;
}
Ok(Some("bitcoin_relay_request"))
}
"bitcoin_relay_approved" | "bitcoin_relay_rejected" => {
let request_id = payload.get("request_id").and_then(|v| v.as_str());
let mut state = load_relay_state(data_dir).await?;
let status = if msg_type == "bitcoin_relay_approved" {
RelayRequestStatus::Approved
} else {
RelayRequestStatus::Rejected
};
let approved_access = if status == RelayRequestStatus::Approved {
save_peer_relay_access(data_dir, from_pubkey, payload).await?
} else {
None
};
if let Some(req) = state.requests.iter_mut().find(|r| {
r.direction == RelayRequestDirection::Outbound
&& r.peer_pubkey == from_pubkey
&& request_id.map(|id| id == r.id).unwrap_or(true)
}) {
req.status = status;
req.updated_at = now();
if let Some((endpoint, secret_path)) = approved_access {
req.approved_endpoint = Some(endpoint);
req.credential_secret_path = Some(secret_path);
}
state.updated_at = Some(now());
save_relay_state(data_dir, &state).await?;
}
Ok(Some(if msg_type == "bitcoin_relay_approved" {
"bitcoin_relay_approved"
} else {
"bitcoin_relay_rejected"
}))
}
_ => Ok(None),
}
}
fn trusted_relay_peers(
known_peers: &[peers::KnownPeer],
state: &BitcoinRelayState,
) -> Vec<TrustedRelayPeer> {
known_peers
.iter()
.map(|peer| TrustedRelayPeer {
pubkey: peer.pubkey.clone(),
onion: peer.onion.clone(),
name: peer.name.clone(),
relay_approved: state.requests.iter().any(|req| {
req.peer_pubkey == peer.pubkey && req.status == RelayRequestStatus::Approved
}),
})
.collect()
}
async fn txrelay_credential_status(data_dir: &Path) -> serde_json::Value {
let credentials_available = txrelay_credentials_available(data_dir).await;
let (password_path, rpcauth_path, client_env_path) = txrelay_secret_paths(data_dir);
let password_available = fs::metadata(&password_path).await.is_ok();
let rpcauth_available = fs::metadata(&rpcauth_path).await.is_ok();
let client_env_available = fs::metadata(&client_env_path).await.is_ok();
json!({
"username": TXRELAY_USER,
"available": credentials_available,
"password_available": password_available,
"rpcauth_available": rpcauth_available,
"client_env_available": client_env_available,
"client_env_path": client_env_path.display().to_string(),
"restart_hint": "Archipelago restarts the active Bitcoin backend after generating txrelay credentials so bitcoind loads the restricted rpcauth whitelist.",
})
}
async fn txrelay_credentials_available(data_dir: &Path) -> bool {
let (password_path, rpcauth_path, client_env_path) = txrelay_secret_paths(data_dir);
fs::metadata(&password_path).await.is_ok()
&& fs::metadata(&rpcauth_path).await.is_ok()
&& fs::metadata(&client_env_path).await.is_ok()
}
async fn ensure_txrelay_credentials(data_dir: &Path) -> Result<TxRelayCredentials> {
let (password_path, rpcauth_path, client_env_path) = txrelay_secret_paths(data_dir);
let password = match read_trimmed(&password_path).await {
Some(value) => value,
None => {
let generated = generate_random_password();
write_secret_file(&password_path, &generated).await?;
generated
}
};
let rpcauth = match read_trimmed(&rpcauth_path).await {
Some(value) => value,
None => {
let generated = generate_rpcauth(TXRELAY_USER, &password);
write_secret_file(&rpcauth_path, &generated).await?;
generated
}
};
let client_env = format!(
"BITCOIN_RPC_TXRELAY_USER={}\nBITCOIN_RPC_TXRELAY_PASSWORD={}\nBITCOIN_RPC_TXRELAY_RPCAUTH={}\n",
TXRELAY_USER, password, rpcauth
);
write_secret_file(&client_env_path, &client_env).await?;
Ok(TxRelayCredentials {
username: TXRELAY_USER.to_string(),
password,
})
}
fn txrelay_secret_paths(data_dir: &Path) -> (PathBuf, PathBuf, PathBuf) {
let secrets_dir = data_dir.join("secrets");
(
secrets_dir.join(TXRELAY_PASSWORD_FILE),
secrets_dir.join(TXRELAY_RPCAUTH_FILE),
secrets_dir.join(TXRELAY_CLIENT_ENV_FILE),
)
}
async fn read_trimmed(path: &Path) -> Option<String> {
fs::read_to_string(path)
.await
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
async fn write_secret_file(path: &Path, contents: &str) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).await?;
}
fs::write(path, contents).await?;
set_private_permissions(path).await;
Ok(())
}
async fn set_private_permissions(path: &Path) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).await;
}
}
fn generate_random_password() -> String {
let mut bytes = [0u8; 32];
rand::rngs::OsRng.fill_bytes(&mut bytes);
BASE64.encode(bytes)
}
fn generate_rpcauth(username: &str, password: &str) -> String {
let mut salt_bytes = [0u8; 16];
rand::rngs::OsRng.fill_bytes(&mut salt_bytes);
let salt_hex = hex::encode(salt_bytes);
let mut mac =
Hmac::<Sha256>::new_from_slice(salt_hex.as_bytes()).expect("HMAC accepts any key length");
mac.update(password.as_bytes());
let hash_hex = hex::encode(mac.finalize().into_bytes());
format!("{username}:{salt_hex}${hash_hex}")
}
fn preferred_endpoint(settings: &BitcoinRelaySettings) -> Option<String> {
if settings.allow_https {
if let Some(endpoint) = settings.https_endpoint.clone() {
return Some(endpoint);
}
}
if settings.allow_tor {
if let Some(endpoint) = settings.tor_endpoint.clone() {
return Some(endpoint);
}
}
if settings.allow_http {
if let Some(endpoint) = settings.http_endpoint.clone() {
return Some(endpoint);
}
}
settings
.https_endpoint
.clone()
.or_else(|| settings.tor_endpoint.clone())
.or_else(|| settings.http_endpoint.clone())
}
fn relay_response_payload(
message_type: &str,
request_id: &str,
request_direction: RelayRequestDirection,
endpoint: Option<&str>,
credentials: Option<&TxRelayCredentials>,
) -> serde_json::Value {
let mut payload = json!({
"type": message_type,
"request_id": request_id,
});
if message_type == "bitcoin_relay_approved"
&& request_direction == RelayRequestDirection::Incoming
{
if let (Some(endpoint), Some(credentials)) = (endpoint, credentials) {
payload["relay_access"] = json!({
"endpoint": endpoint,
"username": &credentials.username,
"password": &credentials.password,
});
}
}
payload
}
async fn save_peer_relay_access(
data_dir: &Path,
from_pubkey: &str,
payload: &serde_json::Value,
) -> Result<Option<(String, String)>> {
let Some(access) = payload.get("relay_access") else {
return Ok(None);
};
let endpoint = access
.get("endpoint")
.and_then(|v| v.as_str())
.map(validate_endpoint)
.transpose()?;
let username = access.get("username").and_then(|v| v.as_str());
let password = access.get("password").and_then(|v| v.as_str());
let (Some(endpoint), Some(username), Some(password)) = (endpoint, username, password) else {
return Ok(None);
};
validate_env_value(username)?;
validate_env_value(password)?;
let secret_path = data_dir.join("secrets").join(format!(
"bitcoin-relay-peer-{}.env",
safe_pubkey_fragment(from_pubkey)
));
let contents = format!(
"BITCOIN_RELAY_PEER_PUBKEY={}\nBITCOIN_RELAY_ENDPOINT={}\nBITCOIN_RELAY_USERNAME={}\nBITCOIN_RELAY_PASSWORD={}\n",
from_pubkey, endpoint, username, password
);
write_secret_file(&secret_path, &contents).await?;
Ok(Some((endpoint, secret_path.display().to_string())))
}
fn validate_env_value(value: &str) -> Result<()> {
if value.is_empty() || value.len() > 1024 || value.contains('\n') || value.contains('\r') {
anyhow::bail!("Invalid relay credential value");
}
Ok(())
}
fn safe_pubkey_fragment(pubkey: &str) -> String {
let fragment = pubkey
.chars()
.filter(|c| c.is_ascii_hexdigit())
.take(24)
.collect::<String>();
if fragment.is_empty() {
"unknown".to_string()
} else {
fragment
}
}
async fn hydrate_tor_endpoint(data_dir: &Path, state: &mut BitcoinRelayState) {
if state.settings.tor_endpoint.is_some() {
return;
}
if let Some(onion) = docker_packages::read_tor_address("bitcoin-rpc").await {
let onion = onion.trim().trim_end_matches('/').to_string();
if !onion.is_empty() {
state.settings.tor_endpoint = Some(format!("http://{onion}/"));
let _ = save_relay_state(data_dir, state).await;
}
}
}
async fn local_sync_status() -> serde_json::Value {
let status = bitcoin_status::get_bitcoin_status().await;
let blockchain = status.blockchain_info.as_ref();
let blocks = blockchain
.and_then(|v| v.get("blocks"))
.and_then(|v| v.as_u64())
.unwrap_or(0);
let headers = blockchain
.and_then(|v| v.get("headers"))
.and_then(|v| v.as_u64())
.unwrap_or(0);
let initial_block_download = blockchain
.and_then(|v| v.get("initialblockdownload"))
.and_then(|v| v.as_bool())
.unwrap_or(true);
let synced =
status.ok && headers > 0 && blocks >= headers.saturating_sub(1) && !initial_block_download;
json!({
"synced": synced,
"blocks": blocks,
"headers": headers,
"chain": blockchain
.and_then(|v| v.get("chain"))
.and_then(|v| v.as_str())
.unwrap_or("unknown"),
"status_ok": status.ok,
"status_stale": status.stale,
"error": status.error,
})
}
async fn load_relay_state(data_dir: &Path) -> Result<BitcoinRelayState> {
let path = state_path(data_dir);
if !path.exists() {
return Ok(BitcoinRelayState::default());
}
let content = fs::read_to_string(&path)
.await
.with_context(|| format!("Failed to read {}", path.display()))?;
Ok(serde_json::from_str(&content).unwrap_or_default())
}
async fn save_relay_state(data_dir: &Path, state: &BitcoinRelayState) -> Result<()> {
let dir = data_dir.join(RELAY_DIR);
fs::create_dir_all(&dir).await?;
let content = serde_json::to_string_pretty(state)?;
fs::write(dir.join(RELAY_STATE_FILE), content).await?;
Ok(())
}
fn state_path(data_dir: &Path) -> PathBuf {
data_dir.join(RELAY_DIR).join(RELAY_STATE_FILE)
}
fn update_bool(params: &serde_json::Value, key: &str, target: &mut bool) {
if let Some(value) = params.get(key).and_then(|v| v.as_bool()) {
*target = value;
}
}
fn update_endpoint(
params: &serde_json::Value,
key: &str,
target: &mut Option<String>,
) -> Result<()> {
if !params.get(key).is_some() {
return Ok(());
}
let endpoint = params
.get(key)
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty());
*target = endpoint.map(validate_endpoint).transpose()?;
Ok(())
}
fn validate_endpoint(endpoint: &str) -> Result<String> {
if endpoint.len() > 512 || endpoint.contains('\n') || endpoint.contains('\r') {
anyhow::bail!("Invalid endpoint");
}
let lower = endpoint.to_ascii_lowercase();
if !(lower.starts_with("http://") || lower.starts_with("https://")) {
anyhow::bail!("Endpoint must start with http:// or https://");
}
Ok(endpoint.to_string())
}
fn sanitize_optional_text(value: &str) -> Result<String> {
let value = value.trim();
if value.len() > 500 || value.contains('\0') {
anyhow::bail!("Invalid message");
}
Ok(value.to_string())
}
fn now() -> String {
chrono::Utc::now().to_rfc3339()
}
+464 -55
View File
@@ -2,6 +2,11 @@ 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(5);
const PODMAN_PS_TIMEOUT: Duration = Duration::from_secs(5);
const ORCHESTRATOR_HEALTH_TIMEOUT: Duration = Duration::from_secs(5);
impl RpcHandler {
pub(super) async fn handle_container_install(
@@ -167,46 +172,69 @@ impl RpcHandler {
// between "installed" and "not-installed" in the UI.
let (data, _) = self.state_manager.get_snapshot().await;
if data.server_info.status_info.containers_scanned && !data.package_data.is_empty() {
let containers: Vec<serde_json::Value> = data
.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 => "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
.as_ref()
.and_then(|i| i.interface_addresses.get("main"))
.and_then(|a| a.lan_address.as_deref());
serde_json::json!({
"id": id,
"name": id,
"state": state,
"image": "",
"created": "",
"ports": [],
"lan_address": lan,
})
})
.collect();
let mut containers = Vec::with_capacity(data.package_data.len());
for (id, pkg) in &data.package_data {
// 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 mut state = match &pkg.state {
crate::data_model::PackageState::Running => "running".to_string(),
crate::data_model::PackageState::Stopped => "stopped".to_string(),
crate::data_model::PackageState::Exited => "exited".to_string(),
crate::data_model::PackageState::Starting => "starting".to_string(),
crate::data_model::PackageState::Stopping => "stopping".to_string(),
crate::data_model::PackageState::Restarting => "restarting".to_string(),
crate::data_model::PackageState::Installing => "installing".to_string(),
crate::data_model::PackageState::Installed => "installed".to_string(),
crate::data_model::PackageState::Updating => "updating".to_string(),
crate::data_model::PackageState::Removing => "removing".to_string(),
crate::data_model::PackageState::CreatingBackup => {
"creating-backup".to_string()
}
crate::data_model::PackageState::RestoringBackup => {
"restoring-backup".to_string()
}
crate::data_model::PackageState::BackingUp => "backing-up".to_string(),
};
// Scanner backoff preserves cached package_data. Refresh stable
// states so callers do not see stale `running`/`exited` after
// health-monitor recovery or Quadlet --rm container removal.
if state == "running" && requires_launch_port_for_health(id) {
if !self.cached_reachable_health(id).await?.is_some() {
state = live_state_for_app(id)
.await
.unwrap_or("starting".to_string());
}
} else if should_refresh_cached_state(&state) {
if launch_port_reachable(id).await {
state = "running".to_string();
} else {
if let Some(live) = live_state_for_app(id).await {
state = live;
} else if quadlet_service_active(id).await {
state = "starting".to_string();
}
}
}
let lan = pkg
.installed
.as_ref()
.and_then(|i| i.interface_addresses.get("main"))
.and_then(|a| a.lan_address.as_deref());
containers.push(serde_json::json!({
"id": id,
"name": id,
"state": state,
"image": "",
"created": "",
"ports": [],
"lan_address": lan,
}));
}
return Ok(serde_json::json!(containers));
}
@@ -379,11 +407,44 @@ impl RpcHandler {
// If app_id is provided, get health for that app.
if let Some(params) = params {
if let Some(app_id) = params.get("app_id").and_then(|v| v.as_str()) {
let health = orchestrator
.health(app_id)
if let Some(health) = self.cached_reachable_health(app_id).await? {
return Ok(serde_json::json!({ app_id: health }));
}
if let Some(health) = self.cached_state_health(app_id).await {
return Ok(serde_json::json!({ app_id: health }));
}
if requires_launch_port_for_health(app_id) {
return Ok(serde_json::json!({ app_id: "starting" }));
}
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 tokio::time::timeout(
ORCHESTRATOR_HEALTH_TIMEOUT,
orchestrator.health(&candidate),
)
.await
.context("Failed to get container health")?;
return Ok(serde_json::json!({ app_id: health }));
{
Ok(Ok(health)) => return Ok(serde_json::json!({ app_id: health })),
Ok(Err(e)) => last_err = Some(e),
Err(_) => {}
}
}
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"));
}
}
@@ -405,14 +466,19 @@ impl RpcHandler {
.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) => {
match tokio::time::timeout(
ORCHESTRATOR_HEALTH_TIMEOUT,
orchestrator.health(app_id_candidate),
)
.await
{
Ok(Ok(health)) => {
health_map.insert(
app_id_candidate.to_string(),
serde_json::Value::String(health),
);
}
Err(_) => {
Ok(Err(_)) | Err(_) => {
health_map.insert(
app_id_candidate.to_string(),
serde_json::Value::String("unknown".to_string()),
@@ -423,6 +489,143 @@ impl RpcHandler {
Ok(serde_json::Value::Object(health_map))
}
async fn cached_state_health(&self, app_id: &str) -> Option<&'static str> {
let (data, _) = self.state_manager.get_snapshot().await;
let Some(pkg) = data.package_data.get(app_id) else {
if data.server_info.status_info.containers_scanned {
return Some("stopped");
}
return None;
};
match pkg.state {
crate::data_model::PackageState::Running => None,
crate::data_model::PackageState::Installing
| crate::data_model::PackageState::Installed
| crate::data_model::PackageState::Starting => Some("starting"),
crate::data_model::PackageState::Stopping
| crate::data_model::PackageState::Stopped
| crate::data_model::PackageState::Exited => Some("stopped"),
crate::data_model::PackageState::Removing => Some("removing"),
crate::data_model::PackageState::Restarting
| crate::data_model::PackageState::Updating
| crate::data_model::PackageState::CreatingBackup
| crate::data_model::PackageState::RestoringBackup
| crate::data_model::PackageState::BackingUp => Some("starting"),
}
}
async fn cached_reachable_health(&self, app_id: &str) -> Result<Option<String>> {
let (data, _) = self.state_manager.get_snapshot().await;
let pkg = data.package_data.get(app_id);
if matches!(
pkg.map(|pkg| &pkg.state),
Some(crate::data_model::PackageState::Removing)
) {
return Ok(None);
}
let url = pkg
.and_then(|pkg| pkg.installed.as_ref())
.and_then(|i| i.interface_addresses.get("main"))
.and_then(|a| a.lan_address.as_deref())
.map(ToOwned::to_owned)
.or_else(|| health_probe_url_for_app(app_id));
let Some(url) = url else {
return Ok(None);
};
if url.starts_with("http://") || url.starts_with("https://") {
return Ok(http_launch_url_reachable(&url)
.await
.then(|| "healthy".to_string()));
}
let Some(port) = port_from_url(&url) else {
return Ok(None);
};
Ok(launch_port_reachable_by_port(port)
.await
.then(|| "healthy".to_string()))
}
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 {
if let Some(health) = self.cached_reachable_health(app_id).await? {
return Ok(Some(health));
}
Ok(Some("unknown".to_string()))
} else if saw_starting {
if let Some(health) = self.cached_reachable_health(app_id).await? {
return Ok(Some(health));
}
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(Ok(health)) =
tokio::time::timeout(ORCHESTRATOR_HEALTH_TIMEOUT, 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",
]),
_ => None,
}
}
fn status_app_id_candidates(app_id: &str) -> Vec<String> {
@@ -449,6 +652,14 @@ fn status_app_id_candidates(app_id: &str) -> Vec<String> {
push("mempool-electrs");
push("electrumx");
}
"mempool" | "mempool-web" => {
push("mempool");
push("archy-mempool-web");
}
"immich" => {
push("immich");
push("immich_server");
}
_ => push(app_id),
}
@@ -469,6 +680,8 @@ fn status_container_name_candidates(app_id: &str) -> Vec<String> {
"lnd-ui" => push("archy-lnd-ui"),
"electrs-ui" => push("archy-electrs-ui"),
"electrs" | "mempool-electrs" => push("electrumx"),
"mempool" | "mempool-web" | "archy-mempool-web" => push("mempool"),
"immich" => push("immich_server"),
_ => {}
}
@@ -482,16 +695,131 @@ fn status_container_name_candidates(app_id: &str) -> Vec<String> {
out
}
fn should_refresh_cached_state(state: &str) -> bool {
matches!(state, "exited" | "stopped" | "stopping")
}
async fn live_state_for_app(app_id: &str) -> Option<String> {
for name in status_container_name_candidates(app_id) {
if let Some(live) = inspect_container_state_value(&name).await {
if let Some(live_state) = live.get("state").and_then(|v| v.as_str()) {
return Some(live_state.to_string());
}
}
}
None
}
async fn quadlet_service_active(app_id: &str) -> bool {
for name in status_container_name_candidates(app_id) {
let service = format!("{name}.service");
let mut cmd = tokio::process::Command::new("systemctl");
cmd.args(["--user", "is-active", "--quiet", &service]);
cmd.kill_on_drop(true);
if matches!(
tokio::time::timeout(Duration::from_secs(2), cmd.status()).await,
Ok(Ok(status)) if status.success()
) {
return true;
}
}
false
}
fn health_probe_url_for_app(app_id: &str) -> Option<String> {
let port = match app_id {
"bitcoin-ui" => 8334,
"botfights" => 9100,
"btcpay-server" | "btcpay" | "btcpayserver" => 23000,
"dwn" => 3100,
"electrumx" | "electrs" | "mempool-electrs" | "electrs-ui" => 50002,
"fedimint" | "fedimintd" => 8175,
"filebrowser" => 8083,
"gitea" => 3001,
"grafana" => 3000,
"homeassistant" | "home-assistant" => 8123,
"immich" | "immich_server" => 2283,
"indeedhub" => 7778,
"jellyfin" => 8096,
"lnd" | "lnd-ui" => 18083,
"mempool" | "mempool-web" => 4080,
"nginx-proxy-manager" => 8081,
"ollama" => 11434,
"photoprism" => 2342,
"portainer" => 9000,
"searxng" => 8888,
"tailscale" => 8240,
"uptime-kuma" => 3002,
"vaultwarden" => 8082,
_ => return None,
};
Some(format!("http://localhost:{port}"))
}
fn requires_launch_port_for_health(app_id: &str) -> bool {
matches!(app_id, "fedimint" | "fedimintd" | "fedimint-gateway")
}
async fn launch_port_reachable(app_id: &str) -> bool {
let Some(port) = health_probe_url_for_app(app_id).and_then(|url| port_from_url(&url)) else {
return false;
};
launch_port_reachable_by_port(port).await
}
async fn launch_port_reachable_by_port(port: u16) -> bool {
matches!(
tokio::time::timeout(
Duration::from_secs(2),
tokio::net::TcpStream::connect(("127.0.0.1", port)),
)
.await,
Ok(Ok(_))
)
}
async fn http_launch_url_reachable(url: &str) -> bool {
let Ok(client) = reqwest::Client::builder()
.timeout(Duration::from_secs(2))
.redirect(reqwest::redirect::Policy::none())
.build()
else {
return false;
};
match client.get(url).send().await {
Ok(response) => {
let status = response.status();
status.is_success() || status.is_redirection()
}
Err(_) => false,
}
}
fn port_from_url(url: &str) -> Option<u16> {
let after_colon = url.rsplit_once(':')?.1;
let port = after_colon
.chars()
.take_while(|c| c.is_ascii_digit())
.collect::<String>();
port.parse::<u16>().ok()
}
async fn inspect_container_state_value(name: &str) -> Option<serde_json::Value> {
let out = tokio::process::Command::new("podman")
.args([
"inspect",
name,
"--format",
"{{.State.Status}} {{.State.Running}}",
])
.output()
if let Some(v) = ps_container_state_value(name).await {
return Some(v);
}
let mut cmd = tokio::process::Command::new("podman");
cmd.args([
"inspect",
name,
"--format",
"{{.State.Status}} {{.State.Running}} {{if .State.Healthcheck}}{{.State.Healthcheck.Status}}{{else}}none{{end}}",
]);
cmd.kill_on_drop(true);
let out = tokio::time::timeout(PODMAN_INSPECT_TIMEOUT, cmd.output())
.await
.ok()?
.ok()?;
if !out.status.success() {
return None;
@@ -504,10 +832,91 @@ async fn inspect_container_state_value(name: &str) -> Option<serde_json::Value>
let mut parts = line.split_whitespace();
let status = parts.next().unwrap_or("unknown");
let running = parts.next().unwrap_or("false") == "true";
let health = parts.next().unwrap_or("none");
Some(serde_json::json!({
"name": name,
"status": status,
"state": status,
"running": running,
"health": health,
}))
}
async fn ps_container_state_value(name: &str) -> Option<serde_json::Value> {
let mut cmd = tokio::process::Command::new("podman");
cmd.args([
"ps",
"-a",
"--filter",
&format!("name={name}"),
"--format",
"{{.Names}}|{{.Status}}",
]);
cmd.kill_on_drop(true);
let out = tokio::time::timeout(PODMAN_PS_TIMEOUT, cmd.output())
.await
.ok()?
.ok()?;
if !out.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&out.stdout);
for line in stdout.lines() {
let mut parts = line.splitn(2, '|');
let container_name = parts.next().unwrap_or_default();
if container_name != name {
continue;
}
let status = parts.next().unwrap_or_default();
let state = state_from_podman_status(status);
let health = parse_health_from_status(status).unwrap_or("none");
return Some(serde_json::json!({
"name": name,
"status": state,
"state": state,
"running": state.eq_ignore_ascii_case("running"),
"health": health,
}));
}
None
}
fn state_from_podman_status(status: &str) -> &str {
if status.starts_with("Up ") {
"running"
} else if status.starts_with("Exited ") {
"exited"
} else if status.starts_with("Created") {
"created"
} else if status.starts_with("Stopping") {
"stopping"
} else if status.starts_with("Removing") {
"removing"
} else {
"unknown"
}
}
fn parse_health_from_status(status: &str) -> Option<&str> {
let start = status.rfind('(')?;
let end = status.rfind(')')?;
(start < end).then(|| &status[start + 1..end])
}
async fn inspect_container_health_value(name: &str) -> Option<String> {
let v = inspect_container_state_value(name).await?;
if let Some(health) = v.get("health").and_then(|s| s.as_str()) {
if health != "none" {
return Some(health.to_string());
}
}
match v.get("state").and_then(|s| s.as_str()).unwrap_or("unknown") {
"running" => Some("healthy".to_string()),
"created" => Some("starting".to_string()),
"paused" => Some("paused".to_string()),
"stopping" => Some("unhealthy".to_string()),
"exited" | "stopped" => Some("unhealthy".to_string()),
other => Some(format!("unknown:{other}")),
}
}
@@ -13,6 +13,7 @@ impl RpcHandler {
match method {
"echo" => self.handle_echo(params).await,
"server.echo" => self.handle_echo(params).await,
"server.get-state" => self.handle_server_get_state().await,
"health" => self.handle_health().await,
"auth.login" => self.handle_auth_login(params).await,
"auth.logout" => self.handle_auth_logout().await,
@@ -54,6 +55,7 @@ impl RpcHandler {
"package.restart" => self.handle_package_restart(params).await,
"package.uninstall" => self.clone().spawn_package_uninstall(params).await,
"package.update" => self.clone().spawn_package_update(params).await,
"package.credentials" => self.handle_package_credentials(params).await,
"app.filebrowser-token" => self.handle_filebrowser_token().await,
// Bundled app management (for pre-loaded container images)
@@ -96,6 +98,20 @@ impl RpcHandler {
// Bitcoin & Lightning deep data
"bitcoin.getinfo" => self.handle_bitcoin_getinfo().await,
"bitcoin.relay-status" => self.handle_bitcoin_relay_status().await,
"bitcoin.relay-update-settings" => {
self.handle_bitcoin_relay_update_settings(params).await
}
"bitcoin.relay-request-peer" => self.handle_bitcoin_relay_request_peer(params).await,
"bitcoin.relay-approve-request" => {
self.handle_bitcoin_relay_approve_request(params).await
}
"bitcoin.relay-reject-request" => {
self.handle_bitcoin_relay_reject_request(params).await
}
"bitcoin.relay-create-tor-service" => {
self.handle_bitcoin_relay_create_tor_service().await
}
"bitcoin.init-wallet-from-seed" => {
self.handle_bitcoin_init_wallet_from_seed(params).await
}
@@ -530,6 +546,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();
+4 -11
View File
@@ -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 {
+51 -3
View File
@@ -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);
}
+7 -5
View File
@@ -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
);
+6 -6
View File
@@ -3,7 +3,7 @@ use anyhow::{Context, Result};
use base64::Engine;
use serde::{Deserialize, Serialize};
use super::{read_lnd_admin_macaroon, LndAmount, LndBalanceResponse};
use super::{read_lnd_admin_macaroon, LndAmount, LndBalanceResponse, LND_REST_BASE_URL};
#[derive(Debug, Serialize)]
struct LndInfo {
@@ -44,7 +44,7 @@ impl RpcHandler {
.context("Failed to create HTTP client")?;
let get_info: LndGetInfoResponse = client
.get("https://127.0.0.1:8080/v1/getinfo")
.get(format!("{LND_REST_BASE_URL}/v1/getinfo"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
@@ -54,7 +54,7 @@ impl RpcHandler {
.context("Failed to parse LND getinfo response")?;
let channel_balance: LndChannelBalanceResponse = match client
.get("https://127.0.0.1:8080/v1/balance/channels")
.get(format!("{LND_REST_BASE_URL}/v1/balance/channels"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
@@ -70,7 +70,7 @@ impl RpcHandler {
};
let wallet_balance: LndBalanceResponse = match client
.get("https://127.0.0.1:8080/v1/balance/blockchain")
.get(format!("{LND_REST_BASE_URL}/v1/balance/blockchain"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
@@ -166,7 +166,7 @@ impl RpcHandler {
"cert_base64url": cert_b64url,
"macaroon_base64url": macaroon_b64url,
"tor_onion": tor_onion,
"rest_port": 8080,
"rest_port": 18080,
"grpc_port": 10009,
}))
}
@@ -186,7 +186,7 @@ impl RpcHandler {
.context("Failed to build HTTP client")?;
let resp = client
.get("https://127.0.0.1:8080/v1/channels/backup")
.get(format!("{LND_REST_BASE_URL}/v1/channels/backup"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
+1
View File
@@ -9,6 +9,7 @@ use anyhow::{anyhow, Context, Result};
/// Canonical on-host path for LND's admin macaroon.
pub(crate) const LND_ADMIN_MACAROON_PATH: &str =
"/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
pub(in crate::api) const LND_REST_BASE_URL: &str = "https://127.0.0.1:18080";
// Shared LND response types used by multiple submodules
#[derive(Debug, serde::Deserialize)]
+4 -2
View File
@@ -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
+17 -10
View File
@@ -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
@@ -21,10 +23,15 @@ impl RpcHandler {
.await
.context("Failed to parse newaddress response")?;
if let Some(error) = body.get("error").and_then(|v| v.as_str()) {
anyhow::bail!("LND could not generate an address: {}", error);
}
let address = body
.get("address")
.and_then(|v| v.as_str())
.unwrap_or("")
.filter(|addr| !addr.trim().is_empty())
.ok_or_else(|| anyhow::anyhow!("LND did not return a Bitcoin address. The wallet may still be locked, uninitialized, or waiting for Bitcoin to sync."))?
.to_string();
Ok(serde_json::json!({ "address": address }))
@@ -69,7 +76,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 +136,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 +238,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 +296,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 +329,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 +394,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 +423,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 +521,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
+1 -1
View File
@@ -188,7 +188,7 @@ impl RpcHandler {
let (client, macaroon_hex) = self.lnd_client().await?;
let url = format!("https://127.0.0.1:8080/v1/invoice/{}", r_hash);
let url = format!("{}/v1/invoice/{r_hash}", super::lnd::LND_REST_BASE_URL);
let paid = match client
.get(&url)
.header("Grpc-Metadata-macaroon", &macaroon_hex)
@@ -53,6 +53,7 @@ pub(super) fn sanitize_error_message(msg: &str) -> String {
"Unauthorized",
"Forbidden",
"Not supported",
"Requires",
"requires",
"must be",
"cannot",
+3 -1
View File
@@ -2,6 +2,7 @@ mod analytics;
mod auth;
mod backup_rpc;
mod bitcoin;
pub(crate) mod bitcoin_relay;
mod container;
mod content;
mod credentials;
@@ -12,7 +13,7 @@ mod fips;
mod handshake;
mod identity;
mod interfaces;
mod lnd;
pub(in crate::api) mod lnd;
mod marketplace;
mod mesh;
mod middleware;
@@ -302,6 +303,7 @@ impl RpcHandler {
| "system.stats"
| "tor.status"
| "tor.onion-addresses"
| "bitcoin.relay-status"
| "federation.list-nodes"
| "system.get-settings"
| "system.get-node-key"
@@ -54,6 +54,7 @@ impl RpcHandler {
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?
.to_string();
super::validation::validate_app_id(&package_id)?;
super::dependencies::check_bitcoin_pruning_compatibility(&package_id).await?;
// Reject if already in a transitional lifecycle (prevents double-click
// queuing two installs on the same package).
@@ -113,11 +114,26 @@ impl RpcHandler {
Err(e) => {
error!("package.install {} failed: {:#}", package_id_spawn, e);
install_log(&format!("INSTALL FAIL: {}{:#}", package_id_spawn, e)).await;
// No pre-state to revert to — remove the entry entirely so
// the UI shows the app as not installed. The next package
// scan will re-create it only if podman actually has a
// container for it (partial install recovery).
remove_package_entry(&handler.state_manager, &package_id_spawn).await;
// Don't remove the entry — that's what made the card
// vanish from My Apps mid-install / between retry-loop
// attempts (e.g. tailscale's entrypoint failure). Leave
// the entry visible with state=Stopped + the install
// error in install_progress.message so the user can see
// what went wrong and decide whether to retry or
// uninstall. clear_install_progress would erase the
// message, so we set it explicitly here instead.
let err_msg = format!("Install failed: {:#}", e);
let (mut data, _) = handler.state_manager.get_snapshot().await;
if let Some(entry) = data.package_data.get_mut(&package_id_spawn) {
entry.state = PackageState::Stopped;
entry.install_progress = Some(crate::data_model::InstallProgress {
size: 0,
downloaded: 0,
phase: None,
message: Some(err_msg),
});
handler.state_manager.update_data(data).await;
}
}
}
});
@@ -395,16 +411,6 @@ async fn set_package_state_and_clear_uninstall_stage(
}
}
/// Remove a package entry from state. Used for install-failure cleanup
/// (since there's no pre-state to revert to — the entry was created
/// speculatively when we flipped to Installing).
async fn remove_package_entry(state_manager: &StateManager, package_id: &str) {
let (mut data, _) = state_manager.get_snapshot().await;
if data.package_data.remove(package_id).is_some() {
state_manager.update_data(data).await;
}
}
/// Kick the container scanner to run immediately and wait for it to finish
/// (with a 2s timeout). Used by install/update success paths so the fresh
/// manifest — with `interfaces.main.ui` populated from the now-running
+283 -84
View File
@@ -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(60);
fn is_platform_managed_app(app_id: &str) -> bool {
matches!(
app_id,
"bitcoin"
| "bitcoin-core"
| "bitcoin-knots"
| "bitcoin-ui"
| "lnd"
| "lnd-ui"
| "electrumx"
| "electrs"
| "mempool-electrs"
| "electrs-ui"
| "mempool"
| "mempool-web"
| "mempool-api"
| "archy-mempool-db"
| "archy-mempool-web"
| "btcpay"
| "btcpay-server"
| "btcpayserver"
| "archy-btcpay-db"
| "archy-nbxplorer"
| "fedimint"
| "fedimint-gateway"
| "indeedhub"
| "immich"
)
}
fn safe_dynamic_arg(value: &str) -> bool {
!value.is_empty()
&& value.len() <= 512
&& !value.chars().any(|c| matches!(c, '\0' | '\n' | '\r'))
}
async fn dynamic_app_config(
app_id: &str,
) -> Option<(
Vec<String>,
Vec<String>,
Vec<String>,
Option<String>,
Option<Vec<String>>,
)> {
if is_platform_managed_app(app_id) {
return None;
}
let config_path = format!("/var/lib/archipelago/app-configs/{}.json", app_id);
let data = tokio::fs::read_to_string(&config_path).await.ok()?;
let cfg = serde_json::from_str::<serde_json::Value>(&data).ok()?;
let string_array = |key: &str| -> Vec<String> {
cfg.get(key)
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|v| v.as_str())
.filter(|s| safe_dynamic_arg(s))
.map(String::from)
.collect()
})
.unwrap_or_default()
};
let command = cfg
.get("command")
.and_then(|v| v.as_str())
.filter(|s| safe_dynamic_arg(s))
.map(String::from);
let args = cfg.get("args").and_then(|v| v.as_array()).map(|a| {
a.iter()
.filter_map(|v| v.as_str())
.filter(|s| safe_dynamic_arg(s))
.map(String::from)
.collect::<Vec<_>>()
});
tracing::info!(app_id = %app_id, "loaded catalog runtime config for generic app");
Some((
string_array("ports"),
string_array("volumes"),
string_array("env"),
command,
args.filter(|a| !a.is_empty()),
))
}
/// Trusted Docker registries. Only images from these sources are allowed.
#[allow(dead_code)]
@@ -63,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,22 +256,21 @@ 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")
@@ -179,10 +281,10 @@ pub(super) fn get_health_check_args(app_id: &str, _rpc_pass: &str) -> Vec<String
"3",
),
"homeassistant" | "home-assistant" => {
("curl -sf http://localhost:8123/api/ || exit 1", "30s", "3")
("curl -sf http://localhost:8123/ || exit 1", "30s", "3")
}
"grafana" => (
"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",
),
@@ -199,7 +301,7 @@ pub(super) fn get_health_check_args(app_id: &str, _rpc_pass: &str) -> Vec<String
"30s",
"3",
),
"searxng" => ("wget -q -O /dev/null http://localhost:8080/ || exit 1", "30s", "3"),
"searxng" => (http_probe_cmd("http://localhost:8080/"), "30s", "3"),
"photoprism" => (
"curl -sf http://localhost:2342/api/v1/status || exit 1",
"60s",
@@ -219,10 +321,8 @@ pub(super) fn get_health_check_args(app_id: &str, _rpc_pass: &str) -> Vec<String
"ollama" => ("curl -sf http://localhost:11434/ || exit 1", "30s", "3"),
"fedimint" => ("curl -sf http://localhost:8175/ || exit 1", "60s", "3"),
"fedimint-gateway" => ("curl -sf http://localhost:8176/ || exit 1", "60s", "3"),
"nostr-rs-relay" | "nostr-relay" => {
("curl -sf http://localhost:8080/ || exit 1", "30s", "3")
}
"nginx-proxy-manager" => ("curl -sf http://localhost:81/api/ || exit 1", "30s", "3"),
"nostr-rs-relay" | "nostr-relay" => (http_probe_cmd("http://localhost:8080/"), "30s", "3"),
"nginx-proxy-manager" => (http_probe_cmd("http://localhost:81/"), "30s", "3"),
"routstr" => (
"curl -sf http://localhost:8000/v1/models || exit 1",
"30s",
@@ -237,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",
@@ -272,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",
@@ -292,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
@@ -360,6 +486,20 @@ 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-dashboard".into(),
"netbird-server".into(),
],
"nostr-vpn" => vec![
"nostr-vpn".into(),
"archy-nostr-vpn".into(),
@@ -374,12 +514,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();
@@ -394,11 +536,43 @@ 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),
@@ -414,6 +588,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 +650,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()],
@@ -497,6 +676,16 @@ pub(super) async fn get_app_config(
// only what's in bitcoin.conf + argv. The shared bitcoin.conf
// carries rpcauth; we inject the networking flags as CLI args so
// RPC is reachable from the bitcoin-ui companion container.
//
// Sync-speed flags:
// -dbcache=4096 — UTXO set cache; 4GB is the sweet spot before
// diminishing returns. Container has --memory=8g now so
// there's headroom for mempool + connections.
// -par=0 — use all available cores for script
// verification (defaults to NCPU-1 capped at 16). Was
// effectively pinned at 2 by --cpus=2 (now removed).
// -maxconnections=125 — default but explicit, so ops can
// tune downward on bandwidth-constrained nodes.
Some(vec![
"-server=1".to_string(),
"-rpcbind=0.0.0.0".to_string(),
@@ -504,6 +693,9 @@ pub(super) async fn get_app_config(
"-rpcport=8332".to_string(),
"-printtoconsole=1".to_string(),
"-datadir=/home/bitcoin/.bitcoin".to_string(),
"-dbcache=4096".to_string(),
"-par=0".to_string(),
"-maxconnections=125".to_string(),
]),
),
"bitcoin" | "bitcoin-knots" => (
@@ -522,7 +714,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![],
@@ -597,6 +789,14 @@ pub(super) async fn get_app_config(
"COIN=Bitcoin".to_string(),
"DB_DIRECTORY=/data".to_string(),
"SERVICES=tcp://:50001,rpc://0.0.0.0:8000".to_string(),
// Sync-speed: bigger LRU/write cache during initial
// history index. Default is 1200MB; 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,
@@ -733,25 +933,40 @@ 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(),
"/run/user/1000/podman/podman.sock:/var/run/docker.sock".to_string(),
"/var/lib/archipelago/portainer/compose:/data/compose".to_string(),
],
vec![],
None,
@@ -762,7 +977,11 @@ pub(super) async fn get_app_config(
vec!["/var/lib/archipelago/uptime-kuma:/app/data".to_string()],
vec!["TZ=UTC".to_string()],
None,
None,
Some(vec![
"--".to_string(),
"node".to_string(),
"server/server.js".to_string(),
]),
),
"tailscale" => (
vec!["8240:8240".to_string()],
@@ -777,7 +996,7 @@ pub(super) async fn get_app_config(
Some(vec![
"sh".to_string(),
"-c".to_string(),
"tailscale web --listen 0.0.0.0:8240 & exec tailscaled".to_string(),
"tailscaled --tun=userspace-networking & for i in $(seq 1 30); do [ -S /var/run/tailscale/tailscaled.sock ] && break; sleep 1; done; tailscale web --listen 0.0.0.0:8240 & wait".to_string(),
]),
),
"fedimint" => (
@@ -938,8 +1157,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![
@@ -960,31 +1179,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,19 @@ 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"]
}
"netbird" => &["netbird-server", "netbird-dashboard", "netbird"],
"penpot" | "penpot-frontend" => &[
"penpot-postgres",
"penpot-valkey",
@@ -144,17 +304,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 +377,40 @@ 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 netbird_start_order_starts_server_before_dashboard() {
assert_eq!(
startup_order("netbird"),
&["netbird-server", "netbird-dashboard", "netbird"]
);
}
#[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
@@ -25,6 +25,7 @@ impl RpcHandler {
size,
downloaded,
phase: existing_phase,
message: None,
});
self.state_manager.update_data(data).await;
}
@@ -55,6 +56,7 @@ impl RpcHandler {
size,
downloaded,
phase: Some(phase),
message: None,
});
self.state_manager.update_data(data).await;
}
@@ -97,6 +99,7 @@ impl RpcHandler {
size: total,
downloaded,
phase: existing_phase,
message: None,
});
state_manager.update_data(data).await;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+39 -16
View File
@@ -16,6 +16,8 @@ use anyhow::{Context, Result};
use tokio::io::{AsyncBufReadExt, BufReader};
use tracing::{error, info, warn};
const PODMAN_UPDATE_PULL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600);
impl RpcHandler {
/// Update a package to the version pinned in image-versions.sh.
/// This is a manual operation — the user clicks "Update" in the UI.
@@ -322,30 +324,51 @@ impl RpcHandler {
async fn pull_update_image(&self, package_id: &str, image: &str) -> Result<()> {
self.set_install_progress(package_id, 0, 0).await;
let mut child = tokio::process::Command::new("podman")
.args(["pull", image])
let mut cmd = tokio::process::Command::new("podman");
cmd.arg("pull");
if archipelago_container::image_uses_insecure_registry(image) {
cmd.arg("--tls-verify=false");
}
cmd.kill_on_drop(true);
let mut child = cmd
.arg(image)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.context("Failed to start image pull")?;
if let Some(stderr) = child.stderr.take() {
let progress_task = if let Some(stderr) = child.stderr.take() {
let reader = BufReader::new(stderr);
let mut lines = reader.lines();
let pkg_id = package_id.to_string();
let state_mgr = self.state_manager.clone();
while let Ok(Some(line)) = lines.next_line().await {
if let Some((downloaded, total)) = parse_pull_progress(&line) {
Self::update_install_progress(&state_mgr, &pkg_id, downloaded, total).await;
Some(tokio::spawn(async move {
while let Ok(Some(line)) = lines.next_line().await {
if let Some((downloaded, total)) = parse_pull_progress(&line) {
Self::update_install_progress(&state_mgr, &pkg_id, downloaded, total).await;
}
}
}
}
}))
} else {
None
};
let status = child
.wait()
.await
.context("Failed to wait for image pull")?;
let status = match tokio::time::timeout(PODMAN_UPDATE_PULL_TIMEOUT, child.wait()).await {
Ok(result) => result.context("Failed to wait for image pull")?,
Err(_) => {
let _ = child.kill().await;
return Err(anyhow::anyhow!(
"podman pull {} timed out after {}s",
image,
PODMAN_UPDATE_PULL_TIMEOUT.as_secs()
));
}
};
if let Some(task) = progress_task {
let _ = task.await;
}
if !status.success() {
return Err(anyhow::anyhow!("podman pull {} failed", image));
}
@@ -425,7 +448,6 @@ fn should_try_orchestrator_update(package_id: &str, orchestrator_available: bool
fn orchestrator_update_app_id(package_id: &str) -> &str {
match package_id {
"bitcoin-knots" => "bitcoin-core",
"electrs" | "mempool-electrs" => "electrumx",
_ => package_id,
}
@@ -454,8 +476,8 @@ fn candidate_app_ids_for_container(container_name: &str) -> Vec<String> {
match container_name {
"bitcoin-knots" | "bitcoin-core" => {
push("bitcoin-core");
push("bitcoin-knots");
push("bitcoin-core");
}
"archy-bitcoin-ui" => push("bitcoin-ui"),
"archy-lnd-ui" => push("lnd-ui"),
@@ -520,7 +542,7 @@ mod tests {
fn container_name_candidates_cover_common_aliases() {
assert_eq!(
candidate_app_ids_for_container("bitcoin-knots"),
vec!["bitcoin-core", "bitcoin-knots"]
vec!["bitcoin-knots", "bitcoin-core"]
);
assert_eq!(
candidate_app_ids_for_container("archy-bitcoin-ui"),
@@ -538,7 +560,8 @@ mod tests {
#[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("bitcoin-knots"), "bitcoin-knots");
assert_eq!(orchestrator_update_app_id("bitcoin-core"), "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");
+125 -22
View File
@@ -1,7 +1,7 @@
use super::*;
use crate::api::rpc::RpcHandler;
use anyhow::{Context, Result};
use tracing::{debug, info};
use tracing::{debug, info, warn};
impl RpcHandler {
/// server.set-name — Rename the server (persisted to data_dir/server-name)
@@ -32,6 +32,21 @@ impl RpcHandler {
data.server_info.name = Some(name.clone());
self.state_manager.update_data(data).await;
let hostname = hostname_from_server_name(&name);
let hostname_result = set_system_hostname(&hostname).await;
let (hostname_updated, hostname_error) = match hostname_result {
Ok(()) => (true, None),
Err(e) => {
warn!(
name = %name,
hostname = %hostname,
"Server name persisted but OS hostname update failed: {}",
e
);
(false, Some(e.to_string()))
}
};
info!("Server name updated to: {}", name);
// Push the new name to federation peers in background
@@ -43,7 +58,12 @@ impl RpcHandler {
}
});
Ok(serde_json::json!({ "name": name }))
Ok(serde_json::json!({
"name": name,
"hostname": hostname,
"hostname_updated": hostname_updated,
"hostname_error": hostname_error,
}))
}
/// system.stats — CPU usage, RAM used/total, disk used/total, uptime, load average
@@ -155,21 +175,7 @@ impl RpcHandler {
let mut freed_bytes: u64 = 0;
let mut actions: Vec<String> = Vec::new();
// 1. Prune dangling container images
match prune_container_images().await {
Ok(bytes) => {
if bytes > 0 {
freed_bytes += bytes;
actions.push(format!(
"Pruned dangling images: {} freed",
format_bytes(bytes)
));
}
}
Err(e) => actions.push(format!("Image prune failed: {}", e)),
}
// 2. Clean old log files (> 30 days)
// 1. Clean old log files (> 30 days)
match clean_old_logs(30).await {
Ok(bytes) => {
if bytes > 0 {
@@ -180,7 +186,20 @@ impl RpcHandler {
Err(e) => actions.push(format!("Log cleanup failed: {}", e)),
}
// 3. Remove stale temp files
match vacuum_journal_logs("200M").await {
Ok(bytes) => {
if bytes > 0 {
freed_bytes += bytes;
actions.push(format!(
"Vacuumed journal logs: {} freed",
format_bytes(bytes)
));
}
}
Err(e) => actions.push(format!("Journal cleanup failed: {}", e)),
}
// 2. Remove stale temp files
match clean_temp_files().await {
Ok(bytes) => {
if bytes > 0 {
@@ -191,17 +210,53 @@ impl RpcHandler {
Err(e) => actions.push(format!("Temp cleanup failed: {}", e)),
}
// 4. Prune container build cache
match prune_build_cache().await {
// 3. Keep only the most recent backend deploy backups. These are useful
// for rollback, but a long-lived alpha node can accumulate gigabytes of
// old binaries under /usr/local/bin.
match clean_backend_backups(3).await {
Ok(bytes) => {
if bytes > 0 {
freed_bytes += bytes;
actions.push(format!("Pruned build cache: {} freed", format_bytes(bytes)));
actions.push(format!(
"Removed old backend backups: {} freed",
format_bytes(bytes)
));
}
}
Err(e) => actions.push(format!("Build cache prune failed: {}", e)),
Err(e) => actions.push(format!("Backend backup cleanup failed: {}", e)),
}
match clean_legacy_backend_backups(3).await {
Ok(bytes) => {
if bytes > 0 {
freed_bytes += bytes;
actions.push(format!(
"Removed old legacy backend backups: {} freed",
format_bytes(bytes)
));
}
}
Err(e) => actions.push(format!("Legacy backend backup cleanup failed: {}", e)),
}
match clean_web_ui_backups(3).await {
Ok(bytes) => {
if bytes > 0 {
freed_bytes += bytes;
actions.push(format!(
"Removed old web UI backups: {} freed",
format_bytes(bytes)
));
}
}
Err(e) => actions.push(format!("Web UI backup cleanup failed: {}", e)),
}
actions.push(
"Skipped Podman image/volume prune: Podman store commands can block app health on busy nodes"
.to_string(),
);
tracing::info!(
"Disk cleanup complete: {} freed ({} actions)",
format_bytes(freed_bytes),
@@ -216,6 +271,54 @@ impl RpcHandler {
}
}
pub(super) fn hostname_from_server_name(name: &str) -> String {
let mut hostname = String::with_capacity(name.len());
let mut previous_dash = false;
for c in name.trim().chars().flat_map(char::to_lowercase) {
let valid = c.is_ascii_lowercase() || c.is_ascii_digit();
if valid {
hostname.push(c);
previous_dash = false;
} else if !previous_dash {
hostname.push('-');
previous_dash = true;
}
if hostname.len() >= 63 {
break;
}
}
let hostname = hostname.trim_matches('-').to_string();
if hostname.is_empty() {
"archipelago".to_string()
} else {
hostname
}
}
async fn set_system_hostname(hostname: &str) -> Result<()> {
let output = tokio::process::Command::new("/usr/bin/sudo")
.args(["-n", "/usr/bin/hostnamectl", "set-hostname", hostname])
.output()
.await
.context("Failed to run hostnamectl")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
anyhow::bail!(
"{}",
if stderr.is_empty() {
"hostnamectl failed".to_string()
} else {
stderr
}
);
}
Ok(())
}
impl RpcHandler {
/// system.factory-reset — Wipe all user data, remove containers, and restart.
/// Only preserves the data_dir itself (recreated empty on restart).
+346 -47
View File
@@ -1,6 +1,9 @@
mod handlers;
use crate::update::host_sudo;
use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use tracing::{debug, info};
/// Push the server name to all federation peers by syncing state.
@@ -301,53 +304,12 @@ pub(super) async fn detect_usb_hardware_wallets() -> Result<Vec<serde_json::Valu
Ok(devices)
}
/// Prune dangling container images via `podman image prune -f`.
/// Returns estimated bytes freed.
pub(super) async fn prune_container_images() -> Result<u64> {
let output = tokio::process::Command::new("podman")
.args(["image", "prune", "-f"])
.output()
.await
.context("Failed to run podman image prune")?;
if !output.status.success() {
anyhow::bail!(
"podman image prune failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
// Podman outputs image IDs, estimate ~100MB per pruned image
let stdout = String::from_utf8_lossy(&output.stdout);
let pruned_count = stdout.lines().filter(|l| !l.trim().is_empty()).count();
Ok(pruned_count as u64 * 100_000_000) // rough estimate
}
/// Prune container build cache via `podman system prune -f`.
pub(super) async fn prune_build_cache() -> Result<u64> {
// Just prune volumes and build cache (not containers or images — those are handled above)
let output = tokio::process::Command::new("podman")
.args(["volume", "prune", "-f"])
.output()
.await
.context("Failed to run podman volume prune")?;
if !output.status.success() {
anyhow::bail!(
"podman volume prune failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
let stdout = String::from_utf8_lossy(&output.stdout);
let pruned_count = stdout.lines().filter(|l| !l.trim().is_empty()).count();
Ok(pruned_count as u64 * 10_000_000) // rough estimate per volume
}
/// Clean log files older than `max_age_days` from common log directories.
pub(super) async fn clean_old_logs(max_age_days: u64) -> Result<u64> {
let output = tokio::process::Command::new("sudo")
let output = tokio::process::Command::new("timeout")
.args([
"60s",
"sudo",
"find",
"/var/log",
"-type",
@@ -366,8 +328,10 @@ pub(super) async fn clean_old_logs(max_age_days: u64) -> Result<u64> {
let stdout = String::from_utf8_lossy(&output.stdout);
let deleted_count = stdout.lines().filter(|l| !l.trim().is_empty()).count();
// Also clean rotated/compressed logs
let _ = tokio::process::Command::new("sudo")
let _ = tokio::process::Command::new("timeout")
.args([
"60s",
"sudo",
"find",
"/var/log",
"-type",
@@ -384,14 +348,81 @@ pub(super) async fn clean_old_logs(max_age_days: u64) -> Result<u64> {
Ok(deleted_count as u64 * 500_000) // rough estimate per log file
}
/// Vacuum systemd journals to a bounded size. Returns measured bytes freed.
pub(super) async fn vacuum_journal_logs(max_size: &str) -> Result<u64> {
let before = journal_disk_usage().await.unwrap_or(0);
let output = tokio::process::Command::new("timeout")
.args(["60s", "sudo", "journalctl", "--vacuum-size", max_size])
.output()
.await
.context("Failed to run journal vacuum")?;
if !output.status.success() {
anyhow::bail!(
"journal vacuum failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
let after = journal_disk_usage().await.unwrap_or(before);
Ok(before.saturating_sub(after))
}
async fn journal_disk_usage() -> Result<u64> {
let output = tokio::process::Command::new("sudo")
.args(["-n", "journalctl", "--disk-usage"])
.output()
.await
.context("Failed to read journal disk usage")?;
if !output.status.success() {
anyhow::bail!(
"journalctl --disk-usage failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
parse_journal_disk_usage(&String::from_utf8_lossy(&output.stdout))
.ok_or_else(|| anyhow::anyhow!("could not parse journal disk usage"))
}
fn parse_journal_disk_usage(output: &str) -> Option<u64> {
let mut parts = output.split_whitespace();
while let Some(part) = parts.next() {
let (number, inline_unit) = split_number_unit(part);
let Ok(value) = number.parse::<f64>() else {
continue;
};
let unit = inline_unit.unwrap_or_else(|| parts.next().unwrap_or_default());
let multiplier = match unit {
"B" | "bytes" => 1.0,
"K" | "KB" | "KiB" => 1024.0,
"M" | "MB" | "MiB" => 1024.0 * 1024.0,
"G" | "GB" | "GiB" => 1024.0 * 1024.0 * 1024.0,
_ => continue,
};
return Some((value * multiplier) as u64);
}
None
}
fn split_number_unit(value: &str) -> (&str, Option<&str>) {
let split_at = value
.char_indices()
.find_map(|(idx, ch)| (!ch.is_ascii_digit() && ch != '.').then_some(idx))
.unwrap_or(value.len());
let (number, unit) = value.split_at(split_at);
(number, (!unit.is_empty()).then_some(unit))
}
/// Remove stale temp files from /tmp and /var/tmp.
pub(super) async fn clean_temp_files() -> Result<u64> {
let mut freed = 0u64;
for dir in &["/tmp", "/var/tmp"] {
let output = tokio::process::Command::new("sudo")
let output = tokio::process::Command::new("timeout")
.args([
"find", dir, "-type", "f", "-mtime", "+7", "-delete", "-print",
"45s", "sudo", "find", dir, "-type", "f", "-mtime", "+7", "-delete", "-print",
])
.output()
.await;
@@ -406,6 +437,177 @@ pub(super) async fn clean_temp_files() -> Result<u64> {
Ok(freed)
}
/// Keep the newest timestamped backend backups and remove older ones.
pub(super) async fn clean_backend_backups(keep: usize) -> Result<u64> {
clean_backend_backups_in(Path::new("/usr/local/bin"), keep).await
}
/// Keep the newest legacy backend backups and remove older alpha-era deploy artifacts.
pub(super) async fn clean_legacy_backend_backups(keep: usize) -> Result<u64> {
clean_named_backups_in(
Path::new("/usr/local/bin"),
keep,
|name| name.starts_with("archipelago.bak") || name.starts_with("archipelago.before-"),
false,
)
.await
}
/// Keep the newest web UI rollback backups and remove older copies.
pub(super) async fn clean_web_ui_backups(keep: usize) -> Result<u64> {
clean_named_backups_in(
Path::new("/opt/archipelago"),
keep,
|name| name.starts_with("web-ui.bak") || name == "web-ui.old",
true,
)
.await
}
async fn clean_backend_backups_in(dir: &Path, keep: usize) -> Result<u64> {
let mut backups = backend_backup_candidates(dir).await?;
remove_old_backups(&mut backups, keep, false).await
}
async fn clean_named_backups_in(
dir: &Path,
keep: usize,
matches_name: impl Fn(&str) -> bool,
allow_dirs: bool,
) -> Result<u64> {
let mut backups = named_backup_candidates(dir, matches_name, allow_dirs).await?;
remove_old_backups(&mut backups, keep, allow_dirs).await
}
async fn remove_old_backups(
backups: &mut Vec<BackupArtifact>,
keep: usize,
allow_dirs: bool,
) -> Result<u64> {
backups.sort_by(|a, b| {
b.modified
.cmp(&a.modified)
.then_with(|| b.name.cmp(&a.name))
});
let mut freed = 0u64;
for backup in backups.iter().skip(keep) {
let remove_result = if backup.is_dir && allow_dirs {
tokio::fs::remove_dir_all(&backup.path).await
} else {
tokio::fs::remove_file(&backup.path).await
};
match remove_result {
Ok(()) => freed += backup.size,
Err(_) => {
remove_path_with_sudo(&backup.path, backup.is_dir && allow_dirs).await?;
freed += backup.size;
}
}
}
Ok(freed)
}
async fn remove_path_with_sudo(path: &Path, recursive: bool) -> Result<()> {
let path = path.to_string_lossy();
let args = if recursive {
vec!["rm", "-rf", path.as_ref()]
} else {
vec!["rm", "-f", path.as_ref()]
};
let status = host_sudo(&args)
.await
.with_context(|| format!("removing {path} via sudo"))?;
if !status.success() {
anyhow::bail!(
"sudo rm {} {path} exited with {status}",
if recursive { "-rf" } else { "-f" }
);
}
Ok(())
}
#[derive(Debug)]
struct BackupArtifact {
path: PathBuf,
name: String,
modified: SystemTime,
size: u64,
is_dir: bool,
}
async fn backend_backup_candidates(dir: &Path) -> Result<Vec<BackupArtifact>> {
named_backup_candidates(
dir,
|name| {
name.strip_prefix("archipelago.backup-")
.is_some_and(|suffix| !suffix.is_empty() && !suffix.contains('/'))
},
false,
)
.await
}
async fn named_backup_candidates(
dir: &Path,
matches_name: impl Fn(&str) -> bool,
allow_dirs: bool,
) -> Result<Vec<BackupArtifact>> {
let mut backups = Vec::new();
let mut entries = match tokio::fs::read_dir(dir).await {
Ok(entries) => entries,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(backups),
Err(e) => return Err(e).with_context(|| format!("reading {}", dir.display())),
};
while let Some(entry) = entries.next_entry().await? {
let file_name = entry.file_name();
let name = file_name.to_string_lossy();
if !matches_name(&name) {
continue;
}
let meta = entry.metadata().await?;
if !meta.is_file() && !(allow_dirs && meta.is_dir()) {
continue;
}
backups.push(BackupArtifact {
path: entry.path(),
name: name.to_string(),
modified: meta.modified().unwrap_or(SystemTime::UNIX_EPOCH),
size: path_size(&entry.path(), &meta).await.unwrap_or(meta.len()),
is_dir: meta.is_dir(),
});
}
Ok(backups)
}
async fn path_size(path: &Path, meta: &std::fs::Metadata) -> Result<u64> {
if meta.is_file() {
return Ok(meta.len());
}
if !meta.is_dir() {
return Ok(0);
}
let output = tokio::process::Command::new("du")
.args(["-sb", &path.to_string_lossy()])
.output()
.await
.with_context(|| format!("du -sb {}", path.display()))?;
if !output.status.success() {
anyhow::bail!("du -sb {} failed", path.display());
}
let stdout = String::from_utf8_lossy(&output.stdout);
stdout
.split_whitespace()
.next()
.ok_or_else(|| anyhow::anyhow!("du output missing size for {}", path.display()))?
.parse::<u64>()
.with_context(|| format!("parse du size for {}", path.display()))
}
pub(super) fn format_bytes(bytes: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = KB * 1024;
@@ -422,6 +624,103 @@ pub(super) fn format_bytes(bytes: u64) -> String {
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn backend_backup_cleanup_keeps_newest_files() {
let dir = tempfile::tempdir().unwrap();
for name in [
"archipelago.backup-20260501",
"archipelago.backup-20260502",
"archipelago.backup-20260503",
"archipelago.backup-20260504",
"archipelago.backup-20260505",
"archipelago.bak",
"archipelago",
] {
tokio::fs::write(dir.path().join(name), b"12345")
.await
.unwrap();
}
let freed = clean_backend_backups_in(dir.path(), 3).await.unwrap();
assert_eq!(freed, 10);
assert!(!dir.path().join("archipelago.backup-20260501").exists());
assert!(!dir.path().join("archipelago.backup-20260502").exists());
assert!(dir.path().join("archipelago.backup-20260503").exists());
assert!(dir.path().join("archipelago.backup-20260504").exists());
assert!(dir.path().join("archipelago.backup-20260505").exists());
assert!(dir.path().join("archipelago.bak").exists());
assert!(dir.path().join("archipelago").exists());
}
#[tokio::test]
async fn legacy_backend_backup_cleanup_keeps_newest_matching_files() {
let dir = tempfile::tempdir().unwrap();
for name in [
"archipelago.bak-1",
"archipelago.bak-2",
"archipelago.before-3",
"archipelago.backup-keep-separate",
"archipelago",
] {
tokio::fs::write(dir.path().join(name), b"12345")
.await
.unwrap();
}
let freed = clean_named_backups_in(
dir.path(),
1,
|name| name.starts_with("archipelago.bak") || name.starts_with("archipelago.before-"),
false,
)
.await
.unwrap();
assert_eq!(freed, 10);
assert_eq!(
[
"archipelago.bak-1",
"archipelago.bak-2",
"archipelago.before-3"
]
.into_iter()
.filter(|name| dir.path().join(name).exists())
.count(),
1
);
assert!(dir.path().join("archipelago.backup-keep-separate").exists());
assert!(dir.path().join("archipelago").exists());
}
#[test]
fn hostname_from_server_name_derives_linux_safe_hostname() {
assert_eq!(
handlers::hostname_from_server_name("My Archipelago Node"),
"my-archipelago-node"
);
assert_eq!(
handlers::hostname_from_server_name("Kitchen_Node!! 01"),
"kitchen-node-01"
);
assert_eq!(handlers::hostname_from_server_name("!!!"), "archipelago");
}
#[test]
fn parses_journal_disk_usage() {
assert_eq!(
parse_journal_disk_usage(
"Archived and active journals take up 463.9M in the file system."
),
Some(486_434_406)
);
}
}
/// Read temperatures from /sys/class/thermal/thermal_zone*/temp.
pub(super) async fn read_temperatures() -> Result<Vec<serde_json::Value>> {
let mut temps = Vec::new();
+2 -2
View File
@@ -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,
}
}
+23 -20
View File
@@ -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,
}))
+38 -6
View File
@@ -86,6 +86,11 @@ pub struct AuthManager {
data_dir: PathBuf,
}
pub struct ChangePasswordOutcome {
pub ssh_updated: bool,
pub ssh_error: Option<String>,
}
impl AuthManager {
pub fn new(data_dir: PathBuf) -> Self {
Self { data_dir }
@@ -288,7 +293,7 @@ impl AuthManager {
current_password: &str,
new_password: &str,
also_change_ssh: bool,
) -> Result<()> {
) -> Result<ChangePasswordOutcome> {
if !self.verify_password(current_password).await? {
anyhow::bail!("Current password is incorrect");
}
@@ -314,11 +319,21 @@ impl AuthManager {
let content = serde_json::to_string_pretty(&user)?;
fs::write(&user_file, content).await?;
let mut outcome = ChangePasswordOutcome {
ssh_updated: false,
ssh_error: None,
};
if also_change_ssh {
change_ssh_password(new_password).await?;
match change_ssh_password(new_password).await {
Ok(()) => outcome.ssh_updated = true,
Err(e) => {
tracing::warn!("Web password changed but SSH password update failed: {}", e);
outcome.ssh_error = Some(e.to_string());
}
}
}
Ok(())
Ok(outcome)
}
}
@@ -381,14 +396,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);
@@ -485,6 +500,23 @@ mod tests {
assert!(validate_password_strength("MyP@ssw0rd!123").is_ok());
}
#[tokio::test]
async fn test_change_password_updates_web_password_without_ssh() {
let dir = tempfile::tempdir().unwrap();
let auth = AuthManager::new(dir.path().to_path_buf());
auth.setup_user("password123").await.unwrap();
let outcome = auth
.change_password("password123", "MyP@ssw0rd!123", false)
.await
.unwrap();
assert!(!outcome.ssh_updated);
assert!(outcome.ssh_error.is_none());
assert!(auth.verify_password("MyP@ssw0rd!123").await.unwrap());
assert!(!auth.verify_password("password123").await.unwrap());
}
#[test]
fn test_validate_password_strength_too_short() {
assert!(validate_password_strength("Ab1!").is_err());
+252
View File
@@ -0,0 +1,252 @@
//! 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 = 10;
const CACHE_ERROR_BACKOFF_SECS: u64 = 15;
#[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")
|| lower.contains("503 service unavailable")
|| lower.contains("work queue depth exceeded")
|| lower.contains("decode bitcoin rpc json")
|| lower.contains("error decoding response body")
|| lower.contains("expected value at line 1 column 1")
}
fn friendly_transient_error(has_cached_state: bool, err_msg: &str) -> String {
let detail = err_msg
.lines()
.next()
.unwrap_or(err_msg)
.trim()
.trim_end_matches('.');
let lower = detail.to_lowercase();
let state = if lower.contains("verifying blocks") {
"verifying blocks after restart"
} else if lower.contains("connection refused") || lower.contains("tcp connect error") {
"waiting for the Bitcoin RPC listener"
} else if lower.contains("timed out") || lower.contains("timeout") {
"busy and not answering RPC before the timeout"
} else {
"starting or busy syncing"
};
if has_cached_state {
format!("Bitcoin node is {state}; showing last known state and retrying. Detail: {detail}")
} else {
format!("Bitcoin node is {state}; retrying automatically. Detail: {detail}")
}
}
pub fn spawn_status_cache() {
tokio::spawn(async {
loop {
let fresh = fetch_bitcoin_status().await;
let mut cached = cache().write().await;
let mut sleep_secs = CACHE_REFRESH_SECS;
match fresh {
Ok(mut status) => {
status.ok = true;
status.stale = false;
status.error = None;
*cached = status;
}
Err(e) => {
let err_msg = format!("{e:#}");
if transient_error(&err_msg) {
debug!("Bitcoin status: transient RPC failure: {}", err_msg);
} else {
warn!("Bitcoin status: RPC failure: {}", err_msg);
}
sleep_secs = CACHE_ERROR_BACKOFF_SECS;
if cached.blockchain_info.is_some() {
cached.ok = false;
cached.stale = true;
cached.error = Some(friendly_transient_error(true, &err_msg));
} else {
*cached = BitcoinNodeStatus {
ok: false,
stale: false,
updated_at_ms: now_ms(),
error: Some(friendly_transient_error(false, &err_msg)),
..BitcoinNodeStatus::default()
};
}
}
}
drop(cached);
tokio::time::sleep(Duration::from_secs(sleep_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(20))
.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")
}
#[cfg(test)]
mod tests {
use super::friendly_transient_error;
#[test]
fn explains_verifying_blocks_without_generic_timeout_copy() {
let msg = friendly_transient_error(
false,
r#"getblockchaininfo: Bitcoin RPC returned 500 Internal Server Error: {"error":{"code":-28,"message":"Verifying blocks..."}}"#,
);
assert!(msg.contains("verifying blocks after restart"));
assert!(msg.contains("retrying automatically"));
}
#[test]
fn explains_missing_rpc_listener() {
let msg = friendly_transient_error(
true,
"getblockchaininfo: tcp connect error: Connection refused (os error 111)",
);
assert!(msg.contains("waiting for the Bitcoin RPC listener"));
assert!(msg.contains("showing last known state"));
}
#[test]
fn explains_rpc_timeout() {
let msg = friendly_transient_error(
false,
"getblockchaininfo: Bitcoin RPC request failed: operation timed out",
);
assert!(msg.contains("busy and not answering RPC before the timeout"));
}
}
+413 -55
View File
@@ -8,14 +8,14 @@
//!
//! Two things are synced on startup:
//! 1. Doctor artifacts (container-doctor.sh + service + timer).
//! 2. An nginx `location /api/app-catalog` proxy block required for
//! the App Store catalog proxy to actually reach the backend.
//! 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;
use std::path::{Path, PathBuf};
use tokio::fs;
use tracing::{debug, info, warn};
@@ -31,25 +31,352 @@ const DOCTOR_SERVICE_PATH: &str = "/etc/systemd/system/archipelago-doctor.servic
const DOCTOR_TIMER_PATH: &str = "/etc/systemd/system/archipelago-doctor.timer";
const NGINX_CONF_PATH: &str = "/etc/nginx/sites-available/archipelago";
const 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 /api/app-catalog"),
Ok(false) => debug!("Nginx already has /api/app-catalog block"),
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> {
@@ -101,21 +428,14 @@ async fn run() -> Result<bool> {
let timer_changed = write_root_if_needed(DOCTOR_TIMER_PATH, DOCTOR_TIMER).await?;
changed = changed || service_changed || timer_changed;
// 3. Reload + enable. Only when we actually touched units, or when the
// timer isn't enabled yet (catches fresh upgrades of boxes that predate
// the doctor entirely).
let timer_enabled = is_timer_enabled().await;
if service_changed || timer_changed || !timer_enabled {
// 3. Reload if units changed. Do not enable/start the timer here: lifecycle
// qualification and explicit app operations need deterministic Podman
// ownership, and the doctor can race those flows. Operators can enable it
// separately when they want periodic host repair.
if service_changed || timer_changed {
if let Err(e) = host_sudo(&["systemctl", "daemon-reload"]).await {
warn!("daemon-reload failed: {:#}", e);
}
if let Err(e) =
host_sudo(&["systemctl", "enable", "--now", "archipelago-doctor.timer"]).await
{
warn!("enable archipelago-doctor.timer failed: {:#}", e);
} else if !timer_enabled {
info!("Enabled archipelago-doctor.timer");
}
}
Ok(changed)
@@ -156,22 +476,10 @@ async fn write_root_if_needed(path: &str, content: &str) -> Result<bool> {
Ok(true)
}
async fn is_timer_enabled() -> bool {
tokio::process::Command::new("systemctl")
.args(["is-enabled", "--quiet", "archipelago-doctor.timer"])
.status()
.await
.map(|s| s.success())
.unwrap_or(false)
}
/// Patch the nginx site config to add a `/api/app-catalog` proxy block if
/// it's missing. The original ISO shipped individual per-endpoint `location`
/// blocks and no catch-all `/api/`, so `/api/app-catalog` silently fell
/// through to the SPA `index.html` and the frontend got HTML instead of
/// JSON. We anchor the insert to the DWN comment that already sits right
/// after the `/api/blob` block, so the new block lands in both the HTTP
/// and HTTPS server blocks.
/// 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.
@@ -186,51 +494,90 @@ async fn run_nginx() -> Result<bool> {
return Ok(false);
}
if !Path::new(NGINX_CONF_PATH).exists() {
debug!("{} missing — skipping nginx bootstrap", NGINX_CONF_PATH);
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)
}
let content = fs::read_to_string(NGINX_CONF_PATH)
async fn patch_nginx_conf(path: &str) -> Result<bool> {
let content = fs::read_to_string(path)
.await
.with_context(|| format!("read {}", NGINX_CONF_PATH))?;
if content.contains("location /api/app-catalog") {
.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);
}
// 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),
// we bail rather than guess where to splice.
let anchor = " # DWN endpoints — peer access over Tor (no auth)";
if !content.contains(anchor) {
warn!("nginx conf missing DWN anchor — skipping /api/app-catalog patch");
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);
}
}
let replacement = format!("{}{}", NGINX_APP_CATALOG_BLOCK, anchor);
let patched = content.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 so we can revert if `nginx -t` hates what we produced.
// 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);
if let Err(e) = host_sudo(&["cp", NGINX_CONF_PATH, &backup]).await {
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, NGINX_CONF_PATH]).await;
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 exited with {}", s);
anyhow::bail!("sudo mv nginx conf to {} exited with {}", path, s);
}
Err(e) => {
let _ = fs::remove_file(&tmp).await;
@@ -243,7 +590,7 @@ async fn run_nginx() -> Result<bool> {
let valid = matches!(&test, Ok(s) if s.success());
if !valid {
warn!("nginx -t failed after patch — reverting");
let _ = host_sudo(&["mv", &backup, NGINX_CONF_PATH]).await;
let _ = host_sudo(&["mv", &backup, path]).await;
if let Err(e) = test {
return Err(e.context("nginx -t"));
}
@@ -258,3 +605,14 @@ async fn run_nginx() -> Result<bool> {
let _ = host_sudo(&["rm", "-f", &backup]).await;
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn podman_heal_outcome_no_longer_has_cleanup_variant() {
let outcome = PodmanHealOutcome::Unhealthy;
assert_ne!(outcome, PodmanHealOutcome::Healthy);
}
}
+58 -3
View File
@@ -62,6 +62,14 @@ pub struct Config {
/// Tor SOCKS5 proxy (e.g. 127.0.0.1:9050). When set, ALL Nostr traffic routes through Tor.
#[serde(default)]
pub nostr_tor_proxy: Option<String>,
/// Phase 3.2 of v1.7.52: route orchestrator-managed backend installs
/// through Quadlet (`.container` units in ~/.config/containers/systemd
/// + systemctl --user start) instead of `podman create + start`. Default
/// off so the legacy path stays the production path until the harness
/// at tests/lifecycle/run-20x.sh has gone green against the new path
/// on .228 + .198. See `project_v1_7_52_phase3_quadlet_design`.
#[serde(default)]
pub use_quadlet_backends: bool,
}
impl Config {
@@ -132,9 +140,12 @@ impl Config {
config.log_level = level;
}
// Dev mode configuration
if let Ok(dev_mode) = std::env::var("ARCHIPELAGO_DEV_MODE") {
config.dev_mode = dev_mode.parse().unwrap_or(false);
// Production binaries must not be switched into dev orchestration by
// host environment. Several live nodes carried a stale systemd
// ARCHIPELAGO_DEV_MODE override, which rewrote production volume
// mounts into /tmp and prevented real installs from starting.
if std::env::var("ARCHIPELAGO_DEV_MODE").is_ok() {
tracing::warn!("Ignoring ARCHIPELAGO_DEV_MODE in production config");
}
if let Ok(runtime) = std::env::var("ARCHIPELAGO_CONTAINER_RUNTIME") {
@@ -171,6 +182,17 @@ impl Config {
config.nostr_tor_proxy = if s.is_empty() { None } else { Some(s) };
}
// Phase 3.2 of v1.7.52. Truthy values (1, true, yes, on — case-insensitive)
// route backend installs through the Quadlet path without requiring a
// config.json edit + archipelago.service restart (which would trigger
// FM3 cgroup cascade until 3.5 ships). Anything else (or unset) leaves
// the config.json value untouched.
if let Ok(v) = std::env::var("ARCHIPELAGO_USE_QUADLET_BACKENDS") {
if parse_truthy_env(&v) {
config.use_quadlet_backends = true;
}
}
// Host IP for container env vars (detect if not set)
if let Ok(ip) = std::env::var("ARCHIPELAGO_HOST_IP") {
config.host_ip = ip;
@@ -218,10 +240,23 @@ impl Default for Config {
"wss://relay.nostr.info".into(),
],
nostr_tor_proxy: Some("127.0.0.1:9050".into()),
use_quadlet_backends: false,
}
}
}
/// Recognise the canonical "the user meant true" forms for boolean env
/// vars: 1, true, yes, on (case-insensitive, surrounding whitespace
/// trimmed). Anything else — including the typo'd "ture" or the empty
/// string — counts as false. Centralised so future env flags stay
/// consistent with each other.
fn parse_truthy_env(raw: &str) -> bool {
matches!(
raw.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -425,4 +460,24 @@ mod tests {
assert!(!config.nostr_relays.is_empty());
assert!(config.nostr_relays.iter().all(|r| r.starts_with("wss://")));
}
#[test]
fn parse_truthy_env_recognises_canonical_forms() {
for t in ["1", "true", "TRUE", "yes", "Yes", "on", "ON", " true "] {
assert!(parse_truthy_env(t), "{t:?} should parse truthy");
}
for f in ["", "0", "false", "no", "off", "ture", "anything else", " "] {
assert!(!parse_truthy_env(f), "{f:?} should NOT parse truthy");
}
}
#[test]
fn test_config_use_quadlet_backends_defaults_off() {
// Phase 3.2 of v1.7.52 — the new path stays gated until the 20×
// harness goes green on .228 and .198. Flipping this default
// ahead of that would route every backend install through code
// we haven't fleet-validated yet.
let config = Config::default();
assert!(!config.use_quadlet_backends);
}
}
@@ -15,5 +15,23 @@ server {
add_header Access-Control-Allow-Headers "Content-Type, Authorization";
if ($request_method = OPTIONS) { return 204; }
}
location /bitcoin-status {
proxy_pass http://127.0.0.1:5678/bitcoin-status;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
add_header Cache-Control "no-store";
}
location /rpc/v1 {
proxy_pass http://127.0.0.1:5678/rpc/v1;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Cookie $http_cookie;
proxy_set_header X-CSRF-Token $http_x_csrf_token;
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; }
}
+120 -55
View File
@@ -3,8 +3,8 @@
//!
//! Step 5 of the rust-orchestrator migration. Spawned once from `main.rs`
//! (Step 6) after the initial `adopt_existing()` pass. Every `interval` it
//! calls `ProdContainerOrchestrator::reconcile_all()`, which ensures every
//! loaded manifest has a running container, installing fresh ones as needed.
//! calls `ProdContainerOrchestrator::reconcile_existing()`, which repairs
//! containers that already exist without installing every catalog manifest.
//!
//! Per answered design Q3, `interval` defaults to 30 seconds.
//!
@@ -29,6 +29,12 @@ 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,
wait_for_recovery: bool,
}
impl BootReconciler {
@@ -41,28 +47,63 @@ impl BootReconciler {
orchestrator,
interval,
shutdown,
companion_stage: true,
wait_for_recovery: 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.wait_for_recovery = false;
self
}
/// Run the reconcile loop until `shutdown` is notified.
///
/// Does one reconcile immediately, then sleeps `interval` between
/// subsequent passes. A `shutdown.notify_one()` call unblocks the sleep
/// and the task returns after the *next* pass completes.
///
/// Never panics: per-app failures are absorbed into `ReconcileReport` by
/// the orchestrator, and `reconcile_all` itself returns infallibly.
/// Each pass is two stages:
/// 1. App reconcile: `reconcile_all()` keeps every loaded manifest's
/// container running.
/// 2. Companion reconcile: any expected Quadlet companion unit that
/// is missing or inactive is repaired (writes the unit, daemon-
/// reloads, starts the service). This is the safety net for the
/// "someone deleted my unit file" / "systemd lost the service"
/// failure modes.
///
/// Never panics: per-app failures are absorbed into `ReconcileReport`
/// by the orchestrator, and companion failures are logged but never
/// propagated.
pub async fn run_forever(self) {
let wait_start = Instant::now();
while self.wait_for_recovery && !crate::crash_recovery::is_recovery_complete() {
if wait_start.elapsed() > Duration::from_secs(1800) {
tracing::warn!("boot reconciler: boot recovery did not complete within 30 minutes, starting anyway");
break;
}
tokio::select! {
_ = time::sleep(Duration::from_secs(5)) => {}
_ = self.shutdown.notified() => {
tracing::info!("boot reconciler: shutdown requested before recovery completed");
return;
}
}
}
// Initial pass: no delay.
let report = self.orchestrator.reconcile_all().await;
Self::log_report(&report);
self.tick().await;
loop {
let deadline = Instant::now() + self.interval;
tokio::select! {
_ = time::sleep_until(deadline) => {
let report = self.orchestrator.reconcile_all().await;
Self::log_report(&report);
self.tick().await;
}
_ = self.shutdown.notified() => {
tracing::info!("boot reconciler: shutdown requested, exiting loop");
@@ -72,6 +113,23 @@ impl BootReconciler {
}
}
async fn tick(&self) {
let report = self.orchestrator.reconcile_existing().await;
Self::log_report(&report);
if !self.companion_stage {
return;
}
let installed = self.orchestrator.manifest_ids().await;
for (companion, err) in crate::container::companion::reconcile(&installed).await {
tracing::warn!(
companion = %companion,
error = %err,
"companion reconcile failed"
);
}
}
fn log_report(report: &ReconcileReport) {
for (app_id, action) in &report.actions {
tracing::debug!(app_id = %app_id, action = ?action, "reconcile action");
@@ -200,59 +258,69 @@ mod tests {
async fn orch_with_one_running_manifest(
rt: Arc<CountingRuntime>,
) -> Arc<ProdContainerOrchestrator> {
let orch = Arc::new(ProdContainerOrchestrator::with_runtime(
rt,
PathBuf::from("/nonexistent-for-tests"),
));
let mut orch =
ProdContainerOrchestrator::with_runtime(rt, PathBuf::from("/nonexistent-for-tests"));
let tmp = tempfile::tempdir().unwrap().keep();
orch.set_data_dir(tmp);
orch.set_disk_gb_for_test(2_000);
let orch = Arc::new(orch);
orch.insert_manifest_for_test(
pull_manifest("bitcoin-knots", "docker.io/bitcoin/knots:28"),
PathBuf::from("/tmp/bk"),
pull_manifest("test-app", "docker.io/example/test-app:1"),
PathBuf::from("/tmp/test-app"),
)
.await;
orch
}
#[tokio::test(start_paused = true)]
async fn wait_for_status_calls(rt: &CountingRuntime, expected: u32) -> u32 {
for _ in 0..100 {
let count = rt.status_call_count();
if count >= expected {
return count;
}
tokio::task::yield_now().await;
tokio::time::sleep(Duration::from_millis(1)).await;
}
rt.status_call_count()
}
#[tokio::test]
async fn initial_pass_fires_immediately() {
let rt = Arc::new(CountingRuntime::new_with(&["bitcoin-knots"]));
let rt = Arc::new(CountingRuntime::new_with(&["test-app"]));
let orch = orch_with_one_running_manifest(rt.clone()).await;
let shutdown = Arc::new(Notify::new());
let reconciler =
BootReconciler::new(orch.clone(), Duration::from_secs(30), shutdown.clone());
BootReconciler::new(orch.clone(), Duration::from_millis(50), 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");
assert_eq!(
wait_for_status_calls(&rt, 1).await,
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)]
#[tokio::test]
async fn second_pass_fires_after_interval() {
let rt = Arc::new(CountingRuntime::new_with(&["bitcoin-knots"]));
let rt = Arc::new(CountingRuntime::new_with(&["test-app"]));
let orch = orch_with_one_running_manifest(rt.clone()).await;
let shutdown = Arc::new(Notify::new());
let reconciler =
BootReconciler::new(orch.clone(), Duration::from_secs(30), shutdown.clone());
BootReconciler::new(orch.clone(), Duration::from_millis(10), 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);
assert_eq!(wait_for_status_calls(&rt, 1).await, 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;
tokio::time::sleep(Duration::from_millis(20)).await;
wait_for_status_calls(&rt, 2).await;
assert_eq!(
rt.status_call_count(),
@@ -265,26 +333,23 @@ mod tests {
let _ = tokio::time::timeout(Duration::from_secs(1), handle).await;
}
#[tokio::test(start_paused = true)]
#[tokio::test]
async fn shutdown_terminates_loop() {
let rt = Arc::new(CountingRuntime::new_with(&["bitcoin-knots"]));
let rt = Arc::new(CountingRuntime::new_with(&["test-app"]));
let orch = orch_with_one_running_manifest(rt.clone()).await;
let shutdown = Arc::new(Notify::new());
let reconciler =
BootReconciler::new(orch.clone(), Duration::from_secs(30), shutdown.clone());
BootReconciler::new(orch.clone(), Duration::from_millis(50), shutdown.clone())
.without_companion_stage();
let handle = tokio::spawn(reconciler.run_forever());
tokio::task::yield_now().await;
tokio::task::yield_now().await;
wait_for_status_calls(&rt, 1).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)]
#[tokio::test]
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
@@ -294,28 +359,29 @@ mod tests {
// will run, and the next pass will see a new state. We care about
// "loop keeps ticking even when the report has actions".
let rt = Arc::new(CountingRuntime::default());
let orch = Arc::new(ProdContainerOrchestrator::with_runtime(
let mut orch = ProdContainerOrchestrator::with_runtime(
rt.clone(),
PathBuf::from("/nonexistent-for-tests"),
));
);
let tmp = tempfile::tempdir().unwrap().keep();
orch.set_data_dir(tmp);
orch.set_disk_gb_for_test(2_000);
let orch = Arc::new(orch);
orch.insert_manifest_for_test(
pull_manifest("bitcoin-knots", "docker.io/bitcoin/knots:28"),
PathBuf::from("/tmp/bk"),
pull_manifest("test-app", "docker.io/example/test-app:1"),
PathBuf::from("/tmp/test-app"),
)
.await;
let shutdown = Arc::new(Notify::new());
let reconciler =
BootReconciler::new(orch.clone(), Duration::from_secs(30), shutdown.clone());
BootReconciler::new(orch.clone(), Duration::from_millis(10), 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();
let first = wait_for_status_calls(&rt, 1).await;
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::time::sleep(Duration::from_millis(20)).await;
tokio::task::yield_now().await;
tokio::task::yield_now().await;
let second = rt.status_call_count();
@@ -325,7 +391,6 @@ mod tests {
);
shutdown.notify_one();
tokio::time::advance(Duration::from_millis(10)).await;
let _ = tokio::time::timeout(Duration::from_secs(5), handle).await;
}
}
+458
View File
@@ -0,0 +1,458 @@
//! 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 |
//! | fedimint | archy-fedimint-ui | wait/proxy Guardian 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 std::time::Duration;
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";
const COMPANION_IMAGE_CHECK_TIMEOUT: Duration = Duration::from_secs(15);
const COMPANION_BUILD_TIMEOUT: Duration = Duration::from_secs(900);
const COMPANION_PULL_TIMEOUT: Duration = Duration::from_secs(300);
/// 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,
"fedimint" | "fedimintd" => FEDIMINT_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,
}];
const FEDIMINT_UI: &[CompanionSpec] = &[CompanionSpec {
name: "archy-fedimint-ui",
image_base: "fedimint-ui",
build_dir_candidates: &[
"/opt/archipelago/docker/fedimint-ui",
"/home/archipelago/archy/docker/fedimint-ui",
"/home/archipelago/Projects/archy/docker/fedimint-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_output_with_timeout(
Command::new("podman").args(["build", "-t", &local_image, dir]),
COMPANION_BUILD_TIMEOUT,
"podman build companion image",
)
.await?;
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(&registry_image) {
cmd.arg("--tls-verify=false");
}
cmd.arg(&registry_image);
let out = command_output_with_timeout(
&mut cmd,
COMPANION_PULL_TIMEOUT,
"podman pull companion image",
)
.await?;
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 {
let mut cmd = Command::new("podman");
cmd.args(["image", "inspect", image]);
match tokio::time::timeout(COMPANION_IMAGE_CHECK_TIMEOUT, cmd.status()).await {
Ok(Ok(status)) => status.success(),
Ok(Err(err)) => {
warn!(image = %image, error = %err, "companion image existence check failed");
false
}
Err(_) => {
warn!(image = %image, "companion image existence check timed out");
false
}
}
}
async fn command_output_with_timeout(
cmd: &mut Command,
timeout: Duration,
description: &str,
) -> Result<std::process::Output> {
cmd.kill_on_drop(true);
tokio::time::timeout(timeout, cmd.output())
.await
.with_context(|| format!("{description} timed out after {}s", timeout.as_secs()))?
.with_context(|| format!("spawn {description}"))
}
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("fedimint").len(), 1);
assert_eq!(companions_for("fedimintd").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())]);
}
#[test]
fn fedimint_ui_uses_host_network_for_public_guardian_port() {
let spec = &FEDIMINT_UI[0];
let u = build_unit(spec, "localhost/fedimint-ui:latest");
assert_eq!(u.name, "archy-fedimint-ui");
assert!(matches!(u.network, NetworkMode::Host));
assert!(u.ports.is_empty());
}
}
@@ -252,7 +252,8 @@ impl DevContainerOrchestrator {
match status.state {
archipelago_container::ContainerState::Running => Ok("healthy".to_string()),
archipelago_container::ContainerState::Stopped
| archipelago_container::ContainerState::Exited => Ok("unhealthy".to_string()),
| archipelago_container::ContainerState::Exited
| archipelago_container::ContainerState::Stopping => Ok("unhealthy".to_string()),
archipelago_container::ContainerState::Created => Ok("starting".to_string()),
archipelago_container::ContainerState::Paused => Ok("paused".to_string()),
archipelago_container::ContainerState::Unknown(_) => Ok("unknown".to_string()),
+173 -38
View File
@@ -26,13 +26,7 @@ impl DockerPackageScanner {
/// Scan Docker containers and convert to package data
pub async fn scan_containers(&self) -> Result<HashMap<String, PackageDataEntry>> {
let containers = match self.runtime.list_containers().await {
Ok(c) => c,
Err(e) => {
debug!("Failed to list containers: {}", e);
return Ok(HashMap::new());
}
};
let containers = self.runtime.list_containers().await?;
debug!("Found {} containers", containers.len());
@@ -61,12 +55,19 @@ impl DockerPackageScanner {
"indeedhub-build_minio-init_1",
"indeedhub-build_relay_1",
"indeedhub-build_ffmpeg-worker_1",
"netbird-server",
"netbird-dashboard",
"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
@@ -76,10 +77,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);
}
}
}
@@ -112,6 +113,11 @@ impl DockerPackageScanner {
continue;
}
if is_transient_podman_helper(&app_id, &container.ports) {
debug!("Skipping transient Podman helper container: {}", app_id);
continue;
}
// Skip podman-compose infrastructure containers (e.g. indeedhub-build_api_1)
// These have the project prefix pattern: {project}_{service}_{instance}
if app_id.starts_with("indeedhub-build_") {
@@ -119,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);
@@ -129,20 +140,23 @@ impl DockerPackageScanner {
let metadata = get_app_metadata(&app_id);
// Resolve UI address: separate UI containers > static map > dynamic ports
let lan_address = if let Some(ui_address) = ui_containers.get(&app_id) {
let lan_address = if app_id == "netbird" {
reachable_lan_address(&app_id, netbird_configured_launch_url().await).await
} else 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!(
@@ -158,21 +172,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(),
@@ -283,13 +284,28 @@ 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",
}
}
fn is_transient_podman_helper(app_id: &str, ports: &[String]) -> bool {
if !ports.is_empty() {
return false;
}
let Some((left, right)) = app_id.split_once('_') else {
return false;
};
!left.is_empty()
&& !right.is_empty()
&& left.chars().all(|c| c.is_ascii_lowercase())
&& right.chars().all(|c| c.is_ascii_lowercase())
}
fn get_app_metadata(app_id: &str) -> AppMetadata {
let mut meta = match app_id {
"bitcoin-core" => AppMetadata {
@@ -481,6 +497,20 @@ 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: "",
},
"gitea" => AppMetadata {
title: "Gitea".to_string(),
description: "Self-hosted Git service with repository and package hosting".to_string(),
icon: "/assets/img/app-icons/gitea.svg".to_string(),
repo: "https://gitea.com".to_string(),
tier: "",
},
"indeedhub" | "indeehub" => AppMetadata {
title: "IndeedHub".to_string(),
description: "Decentralized media streaming platform".to_string(),
@@ -559,10 +589,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.).
@@ -633,9 +690,87 @@ fn extract_lan_address(ports: &[String]) -> Option<String> {
None
}
async fn netbird_configured_launch_url() -> Option<String> {
let env = tokio::fs::read_to_string("/var/lib/archipelago/netbird/dashboard.env")
.await
.ok()?;
env.lines()
.find_map(|line| line.strip_prefix("NETBIRD_MGMT_API_ENDPOINT="))
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToOwned::to_owned)
.or_else(|| PodmanClient::lan_address_for("netbird"))
}
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;
};
if launch_port_reachable(port).await {
Some(url)
} else {
debug!(app_id = %app_id, port, "suppressing unreachable launch URL");
None
}
}
async fn launch_port_reachable(port: u16) -> bool {
matches!(
tokio::time::timeout(
std::time::Duration::from_secs(2),
tokio::net::TcpStream::connect(("127.0.0.1", port)),
)
.await,
Ok(Ok(_))
)
}
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),
+61 -25
View File
@@ -8,6 +8,8 @@ use anyhow::{Context, Result};
use std::path::PathBuf;
use tokio::fs;
use crate::update::host_sudo;
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";
@@ -39,17 +41,11 @@ pub enum EnsureOutcome {
}
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()))?;
create_dir_all_or_sudo(&paths.srv_root).await?;
create_dir_all_or_sudo(&paths.data_dir).await?;
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))?;
create_dir_all_or_sudo(&paths.srv_root.join(d)).await?;
}
if paths.config_path.exists() {
@@ -60,27 +56,67 @@ pub async fn ensure_config(paths: &EnsurePaths) -> Result<EnsureOutcome> {
.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()))?;
create_dir_all_or_sudo(parent).await?;
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()
)
})?;
write_config_atomically(paths).await?;
Ok(EnsureOutcome::Written)
}
async fn create_dir_all_or_sudo(path: &std::path::Path) -> Result<()> {
match fs::create_dir_all(path).await {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
let path = path.to_string_lossy();
let status = host_sudo(&["mkdir", "-p", &path])
.await
.with_context(|| format!("creating {path} via sudo"))?;
if !status.success() {
anyhow::bail!("mkdir -p {path} via sudo exited with {status}");
}
Ok(())
}
Err(e) => Err(e).with_context(|| format!("creating {}", path.display())),
}
}
async fn write_config_atomically(paths: &EnsurePaths) -> Result<()> {
let tmp = paths.config_path.with_extension("tmp");
match fs::write(&tmp, DEFAULT_CONFIG_JSON).await {
Ok(()) => {
fs::rename(&tmp, &paths.config_path)
.await
.with_context(|| {
format!(
"renaming {} -> {}",
tmp.display(),
paths.config_path.display()
)
})?;
Ok(())
}
Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
let script = format!(
"set -eu\ncat > '{}' <<'FILEBROWSERCONF'\n{}FILEBROWSERCONF\n",
shell_quote(&paths.config_path.to_string_lossy()),
DEFAULT_CONFIG_JSON
);
let status = host_sudo(&["sh", "-lc", &script])
.await
.context("writing .filebrowser.json via sudo")?;
if !status.success() {
anyhow::bail!("writing .filebrowser.json 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('\'', "'\\''")
}
#[cfg(test)]
mod tests {
use super::*;
@@ -168,6 +168,9 @@ 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-dashboard" => Some("NETBIRD_DASHBOARD_IMAGE"),
"netbird-server" => Some("NETBIRD_SERVER_IMAGE"),
// Fedimint
"fedimint" | "fedimintd" => Some("FEDIMINT_IMAGE"),
@@ -205,6 +208,34 @@ 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)?;
available_update_for_images(&pinned, running_image)
}
fn available_update_for_images(pinned: &str, running_image: &str) -> Option<String> {
let pinned_version = extract_version_from_image(&pinned);
if is_floating_tag(&pinned_version) {
return None;
}
let running_version = extract_version_from_image(running_image);
if pinned_version == running_version {
return None;
}
let pinned_repo = image_without_registry_or_tag(&pinned);
let running_repo = image_without_registry_or_tag(running_image);
if pinned_repo != running_repo {
return None;
}
Some(pinned_version)
}
/// Extract version tag from a full image reference.
/// e.g. "git.tx1138.com/lfg2025/lnd:v0.18.4-beta" → "v0.18.4-beta"
/// Returns "latest" if no tag or tag is empty.
@@ -223,6 +254,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)> {
@@ -249,6 +306,11 @@ 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_PROXY_IMAGE"),
("netbird-dashboard", "NETBIRD_DASHBOARD_IMAGE"),
("netbird-server", "NETBIRD_SERVER_IMAGE"),
],
_ => vec![],
}
}
@@ -286,6 +348,47 @@ 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 available_update_ignores_registry_only_changes() {
assert_eq!(
available_update_for_images(
"146.59.87.168:3000/lfg2025/nextcloud:29",
"git.tx1138.com/lfg2025/nextcloud:29",
),
None
);
}
#[test]
fn available_update_returns_pinned_version_for_same_repo_newer_tag() {
assert_eq!(
available_update_for_images(
"146.59.87.168:3000/lfg2025/nextcloud:29",
"146.59.87.168:3000/lfg2025/nextcloud:28",
),
Some("29".to_string())
);
}
#[test]
fn test_parse_image_versions() {
let content = r#"
+454
View File
@@ -0,0 +1,454 @@
//! 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 {
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}")
}
#[allow(dead_code)]
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);
}
}
+4 -4
View File
@@ -1,19 +1,19 @@
pub mod bitcoin_ui;
pub mod boot_reconciler;
pub mod companion;
pub mod data_manager;
pub mod dev_orchestrator;
pub mod docker_packages;
pub mod filebrowser;
pub mod image_versions;
pub mod lnd;
pub mod prod_orchestrator;
pub mod quadlet;
pub mod registry;
pub mod traits;
pub use boot_reconciler::{BootReconciler, DEFAULT_INTERVAL as RECONCILER_DEFAULT_INTERVAL};
pub use dev_orchestrator::DevContainerOrchestrator;
pub use docker_packages::DockerPackageScanner;
pub use prod_orchestrator::{
compute_container_name, AdoptionReport, ProdContainerOrchestrator, ReconcileAction,
ReconcileReport,
};
pub use prod_orchestrator::ProdContainerOrchestrator;
pub use traits::ContainerOrchestrator;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+48 -3
View File
@@ -8,9 +8,10 @@ use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
use tokio::fs;
use tracing::{debug, info};
const REGISTRY_FILE: &str = "config/registries.json";
const OVH_REGISTRY_URL: &str = "146.59.87.168:3000/lfg2025";
const TX1138_REGISTRY_URL: &str = "git.tx1138.com/lfg2025";
/// A single container registry.
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -44,14 +45,14 @@ impl Default for RegistryConfig {
Self {
registries: vec![
Registry {
url: "146.59.87.168:3000/lfg2025".to_string(),
url: OVH_REGISTRY_URL.to_string(),
name: "Server 1 (OVH)".to_string(),
tls_verify: false,
enabled: true,
priority: 0,
},
Registry {
url: "git.tx1138.com/lfg2025".to_string(),
url: TX1138_REGISTRY_URL.to_string(),
name: "Server 2 (tx1138)".to_string(),
tls_verify: true,
enabled: true,
@@ -139,6 +140,19 @@ pub async fn load_registries(data_dir: &Path) -> Result<RegistryConfig> {
changed = true;
}
}
let before_order: Vec<(String, bool, u32)> = config
.registries
.iter()
.map(|r| (r.url.clone(), r.enabled, r.priority))
.collect();
force_ovh_registry_primary(&mut config);
changed = changed
|| before_order
!= config
.registries
.iter()
.map(|r| (r.url.clone(), r.enabled, r.priority))
.collect::<Vec<_>>();
if changed {
// Persist so the next load doesn't have to re-merge.
let _ = save_registries(data_dir, &config).await;
@@ -146,6 +160,37 @@ pub async fn load_registries(data_dir: &Path) -> Result<RegistryConfig> {
Ok(config)
}
fn force_ovh_registry_primary(config: &mut RegistryConfig) {
let defaults = RegistryConfig::default();
for def in defaults.registries {
if !config.registries.iter().any(|r| r.url == def.url) {
config.registries.push(def);
}
}
for registry in config.registries.iter_mut() {
match registry.url.as_str() {
OVH_REGISTRY_URL => {
registry.name = "Server 1 (OVH)".to_string();
registry.tls_verify = false;
registry.enabled = true;
registry.priority = 0;
}
TX1138_REGISTRY_URL => {
registry.name = "Server 2 (tx1138)".to_string();
registry.tls_verify = true;
registry.enabled = true;
registry.priority = 10;
}
_ => {
if registry.priority <= 10 {
registry.priority = registry.priority.saturating_add(20);
}
}
}
}
}
/// Save registry config to disk.
pub async fn save_registries(data_dir: &Path, config: &RegistryConfig) -> Result<()> {
let dir = data_dir.join("config");
+417 -60
View File
@@ -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,119 @@ 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
start_stopped_containers_for(data_dir, false).await
}
/// Start stopped multi-container stack members after the backend is already
/// ready. These can take minutes after a reboot, so they must not block
/// systemd readiness.
pub async fn start_stopped_stack_containers(data_dir: &Path) -> RecoveryReport {
start_stopped_app_stacks(data_dir).await
}
async fn start_stopped_app_stacks(data_dir: &Path) -> RecoveryReport {
let user_stopped = load_user_stopped(data_dir).await;
let mut report = RecoveryReport {
total: 0,
recovered: 0,
failed: Vec::new(),
};
for stack in stack_recovery_specs() {
if !stack_has_any_container(stack).await {
continue;
}
info!(
"Recovering stopped {} stack containers after boot",
stack.name
);
repair_stack_network_aliases(stack).await;
for container in stack.containers {
if user_stopped.contains(*container) {
info!("Skipping user-stopped container: {}", container);
continue;
}
match container_state(container).await {
Some(state) if state == "running" => continue,
Some(_) => {}
None => continue,
}
repair_stack_network_aliases(stack).await;
wait_before_stack_container_recovery(stack, container).await;
report.total += 1;
if start_existing_container(container).await {
report.recovered += 1;
} else {
report.failed.push((*container).to_string());
}
}
}
report
}
async fn wait_before_stack_container_recovery(stack: &StackRecoverySpec, container: &str) {
if stack.name != "indeedhub" || container != "indeedhub" {
return;
}
for _ in 0..60 {
if indeedhub_recovery_dependencies_running().await {
repair_stack_network_aliases(stack).await;
break;
}
tokio::time::sleep(Duration::from_secs(2)).await;
}
for _ in 0..60 {
let ready = podman_output(
&["exec", "indeedhub-api", "getent", "hosts", "minio"],
Duration::from_secs(5),
)
.await
.map(|output| output.status.success())
.unwrap_or(false);
if ready {
return;
}
tokio::time::sleep(Duration::from_secs(2)).await;
}
}
async fn indeedhub_recovery_dependencies_running() -> bool {
for name in ["indeedhub-redis", "indeedhub-minio", "indeedhub-api"] {
if container_state(name).await.as_deref() != Some("running") {
return false;
}
}
true
}
async fn start_stopped_containers_for(
data_dir: &Path,
include_stack_members: bool,
) -> RecoveryReport {
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 +455,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 +495,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, include_stack_members))
.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 +526,243 @@ pub async fn start_stopped_containers(data_dir: &Path) -> RecoveryReport {
recover_containers(&records).await
}
fn should_auto_start_stopped_container(name: &str, include_stack_members: bool) -> 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.
if matches!(name, "filebrowser" | "nostr-rs-relay") {
return true;
}
include_stack_members
&& matches!(
name,
"immich_postgres"
| "immich_redis"
| "immich_server"
| "indeedhub-postgres"
| "indeedhub-redis"
| "indeedhub-minio"
| "indeedhub-relay"
| "indeedhub-api"
| "indeedhub-ffmpeg"
| "indeedhub"
| "netbird-server"
| "netbird-dashboard"
| "netbird"
)
}
struct StackRecoverySpec {
name: &'static str,
network: &'static str,
aliases: &'static [(&'static str, &'static str)],
containers: &'static [&'static str],
}
fn stack_recovery_specs() -> &'static [StackRecoverySpec] {
&[
StackRecoverySpec {
name: "immich",
network: "immich-net",
aliases: &[
("immich_postgres", "immich_postgres"),
("immich_redis", "immich_redis"),
("immich_server", "immich_server"),
],
containers: &["immich_postgres", "immich_redis", "immich_server"],
},
StackRecoverySpec {
name: "indeedhub",
network: "indeedhub-net",
aliases: &[
("indeedhub-postgres", "postgres"),
("indeedhub-redis", "redis"),
("indeedhub-minio", "minio"),
("indeedhub-relay", "relay"),
("indeedhub-api", "api"),
("indeedhub", "indeedhub"),
],
containers: &[
"indeedhub-postgres",
"indeedhub-redis",
"indeedhub-minio",
"indeedhub-relay",
"indeedhub-api",
"indeedhub-ffmpeg",
"indeedhub",
],
},
StackRecoverySpec {
name: "netbird",
network: "netbird-net",
aliases: &[
("netbird-server", "netbird-server"),
("netbird-dashboard", "netbird-dashboard"),
("netbird", "netbird"),
],
containers: &["netbird-server", "netbird-dashboard", "netbird"],
},
]
}
async fn stack_has_any_container(stack: &StackRecoverySpec) -> bool {
for container in stack.containers {
if container_state(container).await.is_some() {
return true;
}
}
false
}
async fn repair_stack_network_aliases(stack: &StackRecoverySpec) {
let _ = podman_status(
&["network", "create", stack.network],
Duration::from_secs(15),
)
.await;
for (container, alias) in stack.aliases {
if container_state(container).await.is_none() {
continue;
}
if network_alias_present(stack.network, container, alias).await {
continue;
}
let _ = podman_status(
&["network", "disconnect", "-f", stack.network, container],
Duration::from_secs(15),
)
.await;
let _ = podman_status(
&[
"network",
"connect",
"--alias",
alias,
stack.network,
container,
],
Duration::from_secs(15),
)
.await;
}
}
async fn network_alias_present(network_name: &str, container: &str, alias: &str) -> bool {
let output = match podman_output(
&[
"inspect",
container,
"--format",
"{{json .NetworkSettings.Networks}}",
],
Duration::from_secs(10),
)
.await
{
Ok(output) if output.status.success() => output,
_ => return false,
};
let Ok(networks) = serde_json::from_slice::<serde_json::Value>(&output.stdout) else {
return false;
};
networks
.get(network_name)
.and_then(|network| network.get("Aliases"))
.and_then(|aliases| aliases.as_array())
.map(|aliases| aliases.iter().any(|value| value.as_str() == Some(alias)))
.unwrap_or(false)
}
async fn container_state(container: &str) -> Option<String> {
let output = podman_output(
&["inspect", container, "--format", "{{.State.Status}}"],
Duration::from_secs(10),
)
.await
.ok()?;
output
.status
.success()
.then(|| String::from_utf8_lossy(&output.stdout).trim().to_string())
}
async fn start_existing_container(container: &str) -> bool {
info!("Recovering stack container: {}", container);
let timeout = match container {
"immich_server" | "netbird-server" => Duration::from_secs(120),
_ => Duration::from_secs(90),
};
if container_state(container).await.as_deref() == Some("initialized") {
cleanup_container_runtime_state(container).await;
}
match podman_output(&["start", container], timeout).await {
Ok(output) if output.status.success() => {
tokio::time::sleep(Duration::from_secs(3)).await;
if container_state(container).await.as_deref() == Some("exited") {
warn!("Stack container {} exited shortly after start", container);
false
} else {
info!("Successfully recovered stack container: {}", container);
true
}
}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if stderr.contains("exec.fifo") || stderr.contains("failed to start container") {
cleanup_container_runtime_state(container).await;
if let Ok(retry) = podman_output(&["start", container], timeout).await {
if retry.status.success() {
info!(
"Successfully recovered stack container after cleanup: {}",
container
);
return true;
}
warn!(
"Failed to recover stack container {} after cleanup: {}",
container,
String::from_utf8_lossy(&retry.stderr).trim()
);
return false;
}
}
warn!(
"Failed to recover stack container {}: {}",
container, stderr
);
false
}
Err(e) => {
warn!("Failed to recover stack container {}: {}", container, e);
false
}
}
}
async fn cleanup_container_runtime_state(container: &str) {
let _ = podman_output(
&["container", "cleanup", container],
Duration::from_secs(30),
)
.await;
}
async fn podman_status(args: &[&str], timeout: Duration) -> Option<std::process::ExitStatus> {
podman_output(args, timeout)
.await
.ok()
.map(|output| output.status)
}
async fn podman_output(args: &[&str], timeout: Duration) -> Result<Output> {
let mut cmd = tokio::process::Command::new("podman");
cmd.args(args);
command_with_timeout(cmd, timeout, &format!("podman {}", args.join(" "))).await
}
/// 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 +794,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 +944,20 @@ 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", false));
assert!(should_auto_start_stopped_container("nostr-rs-relay", false));
assert!(!should_auto_start_stopped_container("bitcoin-knots", false));
assert!(!should_auto_start_stopped_container("lnd", false));
assert!(!should_auto_start_stopped_container(
"indeedhub-postgres",
false
));
assert!(should_auto_start_stopped_container(
"indeedhub-postgres",
true
));
}
}
+6
View File
@@ -255,6 +255,12 @@ pub struct InstallProgress {
/// a fixed UI percentage and a descriptive label.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub phase: Option<InstallPhase>,
/// Optional explicit message — used to surface install failures so
/// the UI can keep the app card visible with an error description
/// instead of silently removing the entry on fail. UI's PHASE_INFO
/// label takes precedence when phase is set.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
}
/// Phases of the install / update pipeline, surfaced to the UI so users
+190 -43
View File
@@ -17,7 +17,8 @@ const ELECTRUMX_DATA_DIR: &str = "/var/lib/archipelago/electrumx";
const ESTIMATED_FULL_INDEX_BYTES: f64 = 130_000_000_000.0;
/// Refresh interval for status cache
const CACHE_REFRESH_SECS: u64 = 15;
const CACHE_REFRESH_SECS: u64 = 30;
const CACHE_ERROR_BACKOFF_SECS: u64 = 60;
/// Build Bitcoin RPC Basic auth header using shared credentials.
async fn bitcoin_rpc_auth() -> String {
@@ -30,9 +31,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 +47,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 +69,36 @@ 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 sleep_secs = if fresh.status == "waiting" && fresh.bitcoin_height == 0 {
CACHE_ERROR_BACKOFF_SECS
} else {
CACHE_REFRESH_SECS
};
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(sleep_secs)).await;
}
});
}
@@ -126,6 +152,8 @@ fn is_transient_error(err_msg: &str) -> bool {
|| lower.contains("broken pipe")
|| lower.contains("eof")
|| lower.contains("connection")
|| lower.contains("503 service unavailable")
|| lower.contains("work queue depth exceeded")
}
/// Fetch ElectrumX indexed height via Electrum protocol (TCP JSON-RPC).
@@ -187,13 +215,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 +295,18 @@ async fn bitcoin_network_height() -> Result<u64> {
}
let json: serde_json::Value = resp.json().await?;
let height = json
let result = json
.get("result")
.and_then(|r| r.as_u64())
.context("Missing result in Bitcoin RPC")?;
Ok(height)
let blocks = result
.get("blocks")
.and_then(|h| h.as_u64())
.context("Missing blocks in Bitcoin RPC")?;
let headers = result
.get("headers")
.and_then(|h| h.as_u64())
.unwrap_or(blocks);
Ok((blocks, headers.max(blocks)))
}
/// Fetch fresh ElectrumX sync status (called by background cache task).
@@ -260,8 +351,8 @@ async fn fetch_electrs_sync_status() -> ElectrsSyncStatus {
onion
};
let network_height = match bitcoin_network_height().await {
Ok(h) => h,
let (bitcoin_blocks, network_height) = match bitcoin_chain_heights().await {
Ok(heights) => heights,
Err(e) => {
let err_msg = e.to_string();
if is_transient_error(&err_msg) {
@@ -271,9 +362,11 @@ async fn fetch_electrs_sync_status() -> ElectrsSyncStatus {
}
return ElectrsSyncStatus {
indexed_height: 0,
bitcoin_height: 0,
network_height: 0,
progress_pct: 0.0,
status: "waiting".to_string(),
stale: false,
error: Some("Waiting for Bitcoin node...".to_string()),
index_size,
tor_onion,
@@ -283,21 +376,25 @@ async fn fetch_electrs_sync_status() -> ElectrsSyncStatus {
let indexed_height = match electrumx_indexed_height().await {
Ok(h) => h,
Err(e) => {
let err_msg = e.to_string();
if is_transient_error(&err_msg) {
// ElectrumX is starting up or busy — estimate from data size
let progress_pct = if data_bytes > 0 {
((data_bytes as f64 / ESTIMATED_FULL_INDEX_BYTES) * 100.0).min(99.0)
} else {
0.0
};
let size_str = index_size.clone().unwrap_or_else(|| "0 MB".to_string());
return ElectrsSyncStatus {
Err(e) => match electrumx_log_indexed_height().await {
Ok(h) if h > 0 => h,
_ => {
let err_msg = e.to_string();
if is_transient_error(&err_msg) {
// ElectrumX is starting up or busy — estimate from data size
let progress_pct = if data_bytes > 0 {
((data_bytes as f64 / ESTIMATED_FULL_INDEX_BYTES) * 100.0).min(99.0)
} else {
0.0
};
let size_str = index_size.clone().unwrap_or_else(|| "0 MB".to_string());
return ElectrsSyncStatus {
indexed_height: 0,
bitcoin_height: bitcoin_blocks,
network_height,
progress_pct,
status: "indexing".to_string(),
stale: false,
error: Some(format!(
"Building index ({} / ~130 GB estimated). Electrum RPC will be available when complete.",
size_str
@@ -305,40 +402,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));
}
}
+515 -52
View File
@@ -4,11 +4,11 @@
// handles "created" state containers, resets dependent counters when deps recover,
// and sends WebSocket notifications to the UI on failure.
use crate::data_model::{Notification, NotificationLevel};
use crate::data_model::{Notification, NotificationLevel, PackageState};
use crate::state::StateManager;
use crate::webhooks::{self, WebhookEvent};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Instant;
@@ -67,14 +67,14 @@ fn container_dependencies(name: &str) -> &'static [&'static str] {
let id = name.strip_prefix("archy-").unwrap_or(name);
match id {
// Bitcoin-dependent chain
"lnd" => &["bitcoin-knots"],
"electrumx" | "mempool-electrs" | "electrs" => &["bitcoin-knots"],
"nbxplorer" => &["bitcoin-knots"],
"lnd" => &["bitcoin"],
"electrumx" | "mempool-electrs" | "electrs" => &["bitcoin"],
"nbxplorer" => &["bitcoin"],
"btcpay-server" => &["btcpay-db", "nbxplorer"],
"mempool-api" => &["mempool-db", "electrumx"],
"mempool-web" => &["mempool-api"],
"fedimint" => &["bitcoin-knots"],
"fedimint-gateway" => &["bitcoin-knots", "fedimint"],
"fedimint" => &["bitcoin"],
"fedimint-gateway" => &["bitcoin", "fedimint"],
// IndeedHub stack
"indeedhub-api" => &["indeedhub-postgres", "indeedhub-redis"],
@@ -88,7 +88,7 @@ fn container_dependencies(name: &str) -> &'static [&'static str] {
"penpot-frontend" => &["penpot-backend"],
// UI containers
"bitcoin-ui" => &["bitcoin-knots"],
"bitcoin-ui" => &["bitcoin"],
"lnd-ui" => &["lnd"],
"electrs-ui" => &["electrumx"],
@@ -103,6 +103,16 @@ fn deps_are_running(name: &str, containers: &[ContainerHealth]) -> bool {
return true;
}
for dep in deps {
if *dep == "bitcoin" {
let bitcoin_running = containers.iter().any(|c| {
let c_id = c.name.strip_prefix("archy-").unwrap_or(&c.name);
matches!(c_id, "bitcoin" | "bitcoin-knots" | "bitcoin-core") && c.state == "running"
});
if !bitcoin_running {
return false;
}
continue;
}
// Check both plain name and archy- prefixed name
let dep_running = containers.iter().any(|c| {
let c_id = c.name.strip_prefix("archy-").unwrap_or(&c.name);
@@ -115,6 +125,24 @@ fn deps_are_running(name: &str, containers: &[ContainerHealth]) -> bool {
true
}
fn conflicting_bitcoin_variant(name: &str) -> Option<&'static str> {
match name.strip_prefix("archy-").unwrap_or(name) {
"bitcoin-core" => Some("bitcoin-knots"),
"bitcoin-knots" | "bitcoin" => Some("bitcoin-core"),
_ => None,
}
}
fn has_running_bitcoin_conflict(name: &str, containers: &[ContainerHealth]) -> bool {
let Some(conflict) = conflicting_bitcoin_variant(name) else {
return false;
};
containers.iter().any(|c| {
let id = c.name.strip_prefix("archy-").unwrap_or(&c.name);
id == conflict && c.state == "running"
})
}
/// Track restart attempts per container with exponential backoff and stability reset.
struct RestartTracker {
attempts: HashMap<String, u32>,
@@ -188,6 +216,8 @@ struct ContainerHealth {
name: String,
app_id: String,
state: String,
podman_health: Option<String>,
host_port_ready: Option<bool>,
healthy: bool,
}
@@ -391,73 +421,311 @@ async fn check_containers() -> Vec<ContainerHealth> {
let stdout = String::from_utf8_lossy(&output.stdout);
let containers: Vec<serde_json::Value> = serde_json::from_str(&stdout).unwrap_or_default();
let live_container_ids = live_container_ids(&containers);
cleanup_stale_podman_healthcheck_units(&live_container_ids).await;
// Monitor ALL long-running containers for health — backend services (databases,
// nbxplorer, mempool-api) and UI containers need auto-restart too.
// Only skip ephemeral containers (build infrastructure, init one-shots).
let mut out = Vec::new();
for c in &containers {
let name = c.get("Names").and_then(|v| {
if let Some(arr) = v.as_array() {
arr.first().and_then(|n| n.as_str()).map(|s| s.to_string())
} else {
v.as_str().map(|s| s.to_string())
}
});
let Some(name) = name else {
continue;
};
// Skip podman-compose infrastructure and one-shot init containers
if name.starts_with("indeedhub-build_") || name.contains("-init") {
continue;
}
let app_id = name.strip_prefix("archy-").unwrap_or(&name).to_string();
let state = c
.get("State")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_lowercase();
let podman_health = parse_podman_health(c, &state);
let host_ports = host_tcp_ports_from_container(c);
let host_port_ready = if host_ports.is_empty() {
None
} else {
Some(host_ports_ready(&host_ports).await)
};
let healthy = state == "running"
&& podman_health.as_deref() != Some("unhealthy")
&& host_port_ready != Some(false);
out.push(ContainerHealth {
name,
app_id,
state,
podman_health,
host_port_ready,
healthy,
});
}
out
}
fn host_tcp_ports_from_container(c: &serde_json::Value) -> Vec<u16> {
let Some(ports) = c.get("Ports").and_then(|v| v.as_array()) else {
return Vec::new();
};
let mut out: Vec<u16> = ports
.iter()
.filter(|p| {
p.get("protocol")
.and_then(|v| v.as_str())
.unwrap_or("tcp")
.eq_ignore_ascii_case("tcp")
})
.filter_map(|p| {
p.get("host_port")
.and_then(|v| v.as_u64())
.and_then(|port| u16::try_from(port).ok())
})
.collect();
out.sort_unstable();
out.dedup();
out
}
async fn host_ports_ready(ports: &[u16]) -> bool {
for port in ports {
let ready = tokio::time::timeout(
std::time::Duration::from_secs(2),
tokio::net::TcpStream::connect(("127.0.0.1", *port)),
)
.await
.is_ok_and(|r| r.is_ok());
if !ready {
return false;
}
}
true
}
fn live_container_ids(containers: &[serde_json::Value]) -> HashSet<String> {
containers
.iter()
.filter_map(|c| {
let name = c.get("Names").and_then(|v| {
if let Some(arr) = v.as_array() {
arr.first().and_then(|n| n.as_str()).map(|s| s.to_string())
} else {
v.as_str().map(|s| s.to_string())
}
})?;
// Skip podman-compose infrastructure and one-shot init containers
if name.starts_with("indeedhub-build_") || name.contains("-init") {
return None;
}
let app_id = name.strip_prefix("archy-").unwrap_or(&name).to_string();
let state = c
.get("State")
c.get("Id")
.or_else(|| c.get("ID"))
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_lowercase();
let healthy = state == "running";
Some(ContainerHealth {
name,
app_id,
state,
healthy,
})
.map(|s| s.to_string())
})
.collect()
}
/// Try to restart a container.
async fn restart_container(name: &str) -> bool {
info!("Auto-restarting unhealthy container: {}", name);
async fn cleanup_stale_podman_healthcheck_units(live_container_ids: &HashSet<String>) {
if live_container_ids.is_empty() {
return;
}
let mut units = stale_healthcheck_units_from_systemd(live_container_ids).await;
if units.is_empty() {
return;
}
units.sort();
units.dedup();
let mut cleaned = 0;
for unit in units {
let Some(container_id) = parse_podman_healthcheck_unit(&unit) else {
continue;
};
let service = format!("{}.service", unit.trim_end_matches(".timer"));
if stop_user_unit(&unit).await {
cleaned += 1;
}
let _ = stop_user_unit(&service).await;
let _ = reset_failed_user_unit(&service).await;
debug!(
"Stopped stale Podman healthcheck unit {} for removed container {}",
unit, container_id
);
}
if cleaned > 0 {
info!("Cleaned {} stale Podman healthcheck timer(s)", cleaned);
}
}
async fn stale_healthcheck_units_from_systemd(live_container_ids: &HashSet<String>) -> Vec<String> {
let mut units = Vec::new();
for args in [
[
"--user",
"list-timers",
"--all",
"--no-legend",
"--no-pager",
]
.as_slice(),
["--user", "list-units", "--all", "--no-legend", "--no-pager"].as_slice(),
] {
let output = match tokio::time::timeout(
std::time::Duration::from_secs(20),
tokio::process::Command::new("systemctl")
.args(args.iter().copied())
.output(),
)
.await
{
Ok(Ok(output)) if output.status.success() => output,
Ok(Ok(output)) => {
let stderr = String::from_utf8_lossy(&output.stderr);
debug!("systemctl {} failed: {}", args.join(" "), stderr.trim());
continue;
}
Ok(Err(e)) => {
debug!("Failed to run systemctl {}: {}", args.join(" "), e);
continue;
}
Err(_) => {
debug!("systemctl {} timed out", args.join(" "));
continue;
}
};
let stdout = String::from_utf8_lossy(&output.stdout);
units.extend(stale_healthcheck_units(&stdout, live_container_ids));
}
units
}
fn stale_healthcheck_units(output: &str, live_container_ids: &HashSet<String>) -> Vec<String> {
output
.lines()
.flat_map(|line| line.split_whitespace())
.filter_map(|token| {
let unit = token.trim_start_matches('●');
let id = parse_podman_healthcheck_unit(unit)?;
(!live_container_ids.contains(id)).then(|| unit.to_string())
})
.collect()
}
fn parse_podman_healthcheck_unit(unit: &str) -> Option<&str> {
let unit = unit
.strip_suffix(".timer")
.or_else(|| unit.strip_suffix(".service"))?;
let (container_id, _suffix) = unit.split_once('-')?;
if container_id.len() == 64 && container_id.bytes().all(|b| b.is_ascii_hexdigit()) {
Some(container_id)
} else {
None
}
}
async fn stop_user_unit(unit: &str) -> bool {
run_systemctl_user(["stop", unit]).await
}
async fn reset_failed_user_unit(unit: &str) -> bool {
run_systemctl_user(["reset-failed", unit]).await
}
async fn run_systemctl_user<const N: usize>(args: [&str; N]) -> bool {
let output = match tokio::time::timeout(
std::time::Duration::from_secs(10),
tokio::process::Command::new("systemctl")
.arg("--user")
.args(args.iter().copied())
.output(),
)
.await
{
Ok(Ok(output)) => output,
Ok(Err(e)) => {
debug!("Failed to run systemctl --user {}: {}", args.join(" "), e);
return false;
}
Err(_) => {
debug!("systemctl --user {} timed out", args.join(" "));
return false;
}
};
if output.status.success() {
true
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
debug!(
"systemctl --user {} failed: {}",
args.join(" "),
stderr.trim()
);
false
}
}
fn parse_podman_health(c: &serde_json::Value, state: &str) -> Option<String> {
c.get("Status")
.and_then(|v| v.as_str())
.and_then(parse_health_from_status)
.or_else(|| {
c.get("State")
.and_then(|v| v.get("Health"))
.and_then(|v| v.get("Status"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
})
.or_else(|| state.contains("unhealthy").then(|| "unhealthy".to_string()))
}
fn parse_health_from_status(status: &str) -> Option<String> {
let start = status.rfind('(')?;
let end = status.rfind(')')?;
(start < end).then(|| status[start + 1..end].to_string())
}
/// Try to recover a container. Running containers need a real restart so
/// rootless network helpers such as pasta are recreated; `podman start` is a
/// no-op for a running container with a missing host listener.
async fn restart_container(name: &str, state: &str) -> bool {
let action = if state == "running" {
"restart"
} else {
"start"
};
info!("Auto-{}ing unhealthy container: {}", action, name);
let result = tokio::time::timeout(
std::time::Duration::from_secs(120),
tokio::process::Command::new("podman")
.args(["start", name])
tokio::process::Command::new("systemd-run")
.args(["--user", "--scope", "--quiet", "--collect", "podman"])
.args([action, name])
.output(),
)
.await;
match result {
Ok(Ok(output)) if output.status.success() => {
info!("Successfully restarted container: {}", name);
info!("Successfully recovered container: {}", name);
true
}
Ok(Ok(output)) => {
let stderr = String::from_utf8_lossy(&output.stderr);
warn!("Failed to restart container {}: {}", name, stderr.trim());
warn!("Failed to {} container {}: {}", action, name, stderr.trim());
false
}
Ok(Err(e)) => {
warn!("Failed to execute podman start for {}: {}", name, e);
warn!("Failed to execute podman {} for {}: {}", action, name, e);
false
}
Err(_) => {
warn!("Timeout starting container {} (120s)", name);
warn!("Timeout {}ing container {} (120s)", action, name);
false
}
}
@@ -475,9 +743,10 @@ pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
if crate::crash_recovery::is_recovery_complete() {
break;
}
// Safety timeout: start anyway after 5 minutes even if recovery hangs
if wait_start.elapsed().as_secs() > 300 {
warn!("Health monitor: boot recovery did not complete within 5 minutes, starting anyway");
// Safety timeout: start anyway after 30 minutes even if recovery hangs.
// Stack recovery can take many minutes on low-resource nodes after reboot.
if wait_start.elapsed().as_secs() > 1800 {
warn!("Health monitor: boot recovery did not complete within 30 minutes, starting anyway");
break;
}
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
@@ -539,9 +808,49 @@ pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
debug!("Skipping uninstalled container: {}", container.name);
continue;
}
if matches!(
pkg.state,
PackageState::Starting | PackageState::Stopping | PackageState::Restarting
) {
debug!(
"Skipping container during package lifecycle transition: {} ({:?})",
container.name, pkg.state
);
continue;
}
} else {
// Orphan: container exists in podman but archipelago has
// no package_data entry for it. Common after a variant
// switch (bitcoin-core ↔ bitcoin-knots) where the
// uninstall removed the package entry but the prior
// variant's container survived in stopped state. Without
// this guard the health monitor pages every minute with
// "Auto-restart failed (attempt N/10)" for an app the
// user can no longer see in the dashboard.
debug!(
"Skipping orphan container (not in package_data): {}",
container.name
);
let before = data.notifications.len();
data.notifications.retain(|n| {
n.app_id.as_deref() != Some(&container.app_id)
&& !n.title.contains(&container.app_id)
});
if data.notifications.len() != before {
state_changed = true;
}
continue;
}
if container.healthy {
let before = data.notifications.len();
data.notifications.retain(|n| {
n.app_id.as_deref() != Some(&container.app_id)
&& !n.title.contains(&container.app_id)
});
if data.notifications.len() != before {
state_changed = true;
}
if tracker.attempt_count(&container.name) > 0 {
info!(
"Container {} is healthy again after restart",
@@ -576,8 +885,10 @@ pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
}
continue;
}
// Handle exited, stopped, AND created state containers
if container.state == "exited"
// Handle exited, stopped, created, and Podman-unhealthy running containers.
if container.podman_health.as_deref() == Some("unhealthy")
|| container.host_port_ready == Some(false)
|| container.state == "exited"
|| container.state == "stopped"
|| container.state == "created"
{
@@ -590,6 +901,21 @@ pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
}
}
let unhealthy_app_ids: HashSet<&str> = unhealthy
.iter()
.map(|container| container.app_id.as_str())
.collect();
let before = data.notifications.len();
data.notifications.retain(|n| {
!n.id.starts_with("health-")
|| n.app_id
.as_deref()
.is_some_and(|app_id| unhealthy_app_ids.contains(app_id))
});
if data.notifications.len() != before {
state_changed = true;
}
// Sort by startup tier: databases first, then core, then dependent, then apps, then UIs
unhealthy.sort_by_key(|c| container_tier(&c.name));
@@ -636,6 +962,14 @@ pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
continue;
}
if has_running_bitcoin_conflict(&container.name, &containers) {
debug!(
"Skipping auto-restart for {} because the other Bitcoin implementation is running",
container.name
);
continue;
}
// When transitioning to a higher tier, wait briefly for previous tier to stabilize
if let Some(prev) = prev_tier {
if tier > prev {
@@ -659,7 +993,7 @@ pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
.unwrap_or(&90)
);
let restarted = restart_container(&container.name).await;
let restarted = restart_container(&container.name, &container.state).await;
if !restarted || attempt >= MAX_RESTART_ATTEMPTS {
let notification = Notification {
@@ -814,6 +1148,8 @@ mod tests {
name: "archy-bitcoin-knots".to_string(),
app_id: "bitcoin-knots".to_string(),
state: "running".to_string(),
podman_health: Some("healthy".to_string()),
host_port_ready: None,
healthy: true,
};
assert!(health.healthy);
@@ -828,6 +1164,8 @@ mod tests {
name: "archy-mempool-web".to_string(),
app_id: "mempool-web".to_string(),
state: "exited".to_string(),
podman_health: None,
host_port_ready: None,
healthy: false,
};
assert!(!health.healthy);
@@ -902,7 +1240,7 @@ mod tests {
#[test]
fn test_container_dependencies() {
assert!(container_dependencies("lnd").contains(&"bitcoin-knots"));
assert!(container_dependencies("lnd").contains(&"bitcoin"));
assert!(container_dependencies("indeedhub-api").contains(&"indeedhub-postgres"));
assert!(container_dependencies("indeedhub-api").contains(&"indeedhub-redis"));
assert!(container_dependencies("mempool-api").contains(&"mempool-db"));
@@ -917,18 +1255,24 @@ mod tests {
name: "indeedhub-postgres".into(),
app_id: "indeedhub-postgres".into(),
state: "running".into(),
podman_health: None,
host_port_ready: None,
healthy: true,
},
ContainerHealth {
name: "indeedhub-redis".into(),
app_id: "indeedhub-redis".into(),
state: "running".into(),
podman_health: None,
host_port_ready: None,
healthy: true,
},
ContainerHealth {
name: "indeedhub-api".into(),
app_id: "indeedhub-api".into(),
state: "exited".into(),
podman_health: None,
host_port_ready: None,
healthy: false,
},
];
@@ -938,11 +1282,76 @@ mod tests {
name: "indeedhub-redis".into(),
app_id: "indeedhub-redis".into(),
state: "running".into(),
podman_health: None,
host_port_ready: None,
healthy: true,
}];
assert!(!deps_are_running("indeedhub-api", &partial));
}
#[test]
fn test_bitcoin_dependency_accepts_core_or_knots() {
let core = vec![ContainerHealth {
name: "bitcoin-core".into(),
app_id: "bitcoin-core".into(),
state: "running".into(),
podman_health: None,
host_port_ready: None,
healthy: true,
}];
assert!(deps_are_running("lnd", &core));
let knots = vec![ContainerHealth {
name: "bitcoin-knots".into(),
app_id: "bitcoin-knots".into(),
state: "running".into(),
podman_health: None,
host_port_ready: None,
healthy: true,
}];
assert!(deps_are_running("fedimint", &knots));
let stopped = vec![ContainerHealth {
name: "bitcoin-core".into(),
app_id: "bitcoin-core".into(),
state: "stopped".into(),
podman_health: None,
host_port_ready: None,
healthy: false,
}];
assert!(!deps_are_running("electrumx", &stopped));
}
#[test]
fn test_bitcoin_conflict_detection() {
let containers = vec![ContainerHealth {
name: "bitcoin-core".into(),
app_id: "bitcoin-core".into(),
state: "running".into(),
podman_health: None,
host_port_ready: None,
healthy: true,
}];
assert!(has_running_bitcoin_conflict("bitcoin-knots", &containers));
assert!(!has_running_bitcoin_conflict("bitcoin-core", &containers));
assert!(!has_running_bitcoin_conflict("lnd", &containers));
}
#[test]
fn test_bitcoin_conflict_ignores_stopped_sibling() {
let containers = vec![ContainerHealth {
name: "bitcoin-core".into(),
app_id: "bitcoin-core".into(),
state: "stopped".into(),
podman_health: None,
host_port_ready: None,
healthy: false,
}];
assert!(!has_running_bitcoin_conflict("bitcoin-knots", &containers));
}
#[test]
fn test_container_tier_core() {
assert_eq!(container_tier("bitcoin-knots"), StartupTier::CoreInfra);
@@ -1008,4 +1417,58 @@ mod tests {
tracker.record("test", 100_000_000);
assert!(tracker.check_leak("test").is_none());
}
#[test]
fn podman_unhealthy_makes_running_container_unhealthy() {
let c = serde_json::json!({ "Status": "Up 5 minutes (unhealthy)" });
assert_eq!(
parse_podman_health(&c, "running").as_deref(),
Some("unhealthy")
);
}
#[test]
fn parses_podman_healthcheck_systemd_units() {
let id = "c1f44a6369c91d65f9e9f6134a5591aa02792cff2f1a4e0f689b5a6c03b6c77c";
assert_eq!(
parse_podman_healthcheck_unit(&format!("{}-15c66ddfefa8a763.timer", id)),
Some(id)
);
assert_eq!(
parse_podman_healthcheck_unit(&format!("{}-15c66ddfefa8a763.service", id)),
Some(id)
);
assert_eq!(parse_podman_healthcheck_unit("grafana.service"), None);
assert_eq!(
parse_podman_healthcheck_unit(
"nothexzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz-x.timer"
),
None
);
}
#[test]
fn stale_healthcheck_units_filters_only_removed_container_ids() {
let live = "6467e25fd87d791a63fe9dbf6e2fabc7bf26533aa2c402b1089effeacf7ebbba";
let stale = "c1f44a6369c91d65f9e9f6134a5591aa02792cff2f1a4e0f689b5a6c03b6c77c";
let mut live_ids = HashSet::new();
live_ids.insert(live.to_string());
let output = format!(
" {live}-6fdc497fd3ba3b62.timer loaded active waiting\n\
{stale}-15c66ddfefa8a763.service loaded failed failed\n\
grafana.service loaded active running\n\
{stale}-1898d85de0bb707f.timer loaded active waiting\n"
);
let mut units = stale_healthcheck_units(&output, &live_ids);
units.sort();
assert_eq!(
units,
vec![
format!("{stale}-15c66ddfefa8a763.service"),
format!("{stale}-1898d85de0bb707f.timer"),
]
);
}
}
+55 -39
View File
@@ -20,6 +20,7 @@
use anyhow::{Context, Result};
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use tokio::signal;
use tokio::sync::Notify;
use tracing::info;
@@ -29,6 +30,7 @@ mod auth;
mod avatar;
mod backup;
mod bitcoin_rpc;
mod bitcoin_status;
mod blobs;
mod bootstrap;
mod config;
@@ -136,44 +138,37 @@ async fn main() -> Result<()> {
// Write PID marker early so we can detect crashes on next startup
crash_recovery::write_pid_marker(&config.data_dir).await?;
// Crash recovery runs in background so health endpoint is available immediately
{
let data_dir = config.data_dir.clone();
tokio::spawn(async move {
// Check if previous instance shut down cleanly
match crash_recovery::check_for_crash(&data_dir).await {
Ok(Some(containers)) => {
info!(
"🔧 Recovering {} containers from previous crash...",
containers.len()
);
let report = crash_recovery::recover_containers(&containers).await;
info!(
"🔧 Recovery complete: {}/{} containers restarted (failed: {:?})",
report.recovered, report.total, report.failed
);
}
Ok(None) => {}
Err(e) => {
tracing::warn!("Crash recovery check failed: {}", e);
}
}
// Start any stopped containers (handles clean reboot)
// Skips user-stopped containers, uses tier ordering
let boot_report = crash_recovery::start_stopped_containers(&data_dir).await;
if boot_report.total > 0 {
info!(
"🔄 Boot startup: {}/{} containers started (failed: {:?})",
boot_report.recovered, boot_report.total, boot_report.failed
);
}
// Signal to health monitor that boot recovery is done
crash_recovery::mark_recovery_complete();
});
// Run crash recovery before starting the manifest reconciler. Both paths
// mutate Podman; running them concurrently can corrupt transient runtime
// state and leave netavark/conmon unable to start containers.
match crash_recovery::check_for_crash(&config.data_dir).await {
Ok(Some(containers)) => {
info!(
"🔧 Recovering {} containers from previous crash...",
containers.len()
);
let report = crash_recovery::recover_containers(&containers).await;
info!(
"🔧 Recovery complete: {}/{} containers restarted (failed: {:?})",
report.recovered, report.total, report.failed
);
}
Ok(None) => {}
Err(e) => {
tracing::warn!("Crash recovery check failed: {}", e);
}
}
// Start any stopped containers (handles clean reboot). This remains
// synchronous for the same reason: no concurrent reconciler during Podman
// startup/recovery operations.
let boot_report = crash_recovery::start_stopped_containers(&config.data_dir).await;
if boot_report.total > 0 {
info!(
"🔄 Boot startup: {}/{} containers started (failed: {:?})",
boot_report.recovered, boot_report.total, boot_report.failed
);
}
// Construct the container orchestrator once. In prod mode we load the
// on-disk app manifests, do an initial adoption pass, and spawn the
// BootReconciler loop (Step 5/6 of the rust-orchestrator migration).
@@ -199,17 +194,20 @@ async fn main() -> Result<()> {
}
// Adoption pass: link existing podman containers back to their
// manifests so the reconciler doesn't recreate them.
match prod.adopt_existing().await {
Ok(report) => {
match tokio::time::timeout(Duration::from_secs(35), prod.adopt_existing()).await {
Ok(Ok(report)) => {
info!(
"🔗 Adopted {} existing container(s): {:?}",
report.adopted.len(),
report.adopted
);
}
Err(e) => {
Ok(Err(e)) => {
tracing::warn!(error = %e, "prod orchestrator: adopt_existing failed (non-fatal)");
}
Err(_) => {
tracing::warn!("prod orchestrator: adopt_existing timed out after 35s (non-fatal)")
}
}
// Spawn the boot reconciler loop. Runs an initial reconcile
// immediately, then re-checks every RECONCILER_DEFAULT_INTERVAL
@@ -276,6 +274,23 @@ async fn main() -> Result<()> {
// Spawn periodic container snapshot (for crash recovery)
crash_recovery::spawn_snapshot_task(config.data_dir.clone());
// Recover stopped multi-container stack members after the backend is up.
// This can take minutes on busy nodes after a reboot, so keep it out of
// the synchronous systemd startup path.
{
let data_dir = config.data_dir.clone();
tokio::spawn(async move {
let report = crash_recovery::start_stopped_stack_containers(&data_dir).await;
if report.total > 0 {
info!(
"🔄 Stack boot recovery: {}/{} containers started (failed: {:?})",
report.recovered, report.total, report.failed
);
}
crash_recovery::mark_recovery_complete();
});
}
// Spawn disk space monitor (warns at 85%, auto-cleans at 90%)
disk_monitor::spawn_disk_monitor(config.data_dir.clone());
@@ -289,6 +304,7 @@ async fn main() -> Result<()> {
// Spawn ElectrumX status cache (refreshes every 15s, serves cached data to avoid race conditions)
electrs_status::spawn_status_cache();
bitcoin_status::spawn_status_cache();
let startup_ms = startup_start.elapsed().as_millis();
info!(
+113 -24
View File
@@ -1,42 +1,140 @@
//! Mesh session lifecycle: connect, initialize, main loop.
use super::super::meshtastic::MeshtasticDevice;
use super::super::serial::MeshcoreDevice;
use super::super::types::*;
use super::{
frames, MeshCommand, MeshState, ADVERT_INTERVAL, MAX_CONSECUTIVE_WRITE_FAILURES, SYNC_INTERVAL,
};
use anyhow::Result;
use anyhow::{Context, Result};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::mpsc;
use tracing::{debug, error, info, warn};
/// Scan all candidate serial ports and open the first Meshcore device found.
async fn auto_detect_and_open() -> Result<(String, MeshcoreDevice, DeviceInfo)> {
enum MeshRadioDevice {
Meshcore(MeshcoreDevice),
Meshtastic(MeshtasticDevice),
}
impl MeshRadioDevice {
fn device_type(&self) -> DeviceType {
match self {
Self::Meshcore(_) => DeviceType::Meshcore,
Self::Meshtastic(_) => DeviceType::Meshtastic,
}
}
fn advert_name(&self) -> Option<String> {
match self {
Self::Meshcore(device) => device.advert_name.clone(),
Self::Meshtastic(device) => device.advert_name(),
}
}
async fn set_advert_name(&mut self, name: &str) -> Result<()> {
match self {
Self::Meshcore(device) => device.set_advert_name(name).await,
Self::Meshtastic(device) => device.set_advert_name(name).await,
}
}
async fn send_self_advert(&mut self) -> Result<()> {
match self {
Self::Meshcore(device) => device.send_self_advert().await,
Self::Meshtastic(device) => device.send_self_advert().await,
}
}
async fn send_channel_text(&mut self, channel: u8, payload: &[u8]) -> Result<()> {
match self {
Self::Meshcore(device) => device.send_channel_text(channel, payload).await,
Self::Meshtastic(device) => device.send_channel_text(channel, payload).await,
}
}
async fn get_contacts(&mut self) -> Result<Vec<super::super::protocol::ParsedContact>> {
match self {
Self::Meshcore(device) => device.get_contacts().await,
Self::Meshtastic(device) => device.get_contacts().await,
}
}
async fn reset_contact_path(&mut self, pubkey: &[u8; 32]) -> Result<()> {
match self {
Self::Meshcore(device) => device.reset_contact_path(pubkey).await,
Self::Meshtastic(device) => device.reset_contact_path(pubkey).await,
}
}
async fn sync_messages(&mut self) -> Result<Vec<super::super::protocol::InboundFrame>> {
match self {
Self::Meshcore(device) => device.sync_messages().await,
Self::Meshtastic(device) => device.sync_messages().await,
}
}
async fn try_recv_frame(&mut self) -> Result<Option<super::super::protocol::InboundFrame>> {
match self {
Self::Meshcore(device) => device.try_recv_frame().await,
Self::Meshtastic(device) => device.try_recv_frame().await,
}
}
}
/// Scan all candidate serial ports and open the first supported mesh device found.
async fn auto_detect_and_open() -> Result<(String, MeshRadioDevice, DeviceInfo)> {
let paths = super::super::serial::detect_serial_devices().await;
if paths.is_empty() {
anyhow::bail!("No serial devices found in /dev");
}
for path in &paths {
debug!(path = %path, "Probing for Meshcore device");
debug!(path = %path, "Probing for mesh radio device");
match MeshcoreDevice::open(path).await {
Ok(mut dev) => match dev.initialize().await {
Ok(info) => {
info!(path = %path, firmware = %info.firmware_version, "Found Meshcore device via auto-detect");
return Ok((path.clone(), dev, info));
return Ok((path.clone(), MeshRadioDevice::Meshcore(dev), info));
}
Err(e) => debug!(path = %path, error = %e, "Not a Meshcore device"),
},
Err(e) => debug!(path = %path, error = %e, "Could not open serial port"),
}
match MeshtasticDevice::open(path).await {
Ok(mut dev) => match dev.initialize().await {
Ok(info) => {
info!(path = %path, firmware = %info.firmware_version, "Found Meshtastic device via auto-detect");
return Ok((path.clone(), MeshRadioDevice::Meshtastic(dev), info));
}
Err(e) => debug!(path = %path, error = %e, "Not a Meshtastic device"),
},
Err(e) => debug!(path = %path, error = %e, "Could not open serial port for Meshtastic"),
}
}
anyhow::bail!(
"No Meshcore device found on {} candidate ports: {:?}",
"No supported mesh radio found on {} candidate ports: {:?}",
paths.len(),
paths
)
}
async fn open_preferred_path(path: &str) -> Result<(MeshRadioDevice, DeviceInfo)> {
match MeshcoreDevice::open(path).await {
Ok(mut dev) => match dev.initialize().await {
Ok(info) => return Ok((MeshRadioDevice::Meshcore(dev), info)),
Err(e) => debug!(path = %path, error = %e, "Preferred path is not Meshcore"),
},
Err(e) => debug!(path = %path, error = %e, "Could not open preferred path as Meshcore"),
}
match MeshtasticDevice::open(path).await {
Ok(mut dev) => match dev.initialize().await {
Ok(info) => Ok((MeshRadioDevice::Meshtastic(dev), info)),
Err(e) => Err(e).context("Preferred path is not Meshtastic"),
},
Err(e) => Err(e).context("Could not open preferred path as Meshtastic"),
}
}
/// ASCII marker for the original DM-via-channel format:
/// `@DM:` + base64(`[dest_prefix(6)][inner…]`). No sender info on the wire,
/// so the receiver has to guess the sender from its contact table — which
@@ -90,7 +188,7 @@ fn our_sender_prefix(state: &Arc<MeshState>) -> [u8; 6] {
/// with both the recipient and sender prefixes so attribution works on
/// the receiver side.
async fn send_dm_via_channel(
device: &mut MeshcoreDevice,
device: &mut MeshRadioDevice,
state: &Arc<MeshState>,
dest_pubkey_prefix: &[u8; 6],
payload: &[u8],
@@ -166,7 +264,7 @@ async fn send_dm_via_channel(
}
/// Fetch the contacts list from the device and update the peer cache.
async fn refresh_contacts(device: &mut MeshcoreDevice, state: &Arc<MeshState>) {
async fn refresh_contacts(device: &mut MeshRadioDevice, state: &Arc<MeshState>) {
match device.get_contacts().await {
Ok(contacts) => {
// Skip firmware contacts the user has explicitly wiped via
@@ -239,7 +337,7 @@ async fn refresh_contacts(device: &mut MeshcoreDevice, state: &Arc<MeshState>) {
/// Drain any queued messages from the device.
/// Returns `true` if a write/communication error occurred (for failure tracking).
async fn sync_queued_messages(
device: &mut MeshcoreDevice,
device: &mut MeshRadioDevice,
state: &Arc<MeshState>,
our_x25519_secret: &[u8; 32],
) -> bool {
@@ -274,20 +372,11 @@ pub(super) async fn run_mesh_session(
) -> Result<()> {
// Detect device — try preferred path first, fall back to auto-detect
let (device_path, mut device, device_info) = if let Some(path) = preferred_path {
match MeshcoreDevice::open(path).await {
Ok(mut dev) => match dev.initialize().await {
Ok(info) => (path.to_string(), dev, info),
Err(e) => {
warn!(
"Preferred path {} handshake failed: {} — trying auto-detect",
path, e
);
auto_detect_and_open().await?
}
},
match open_preferred_path(path).await {
Ok((dev, info)) => (path.to_string(), dev, info),
Err(e) => {
warn!(
"Preferred path {} open failed: {} — trying auto-detect",
"Preferred path {} probe failed: {} — trying auto-detect",
path, e
);
auto_detect_and_open().await?
@@ -301,11 +390,11 @@ pub(super) async fn run_mesh_session(
{
let mut status = state.status.write().await;
status.device_connected = true;
status.device_type = DeviceType::Meshcore;
status.device_type = device.device_type();
status.device_path = Some(device_path.clone());
status.firmware_version = Some(device_info.firmware_version.clone());
status.self_node_id = Some(device_info.node_id);
status.self_advert_name = device.advert_name.clone();
status.self_advert_name = device.advert_name();
}
let _ = state.event_tx.send(MeshEvent::DeviceConnected(device_info));
@@ -434,7 +523,7 @@ pub(super) async fn run_mesh_session(
/// Process a single outbound command from MeshService.
async fn handle_send_command(
cmd: MeshCommand,
device: &mut MeshcoreDevice,
device: &mut MeshRadioDevice,
state: &Arc<MeshState>,
consecutive_write_failures: &mut u32,
) {
+621
View File
@@ -0,0 +1,621 @@
//! Async serial driver for Meshtastic devices.
//!
//! Meshtastic uses protobuf payloads over a SLIP-like serial stream. This
//! module implements only the small subset Archipelago needs: connect,
//! discover the local node, send/receive text packets, and provide synthetic
//! contacts to the existing mesh listener.
use super::protocol::{InboundFrame, ParsedContact};
use super::types::{DeviceInfo, DeviceType};
use anyhow::{Context, Result};
use std::collections::HashMap;
use std::time::Duration;
use tracing::{debug, info, warn};
const BAUD_RATE: u32 = 115200;
const READ_TIMEOUT: Duration = Duration::from_secs(5);
const WRITE_TIMEOUT: Duration = Duration::from_secs(2);
const READ_BUF_SIZE: usize = 512;
const START1: u8 = 0x94;
const START2: u8 = 0xc3;
const TO_RADIO_MAX: usize = 512;
const BROADCAST_NUM: u32 = 0xffff_ffff;
const TEXT_MESSAGE_APP: u32 = 1;
const TO_RADIO_PACKET: u64 = 1;
const TO_RADIO_WANT_CONFIG_ID: u64 = 3;
const TO_RADIO_HEARTBEAT: u64 = 7;
const FROM_RADIO_PACKET: u64 = 2;
const FROM_RADIO_MY_INFO: u64 = 3;
const FROM_RADIO_NODE_INFO: u64 = 4;
const FROM_RADIO_CONFIG_COMPLETE_ID: u64 = 7;
const FROM_RADIO_REBOOTED: u64 = 8;
/// Async Meshtastic device handle.
pub struct MeshtasticDevice {
port: serial2_tokio::SerialPort,
read_buf: Vec<u8>,
node_num: Option<u32>,
user_id: Option<String>,
long_name: Option<String>,
short_name: Option<String>,
contacts: HashMap<u32, ParsedContact>,
device_path: String,
}
impl MeshtasticDevice {
pub async fn open(path: &str) -> Result<Self> {
match tokio::fs::metadata(path).await {
Ok(meta) => {
debug!(path = %path, permissions = ?meta.permissions(), "Device node exists")
}
Err(e) => anyhow::bail!("Serial device {} not accessible: {}", path, e),
}
let port = serial2_tokio::SerialPort::open(path, BAUD_RATE).context(format!(
"Failed to open serial port {} (permission denied? device busy?)",
path
))?;
info!(path = %path, baud = BAUD_RATE, "Opened Meshtastic serial port");
Ok(Self {
port,
read_buf: Vec::with_capacity(READ_BUF_SIZE),
node_num: None,
user_id: None,
long_name: None,
short_name: None,
contacts: HashMap::new(),
device_path: path.to_string(),
})
}
pub async fn initialize(&mut self) -> Result<DeviceInfo> {
info!(path = %self.device_path, "Starting Meshtastic handshake");
self.send_to_radio(&encode_want_config()).await?;
let deadline = tokio::time::Instant::now() + READ_TIMEOUT;
let mut saw_meshtastic_frame = false;
let mut saw_config_complete = false;
loop {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
break;
}
match tokio::time::timeout(
remaining.min(Duration::from_millis(250)),
self.read_from_radio(),
)
.await
{
Ok(Ok(Some(frame))) => {
saw_meshtastic_frame = true;
if matches!(
decode_top_level_variant(&frame),
Some((FROM_RADIO_CONFIG_COMPLETE_ID, _))
) {
saw_config_complete = true;
}
self.handle_from_radio(&frame);
if saw_config_complete && self.node_num.is_some() {
break;
}
}
Ok(Ok(None)) | Err(_) => {}
Ok(Err(e)) => return Err(e),
}
}
if !saw_meshtastic_frame {
anyhow::bail!("No Meshtastic serial API response");
}
let node_id = self
.node_num
.ok_or_else(|| anyhow::anyhow!("Meshtastic serial API did not provide MyInfo"))?;
if self.user_id.is_none() && self.long_name.is_none() && self.short_name.is_none() {
anyhow::bail!("Meshtastic serial API did not provide node identity");
}
let firmware_version = self
.long_name
.clone()
.or_else(|| self.user_id.clone())
.unwrap_or_else(|| "Meshtastic".to_string());
info!(node_id, name = %firmware_version, "Meshtastic identity");
Ok(DeviceInfo {
firmware_version,
node_id,
max_contacts: 200,
device_type: DeviceType::Meshtastic,
})
}
pub async fn set_advert_name(&mut self, name: &str) -> Result<()> {
self.long_name = Some(name.to_string());
Ok(())
}
pub async fn send_self_advert(&mut self) -> Result<()> {
self.send_to_radio(&encode_heartbeat()).await
}
pub async fn send_channel_text(&mut self, _channel: u8, msg: &[u8]) -> Result<()> {
let text = String::from_utf8_lossy(msg);
let packet = encode_mesh_packet(BROADCAST_NUM, TEXT_MESSAGE_APP, text.as_bytes());
self.send_to_radio(&encode_to_radio_variant(TO_RADIO_PACKET, &packet))
.await
}
pub async fn get_contacts(&mut self) -> Result<Vec<ParsedContact>> {
if self.contacts.is_empty() {
self.send_to_radio(&encode_want_config()).await?;
let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
while tokio::time::Instant::now() < deadline {
match self.read_from_radio().await? {
Some(frame) => {
let config_complete = matches!(
decode_top_level_variant(&frame),
Some((FROM_RADIO_CONFIG_COMPLETE_ID, _))
);
self.handle_from_radio(&frame);
if config_complete || !self.contacts.is_empty() {
break;
}
}
None => tokio::time::sleep(Duration::from_millis(50)).await,
}
}
}
Ok(self.contacts.values().cloned().collect())
}
pub async fn reset_contact_path(&mut self, _pubkey: &[u8; 32]) -> Result<()> {
Ok(())
}
pub async fn sync_messages(&mut self) -> Result<Vec<InboundFrame>> {
Ok(Vec::new())
}
pub async fn try_recv_frame(&mut self) -> Result<Option<InboundFrame>> {
let Some(frame) = self.read_from_radio().await? else {
return Ok(None);
};
Ok(self.handle_from_radio(&frame))
}
pub fn advert_name(&self) -> Option<String> {
self.long_name
.clone()
.or_else(|| self.short_name.clone())
.or_else(|| self.user_id.clone())
}
async fn send_to_radio(&mut self, payload: &[u8]) -> Result<()> {
if payload.len() > TO_RADIO_MAX {
anyhow::bail!("Meshtastic payload too large: {} bytes", payload.len());
}
let mut frame = Vec::with_capacity(4 + payload.len());
frame.push(START1);
frame.push(START2);
frame.extend_from_slice(&(payload.len() as u16).to_be_bytes());
frame.extend_from_slice(payload);
tokio::time::timeout(WRITE_TIMEOUT, self.port.write_all(&frame))
.await
.context("Meshtastic serial write timed out")?
.context("Meshtastic serial write failed")?;
Ok(())
}
async fn read_from_radio(&mut self) -> Result<Option<Vec<u8>>> {
if let Some(frame) = decode_serial_frame(&mut self.read_buf) {
return Ok(Some(frame));
}
let mut tmp = [0u8; READ_BUF_SIZE];
match tokio::time::timeout(Duration::from_millis(50), self.port.read(&mut tmp)).await {
Ok(Ok(0)) => anyhow::bail!("Meshtastic serial port closed"),
Ok(Ok(n)) => self.read_buf.extend_from_slice(&tmp[..n]),
Ok(Err(e)) => return Err(e).context("Meshtastic serial read error"),
Err(_) => return Ok(None),
}
Ok(decode_serial_frame(&mut self.read_buf))
}
fn handle_from_radio(&mut self, frame: &[u8]) -> Option<InboundFrame> {
let Some((field, value)) = decode_top_level_variant(frame) else {
return None;
};
match field {
FROM_RADIO_MY_INFO => {
if let Some((node_num, user_id)) = parse_my_info(value) {
self.node_num = Some(node_num);
if let Some(user_id) = user_id {
self.user_id = Some(user_id);
}
}
None
}
FROM_RADIO_NODE_INFO => {
self.update_node_info(value);
None
}
FROM_RADIO_PACKET => self.packet_to_inbound_frame(value),
FROM_RADIO_CONFIG_COMPLETE_ID | FROM_RADIO_REBOOTED => None,
other => {
debug!(
field = other,
len = value.len(),
"Unhandled Meshtastic FromRadio field"
);
None
}
}
}
fn update_node_info(&mut self, data: &[u8]) {
if let Some(node) = parse_node_info(data) {
let key = synthetic_pubkey(node.num);
let name = node
.long_name
.or(node.short_name)
.or(node.id)
.unwrap_or_else(|| format!("Meshtastic !{:08x}", node.num));
if Some(node.num) == self.node_num {
self.long_name = Some(name.clone());
}
self.contacts.insert(
node.num,
ParsedContact {
public_key_hex: hex::encode(key),
advert_name: name,
last_advert: node.last_heard.unwrap_or_default(),
contact_type: 1,
path_len: 0xff,
flags: 0,
},
);
}
}
fn packet_to_inbound_frame(&mut self, data: &[u8]) -> Option<InboundFrame> {
let packet = parse_mesh_packet(data)?;
if packet.portnum != TEXT_MESSAGE_APP || packet.payload.is_empty() {
return None;
}
let from = packet.from.unwrap_or(0);
if Some(from) == self.node_num {
return None;
}
let from_key = synthetic_pubkey(from);
self.contacts.entry(from).or_insert_with(|| ParsedContact {
public_key_hex: hex::encode(synthetic_pubkey(from)),
advert_name: format!("Meshtastic !{:08x}", from),
last_advert: 0,
contact_type: 1,
path_len: 0xff,
flags: 0,
});
let mut payload = Vec::with_capacity(15 + packet.payload.len());
payload.push(0); // SNR unknown
payload.extend_from_slice(&[0, 0]); // reserved
payload.extend_from_slice(&from_key[..6]);
payload.push(0xff); // unknown/flood path
payload.push(0); // text type
payload.extend_from_slice(&0u32.to_le_bytes());
payload.extend_from_slice(&packet.payload);
Some(InboundFrame {
code: super::protocol::RESP_CONTACT_MSG_V3,
data: payload,
bytes_consumed: 0,
})
}
}
fn decode_serial_frame(buf: &mut Vec<u8>) -> Option<Vec<u8>> {
let start = buf.windows(2).position(|w| w == [START1, START2])?;
if start > 0 {
buf.drain(..start);
}
if buf.len() < 4 {
return None;
}
let len = u16::from_be_bytes([buf[2], buf[3]]) as usize;
if buf.len() < 4 + len {
return None;
}
let payload = buf[4..4 + len].to_vec();
buf.drain(..4 + len);
Some(payload)
}
fn encode_want_config() -> Vec<u8> {
encode_varint_field(TO_RADIO_WANT_CONFIG_ID, 1)
}
fn encode_heartbeat() -> Vec<u8> {
encode_to_radio_variant(TO_RADIO_HEARTBEAT, &[])
}
fn encode_to_radio_variant(field: u64, bytes: &[u8]) -> Vec<u8> {
let mut out = Vec::new();
encode_len_field(field, bytes, &mut out);
out
}
fn encode_mesh_packet(to: u32, portnum: u32, payload: &[u8]) -> Vec<u8> {
let mut decoded = Vec::new();
encode_varint_field_into(1, portnum as u64, &mut decoded);
encode_len_field(2, payload, &mut decoded);
let mut packet = Vec::new();
encode_fixed32_field(2, to, &mut packet);
encode_len_field(4, &decoded, &mut packet);
packet
}
fn decode_top_level_variant(buf: &[u8]) -> Option<(u64, &[u8])> {
let mut idx = 0;
while idx < buf.len() {
let (key, n) = read_varint(&buf[idx..])?;
idx += n;
let field = key >> 3;
match key & 0x07 {
0 => {
let (_, n) = read_varint(&buf[idx..])?;
idx += n;
if matches!(field, FROM_RADIO_CONFIG_COMPLETE_ID | FROM_RADIO_REBOOTED) {
return Some((field, &[]));
}
}
2 => {
let (len, n) = read_varint(&buf[idx..])?;
idx += n;
let end = idx.checked_add(len as usize)?;
if end > buf.len() {
return None;
}
if matches!(
field,
FROM_RADIO_PACKET | FROM_RADIO_MY_INFO | FROM_RADIO_NODE_INFO
) {
return Some((field, &buf[idx..end]));
}
idx = end;
}
_ => return None,
}
}
None
}
fn parse_my_info(data: &[u8]) -> Option<(u32, Option<String>)> {
let mut idx = 0;
let mut node_num = None;
let mut user_id = None;
while idx < data.len() {
let (field, value, next) = next_field(data, idx)?;
idx = next;
match (field, value) {
(1, FieldValue::Varint(v)) => node_num = Some(v as u32),
(1, FieldValue::Fixed32(v)) => node_num = Some(v),
(3, FieldValue::Bytes(b)) => user_id = parse_user(b).and_then(|u| u.id),
_ => {}
}
}
node_num.map(|n| (n, user_id))
}
struct ParsedNode {
num: u32,
id: Option<String>,
long_name: Option<String>,
short_name: Option<String>,
last_heard: Option<u32>,
}
fn parse_node_info(data: &[u8]) -> Option<ParsedNode> {
let mut idx = 0;
let mut node = ParsedNode {
num: 0,
id: None,
long_name: None,
short_name: None,
last_heard: None,
};
while idx < data.len() {
let (field, value, next) = next_field(data, idx)?;
idx = next;
match (field, value) {
(1, FieldValue::Varint(v)) => node.num = v as u32,
(1, FieldValue::Fixed32(v)) => node.num = v,
(2, FieldValue::Bytes(b)) => {
if let Some(user) = parse_user(b) {
node.id = user.id;
node.long_name = user.long_name;
node.short_name = user.short_name;
}
}
(5, FieldValue::Fixed32(v)) => node.last_heard = Some(v),
_ => {}
}
}
if node.num == 0 {
None
} else {
Some(node)
}
}
struct ParsedUser {
id: Option<String>,
long_name: Option<String>,
short_name: Option<String>,
}
fn parse_user(data: &[u8]) -> Option<ParsedUser> {
let mut idx = 0;
let mut user = ParsedUser {
id: None,
long_name: None,
short_name: None,
};
while idx < data.len() {
let (field, value, next) = next_field(data, idx)?;
idx = next;
match (field, value) {
(1, FieldValue::Bytes(b)) => user.id = string_field(b),
(2, FieldValue::Bytes(b)) => user.long_name = string_field(b),
(3, FieldValue::Bytes(b)) => user.short_name = string_field(b),
_ => {}
}
}
Some(user)
}
struct ParsedPacket {
from: Option<u32>,
portnum: u32,
payload: Vec<u8>,
}
fn parse_mesh_packet(data: &[u8]) -> Option<ParsedPacket> {
let mut idx = 0;
let mut from = None;
let mut decoded = None;
while idx < data.len() {
let (field, value, next) = next_field(data, idx)?;
idx = next;
match (field, value) {
(1, FieldValue::Fixed32(v)) => from = Some(v),
(4, FieldValue::Bytes(b)) => decoded = Some(b),
_ => {}
}
}
let decoded = decoded?;
let mut didx = 0;
let mut portnum = 0;
let mut payload = Vec::new();
while didx < decoded.len() {
let (field, value, next) = next_field(decoded, didx)?;
didx = next;
match (field, value) {
(1, FieldValue::Varint(v)) => portnum = v as u32,
(2, FieldValue::Bytes(b)) => payload = b.to_vec(),
_ => {}
}
}
Some(ParsedPacket {
from,
portnum,
payload,
})
}
enum FieldValue<'a> {
Varint(u64),
Fixed32(u32),
Bytes(&'a [u8]),
}
fn next_field(buf: &[u8], idx: usize) -> Option<(u64, FieldValue<'_>, usize)> {
let (key, n) = read_varint(&buf[idx..])?;
let field = key >> 3;
let mut pos = idx + n;
match key & 0x07 {
0 => {
let (v, n) = read_varint(&buf[pos..])?;
pos += n;
Some((field, FieldValue::Varint(v), pos))
}
2 => {
let (len, n) = read_varint(&buf[pos..])?;
pos += n;
let end = pos.checked_add(len as usize)?;
if end > buf.len() {
return None;
}
Some((field, FieldValue::Bytes(&buf[pos..end]), end))
}
5 => {
let end = pos.checked_add(4)?;
if end > buf.len() {
None
} else {
let value =
u32::from_le_bytes([buf[pos], buf[pos + 1], buf[pos + 2], buf[pos + 3]]);
Some((field, FieldValue::Fixed32(value), end))
}
}
1 => {
let end = pos.checked_add(8)?;
if end > buf.len() {
None
} else {
Some((field, FieldValue::Bytes(&buf[pos..end]), end))
}
}
wire => {
warn!(wire, "Unsupported Meshtastic protobuf wire type");
None
}
}
}
fn read_varint(buf: &[u8]) -> Option<(u64, usize)> {
let mut out = 0u64;
for (i, b) in buf.iter().copied().enumerate().take(10) {
out |= ((b & 0x7f) as u64) << (7 * i);
if b & 0x80 == 0 {
return Some((out, i + 1));
}
}
None
}
fn encode_varint_field(field: u64, value: u64) -> Vec<u8> {
let mut out = Vec::new();
encode_varint_field_into(field, value, &mut out);
out
}
fn encode_varint_field_into(field: u64, value: u64, out: &mut Vec<u8>) {
write_varint((field << 3) | 0, out);
write_varint(value, out);
}
fn encode_len_field(field: u64, bytes: &[u8], out: &mut Vec<u8>) {
write_varint((field << 3) | 2, out);
write_varint(bytes.len() as u64, out);
out.extend_from_slice(bytes);
}
fn encode_fixed32_field(field: u64, value: u32, out: &mut Vec<u8>) {
write_varint((field << 3) | 5, out);
out.extend_from_slice(&value.to_le_bytes());
}
fn write_varint(mut value: u64, out: &mut Vec<u8>) {
while value >= 0x80 {
out.push((value as u8 & 0x7f) | 0x80);
value >>= 7;
}
out.push(value as u8);
}
fn string_field(bytes: &[u8]) -> Option<String> {
std::str::from_utf8(bytes).ok().map(|s| s.to_string())
}
fn synthetic_pubkey(node_num: u32) -> [u8; 32] {
let mut out = [0u8; 32];
out[..4].copy_from_slice(&node_num.to_le_bytes());
out[4..15].copy_from_slice(b"meshtastic:");
out
}
+5 -3
View File
@@ -1,13 +1,15 @@
//! Mesh networking: Meshcore LoRa radio integration for offline peer discovery
//! Mesh networking: LoRa radio integration for offline peer discovery
//! and encrypted messaging between Archipelago nodes.
//!
//! Supports Meshcore firmware on Heltec V3, T-Beam, RAK WisBlock, Station G2,
//! and other ESP32/nRF52-based LoRa boards via USB serial (Companion USB mode).
//! Supports Meshcore firmware via Companion USB and Meshtastic firmware via
//! the Meshtastic serial API on Heltec, T-Beam, RAK WisBlock, Station G2,
//! and other ESP32/nRF52-based LoRa boards.
pub mod alerts;
pub mod bitcoin_relay;
pub mod crypto;
pub mod listener;
pub mod meshtastic;
pub mod message_types;
pub mod outbox;
pub mod protocol;
+1
View File
@@ -323,6 +323,7 @@ pub fn parse_self_info(data: &[u8]) -> Result<(u32, String)> {
}
/// Parsed contact from RESP_CONTACT (0x03).
#[derive(Clone)]
pub struct ParsedContact {
pub public_key_hex: String,
pub advert_name: String,
+32
View File
@@ -8,6 +8,7 @@
use super::protocol::{self, InboundFrame};
use super::types::DeviceInfo;
use anyhow::{Context, Result};
use std::path::Path;
use std::time::Duration;
use tracing::{debug, info, warn};
@@ -400,12 +401,43 @@ const SERIAL_CANDIDATES: &[&str] = &[
"/dev/ttyACM2",
];
const SKIP_SERIAL_MODEL_SUBSTRINGS: &[&str] = &["Sierra_Wireless", "Z-Wave", "Zooz"];
fn likely_non_mesh_serial_device(path: &str) -> bool {
let Some(name) = Path::new(path).file_name().and_then(|s| s.to_str()) else {
return false;
};
let by_id = Path::new("/dev/serial/by-id");
let Ok(entries) = std::fs::read_dir(by_id) else {
return false;
};
for entry in entries.flatten() {
let file_name = entry.file_name().to_string_lossy().to_string();
if !SKIP_SERIAL_MODEL_SUBSTRINGS
.iter()
.any(|needle| file_name.contains(needle))
{
continue;
}
if let Ok(target) = std::fs::read_link(entry.path()) {
if target.file_name().and_then(|s| s.to_str()) == Some(name) {
return true;
}
}
}
false
}
/// Scan for serial devices that could be Meshcore radios.
/// Returns paths to existing serial device files.
pub async fn detect_serial_devices() -> Vec<String> {
let mut devices = Vec::new();
for path in SERIAL_CANDIDATES {
if tokio::fs::metadata(path).await.is_ok() {
if likely_non_mesh_serial_device(path) {
debug!(path = %path, "Skipping known non-mesh serial device");
continue;
}
devices.push(path.to_string());
}
}
@@ -1,15 +1,20 @@
use crate::monitoring::types::{AlertRuleKind, FiredAlert};
use crate::webhooks::{self, WebhookEvent, WebhookPayload};
use chrono::Utc;
use std::collections::HashSet;
use std::path::Path;
use std::sync::Arc;
use tracing::info;
const NOTIFICATION_MAX_AGE_SECS: i64 = 30 * 60;
/// Push fired alerts as notifications to the state manager (broadcast via WebSocket).
pub(crate) async fn push_alert_notifications(
state_mgr: &Arc<crate::state::StateManager>,
alerts: &[FiredAlert],
) {
let (mut data, _rev) = state_mgr.get_snapshot().await;
prune_stale_alert_notifications(&mut data.notifications, alerts);
for alert in alerts {
let level = match alert.kind {
AlertRuleKind::DiskUsage | AlertRuleKind::RamUsage => {
@@ -27,7 +32,7 @@ pub(crate) async fn push_alert_notifications(
level,
title: format!("{:?} Alert", alert.kind),
message: alert.message.clone(),
timestamp: chrono::Utc::now().to_rfc3339(),
timestamp: Utc::now().to_rfc3339(),
app_id: None,
};
data.notifications.push(notification);
@@ -40,6 +45,30 @@ pub(crate) async fn push_alert_notifications(
info!("Fired {} alert(s)", alerts.len());
}
fn prune_stale_alert_notifications(
notifications: &mut Vec<crate::data_model::Notification>,
alerts: &[FiredAlert],
) {
let now = Utc::now();
let active_ids: HashSet<&str> = alerts.iter().map(|alert| alert.id.as_str()).collect();
notifications.retain(|notification| {
if active_ids.contains(notification.id.as_str()) {
return false;
}
if notification.app_id.is_some() || notification.id.starts_with("health-") {
return true;
}
match chrono::DateTime::parse_from_rfc3339(&notification.timestamp) {
Ok(ts) => {
now.signed_duration_since(ts.with_timezone(&Utc))
.num_seconds()
<= NOTIFICATION_MAX_AGE_SECS
}
Err(_) => false,
}
});
}
/// Deliver webhook notifications for alerts that map to webhook events.
pub(crate) async fn deliver_alert_webhooks(data_dir: &Path, alerts: &[FiredAlert]) {
for alert in alerts {
@@ -53,7 +82,7 @@ pub(crate) async fn deliver_alert_webhooks(data_dir: &Path, alerts: &[FiredAlert
event,
title: format!("{:?} Alert", alert.kind),
message: alert.message.clone(),
timestamp: chrono::Utc::now().to_rfc3339(),
timestamp: Utc::now().to_rfc3339(),
node_id: String::new(),
details: Some(serde_json::json!({
"value": alert.value,
@@ -64,3 +93,46 @@ pub(crate) async fn deliver_alert_webhooks(data_dir: &Path, alerts: &[FiredAlert
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::data_model::{Notification, NotificationLevel};
fn notification(id: &str, timestamp: String, app_id: Option<&str>) -> Notification {
Notification {
id: id.to_string(),
level: NotificationLevel::Warning,
title: "DiskUsage Alert".to_string(),
message: "Disk warning".to_string(),
timestamp,
app_id: app_id.map(str::to_string),
}
}
#[test]
fn prune_stale_alert_notifications_removes_duplicate_and_old_generic_alerts() {
let active_alert = FiredAlert {
id: "alert-active".to_string(),
kind: AlertRuleKind::DiskUsage,
message: "Disk warning".to_string(),
value: 90.0,
threshold: 85.0,
timestamp: Utc::now().timestamp(),
acknowledged: false,
};
let old_timestamp = (Utc::now() - chrono::Duration::minutes(45)).to_rfc3339();
let fresh_timestamp = (Utc::now() - chrono::Duration::minutes(5)).to_rfc3339();
let mut notifications = vec![
notification("alert-active", fresh_timestamp.clone(), None),
notification("alert-old", old_timestamp, None),
notification("alert-fresh", fresh_timestamp.clone(), None),
notification("health-indeedhub-1", fresh_timestamp, Some("indeedhub")),
];
prune_stale_alert_notifications(&mut notifications, &[active_alert]);
let ids: Vec<&str> = notifications.iter().map(|n| n.id.as_str()).collect();
assert_eq!(ids, vec!["alert-fresh", "health-indeedhub-1"]);
}
}
+56 -24
View File
@@ -71,30 +71,49 @@ async fn build_telemetry_report(
data_dir: &std::path::Path,
) -> anyhow::Result<serde_json::Value> {
// Anonymous node ID — truncated SHA-256 hash of pubkey
let (node_id, version, container_count, running_count, peer_count) = if let Some(ref sm) = state
{
let (data, _) = sm.get_snapshot().await;
let id = {
use sha2::{Digest, Sha256};
let mut h = Sha256::new();
h.update(data.server_info.pubkey.as_bytes());
hex::encode(h.finalize())[..16].to_string()
let (node_id, version, container_count, running_count, peer_count, containers) =
if let Some(ref sm) = state {
let (data, _) = sm.get_snapshot().await;
let id = {
use sha2::{Digest, Sha256};
let mut h = Sha256::new();
h.update(data.server_info.pubkey.as_bytes());
hex::encode(h.finalize())[..16].to_string()
};
let containers: Vec<serde_json::Value> = data
.package_data
.iter()
.map(|(id, pkg)| {
serde_json::json!({
"id": id,
"state": format!("{:?}", pkg.state),
"version": pkg.manifest.version,
})
})
.collect();
let running = data
.package_data
.values()
.filter(|p| matches!(p.state, crate::data_model::PackageState::Running))
.count();
(
id,
data.server_info.version.clone(),
data.package_data.len(),
running,
data.peer_health.len(),
containers,
)
} else {
(
"unknown".to_string(),
"unknown".to_string(),
0,
0,
0,
Vec::new(),
)
};
let running = data
.package_data
.values()
.filter(|p| matches!(p.state, crate::data_model::PackageState::Running))
.count();
(
id,
data.server_info.version.clone(),
data.package_data.len(),
running,
data.peer_health.len(),
)
} else {
("unknown".to_string(), "unknown".to_string(), 0, 0, 0)
};
// System info
let cpu_cores = std::thread::available_parallelism()
@@ -153,6 +172,7 @@ async fn build_telemetry_report(
"cpu_pct": (cpu_pct * 10.0).round() / 10.0,
"mem_pct": (mem_pct * 10.0).round() / 10.0,
"disk_pct": (disk_pct * 10.0).round() / 10.0,
"containers": containers,
"container_count": container_count,
"running_count": running_count,
"federation_peers": peer_count,
@@ -166,16 +186,28 @@ async fn post_telemetry_report(url: &str, report: &serde_json::Value) -> anyhow:
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()?;
let payload = serde_json::json!({
"method": "telemetry.ingest",
"params": report,
});
let response = client
.post(url)
.header("Content-Type", "application/json")
.header("User-Agent", "Archipelago-Telemetry/1.0")
.json(report)
.json(&payload)
.send()
.await?;
if !response.status().is_success() {
anyhow::bail!("Collector returned {}", response.status());
}
let status = response.status();
let body: serde_json::Value = response.json().await.unwrap_or_default();
if let Some(error) = body.get("error") {
anyhow::bail!("Collector RPC error: {}", error);
}
if body.get("result").is_none() {
anyhow::bail!("Collector returned {} without RPC result", status);
}
Ok(())
}
+8 -4
View File
@@ -13,8 +13,8 @@ use std::path::Path;
const RESERVED_PORTS: &[u16] = &[
80, 443, 81, // HTTP/HTTPS
8332, 8333, 8334, // Bitcoin RPC/P2P
9735, 10009, 8080, // LND P2P, gRPC, REST
8081, // LND UI (archy-lnd-ui)
9735, 10009, 8080, // LND P2P, gRPC, REST
18083, // LND UI (archy-lnd-ui)
4080, 8999, 50001, // Mempool stack
23000, // BTCPay
8173, 8174, 8175, // Fedimint
@@ -27,7 +27,7 @@ const RESERVED_PORTS: &[u16] = &[
3001, 3002, // Gitea, Uptime Kuma
8888, // SearXNG
8096, 2342, 2283, // Jellyfin, Photoprism, Immich
8443, 8084, // NPM
8443, // FIPS TCP fallback
];
/// Start of range for allocating web app ports when preferred is taken.
@@ -93,8 +93,12 @@ impl PortAllocator {
.any(|m| m.host_port == port)
}
fn can_bind(port: u16) -> bool {
std::net::TcpListener::bind(("0.0.0.0", port)).is_ok()
}
fn is_available(&self, port: u16) -> bool {
!self.is_reserved(port) && !self.is_allocated(port)
!self.is_reserved(port) && !self.is_allocated(port) && Self::can_bind(port)
}
/// Allocate a host port for an app. Uses preferred_port if available, else finds next free.
+4 -1
View File
@@ -80,7 +80,10 @@ impl EndpointRateLimiter {
limits.insert("backup.upload-s3".to_string(), (3, 600));
limits.insert("backup.download-s3".to_string(), (3, 600));
// System operations
limits.insert("update.apply".to_string(), (2, 600));
// Update apply is an authenticated local admin action. Keep a guard
// against accidental button storms without locking operators out for
// ten minutes during OTA troubleshooting.
limits.insert("update.apply".to_string(), (10, 60));
limits.insert("system.reboot".to_string(), (2, 300));
limits.insert("system.shutdown".to_string(), (2, 300));
// Password and TOTP changes
+295 -17
View File
@@ -15,8 +15,10 @@ use hyper::server::conn::Http;
use hyper::service::service_fn;
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tracing::{debug, error, info, warn};
@@ -27,6 +29,25 @@ pub struct Server {
_state_manager: Arc<StateManager>,
}
struct ContainerScanGuard<'a> {
scanning: &'a AtomicBool,
}
impl<'a> ContainerScanGuard<'a> {
fn try_acquire(scanning: &'a AtomicBool) -> Option<Self> {
scanning
.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
.ok()
.map(|_| Self { scanning })
}
}
impl Drop for ContainerScanGuard<'_> {
fn drop(&mut self) {
self.scanning.store(false, Ordering::Release);
}
}
impl Server {
pub async fn new(
config: Config,
@@ -313,6 +334,7 @@ impl Server {
let scanner = create_docker_scanner(&config).await?;
let state = state_manager.clone();
let identity_clone = identity.clone();
let data_dir = config.data_dir.clone();
let scan_kick = api_handler.rpc_handler().scan_kick();
let scan_tick = api_handler.rpc_handler().scan_tick();
@@ -330,16 +352,22 @@ impl Server {
// lifecycle op, and to break out if the spawned task dies
// without ever writing a final state.
let mut transitional_since: HashMap<String, Instant> = HashMap::new();
let mut scan_backoff_until: Option<Instant> = None;
if let Err(e) = scan_and_update_packages(
&scanner,
&state,
identity_clone.as_ref(),
&data_dir,
&mut absence_tracker,
&mut transitional_since,
)
.await
{
error!("Failed to scan containers: {}", e);
if is_podman_scan_timeout(&e) {
scan_backoff_until = Some(Instant::now() + Duration::from_secs(30));
warn!("Podman container scan timed out; backing off scans for 30s");
}
}
// Bump the scan-completion counter so any caller waiting on a
// kicked scan (install/update success path) can proceed.
@@ -354,7 +382,7 @@ impl Server {
// Skip missed ticks instead of catching up — prevents burst of scans
// after a slow podman response (which causes DB lock storms)
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let scanning = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let scanning = std::sync::Arc::new(AtomicBool::new(false));
loop {
tokio::select! {
_ = interval.tick() => {}
@@ -362,24 +390,37 @@ impl Server {
debug!("Scan kicked by install/update success — running immediately");
}
}
if scanning.load(std::sync::atomic::Ordering::Relaxed) {
debug!("Skipping container scan — previous scan still in progress");
continue;
if let Some(until) = scan_backoff_until {
if Instant::now() < until {
debug!("Skipping container scan — Podman scan backoff active");
scan_tick.send_modify(|n| *n = n.wrapping_add(1));
continue;
}
}
scanning.store(true, std::sync::atomic::Ordering::Relaxed);
if let Err(e) = scan_and_update_packages(
let Some(_scan_guard) = ContainerScanGuard::try_acquire(&scanning) else {
debug!("Skipping container scan — previous scan still in progress");
scan_tick.send_modify(|n| *n = n.wrapping_add(1));
continue;
};
let scan_result = scan_and_update_packages(
&scanner,
&state,
identity_clone.as_ref(),
&data_dir,
&mut absence_tracker,
&mut transitional_since,
)
.await
{
.await;
if let Err(e) = scan_result {
error!("Failed to update containers: {}", e);
if is_podman_scan_timeout(&e) {
scan_backoff_until = Some(Instant::now() + Duration::from_secs(30));
warn!("Podman container scan timed out; backing off scans for 30s");
}
} else {
scan_backoff_until = None;
}
scan_tick.send_modify(|n| *n = n.wrapping_add(1));
scanning.store(false, std::sync::atomic::Ordering::Relaxed);
}
});
}
@@ -835,7 +876,21 @@ const CONTAINER_ABSENCE_THRESHOLD: u32 = 3;
/// 600s. 2× that gives the spawned task ample margin before we assume it
/// died (panic, OOM, process restart mid-stop) and fall back to the
/// scanner's authoritative view. Applies to all transitional variants.
const TRANSITIONAL_STUCK_TIMEOUT: Duration = Duration::from_secs(1200);
const TRANSITIONAL_STUCK_TIMEOUT: Duration = Duration::from_secs(120);
/// Multi-container installs can legitimately spend several minutes before the
/// primary user-facing container exists. BTCPay, for example, pulls/starts
/// Postgres and NBXplorer before `btcpay-server`; do not erase its installing
/// card just because the primary container is absent during that setup window.
const INSTALLING_STUCK_TIMEOUT: Duration = Duration::from_secs(20 * 60);
fn transitional_stuck_timeout(state: &crate::data_model::PackageState) -> Duration {
use crate::data_model::PackageState::*;
match state {
Installing | Starting | Restarting => INSTALLING_STUCK_TIMEOUT,
_ => TRANSITIONAL_STUCK_TIMEOUT,
}
}
/// Returns true if `state` is one of the transitional variants that a
/// `spawn_transitional`-style background task owns. While such a state is
@@ -857,6 +912,18 @@ fn is_transitional(state: &crate::data_model::PackageState) -> bool {
)
}
fn absent_transitional_replacement(
state: &crate::data_model::PackageState,
) -> Option<crate::data_model::PackageState> {
match state {
// A stop operation is complete once the container record disappears.
// Do not leave the app card wedged in "Stopping..." just because the
// background task died or the backend restarted before it wrote back.
crate::data_model::PackageState::Stopping => Some(crate::data_model::PackageState::Stopped),
_ => None,
}
}
/// Merge a fresh scan entry `fresh` into `existing` while preserving
/// `existing.state` (which is transitional — the RPC spawn task owns it).
/// Non-state observability fields are taken from `fresh` so the UI still
@@ -864,9 +931,29 @@ fn is_transitional(state: &crate::data_model::PackageState) -> bool {
fn merge_preserving_transitional(
existing: &crate::data_model::PackageDataEntry,
fresh: &crate::data_model::PackageDataEntry,
user_stop_requested: bool,
) -> crate::data_model::PackageDataEntry {
let state = match (&existing.state, &fresh.state) {
// A user-initiated stop must keep showing Stopping while podman still
// reports Running. Repair/restart transitions do not have a user-stop
// marker, so a fresh Running scan means the app recovered.
(crate::data_model::PackageState::Stopping, crate::data_model::PackageState::Running)
if !user_stop_requested =>
{
fresh.state.clone()
}
// Removing with a live running container is stale: uninstall either
// failed or Archipelago restarted before the spawned task could revert
// state. Let the scanner recover the UI immediately instead of
// keeping the app wedged in Removing for 20 minutes.
(crate::data_model::PackageState::Removing, crate::data_model::PackageState::Running) => {
fresh.state.clone()
}
_ => existing.state.clone(),
};
crate::data_model::PackageDataEntry {
state: existing.state.clone(),
state,
// install_progress and uninstall_stage are also owned by the
// initiating op (same reason as state) — keep them.
install_progress: existing.install_progress.clone(),
@@ -881,14 +968,28 @@ fn merge_preserving_transitional(
}
}
fn is_podman_scan_timeout(error: &anyhow::Error) -> bool {
let msg = format!("{:#}", error);
msg.contains("podman ps") && msg.contains("timed out")
}
async fn scan_and_update_packages(
scanner: &DockerPackageScanner,
state: &StateManager,
identity: &NodeIdentity,
data_dir: &std::path::Path,
absence_tracker: &mut HashMap<String, u32>,
transitional_since: &mut HashMap<String, Instant>,
) -> Result<()> {
let packages = scanner.scan_containers().await?;
let mut packages = scanner.scan_containers().await?;
let user_stopped = crate::crash_recovery::load_user_stopped(data_dir).await;
for (id, pkg) in packages.iter_mut() {
if pkg.state == crate::data_model::PackageState::Exited && user_stopped.contains(id) {
pkg.state = crate::data_model::PackageState::Stopped;
pkg.exit_code = None;
}
}
normalize_reachable_package_health(&mut packages).await;
let (current_data, _) = state.get_snapshot().await;
let tor_addr = docker_packages::read_tor_address("archipelago").await;
@@ -939,13 +1040,14 @@ async fn scan_and_update_packages(
let overwrite = match existing {
Some(existing_entry) if is_transitional(&existing_entry.state) => {
let entered = *transitional_since.entry(id.clone()).or_insert(now);
let stuck = now.duration_since(entered) > TRANSITIONAL_STUCK_TIMEOUT;
let timeout = transitional_stuck_timeout(&existing_entry.state);
let stuck = now.duration_since(entered) > timeout;
if stuck {
warn!(
"Container {} stuck in {:?} for >{}s; overriding with scan state {:?}",
id,
existing_entry.state,
TRANSITIONAL_STUCK_TIMEOUT.as_secs(),
timeout.as_secs(),
pkg.state
);
transitional_since.remove(id);
@@ -955,7 +1057,11 @@ async fn scan_and_update_packages(
// observability fields (health, exit_code, lan_address
// via installed) from the fresh scan so the UI still
// sees live readings.
let merged_entry = merge_preserving_transitional(existing_entry, pkg);
let merged_entry = merge_preserving_transitional(
existing_entry,
pkg,
user_stopped.contains(id),
);
if existing.cloned() != Some(merged_entry.clone()) {
merged.insert(id.clone(), merged_entry);
changed = true;
@@ -992,6 +1098,42 @@ async fn scan_and_update_packages(
// owner (spawn_task) is responsible for clearing state, not us.
if let Some(entry) = merged.get(&id) {
if is_transitional(&entry.state) {
if let Some(replacement) = absent_transitional_replacement(&entry.state) {
let mut updated = entry.clone();
updated.state = replacement;
updated.health = None;
updated.exit_code = None;
updated.install_progress = None;
updated.uninstall_stage = None;
merged.insert(id.clone(), updated);
transitional_since.remove(&id);
absence_tracker.remove(&id);
changed = true;
continue;
}
let entered = *transitional_since.entry(id.clone()).or_insert(now);
let timeout = transitional_stuck_timeout(&entry.state);
if now.duration_since(entered) > timeout {
warn!(
"Container {} stuck in {:?} and absent for >{}s; removing stale transitional state",
id,
entry.state,
timeout.as_secs()
);
merged.remove(&id);
transitional_since.remove(&id);
changed = true;
}
absence_tracker.remove(&id);
continue;
}
// Quadlet-generated units run containers with `--rm`, so a
// clean user stop removes the Podman record. Keep the package
// visible as Stopped while the user-stopped marker exists so
// package.start can recreate it via systemd/Quadlet.
if entry.state == crate::data_model::PackageState::Stopped
&& user_stopped.contains(&id)
{
absence_tracker.remove(&id);
continue;
}
@@ -1028,6 +1170,99 @@ async fn scan_and_update_packages(
Ok(())
}
async fn normalize_reachable_package_health(
packages: &mut HashMap<String, crate::data_model::PackageDataEntry>,
) {
for (id, pkg) in packages.iter_mut() {
if pkg.state != crate::data_model::PackageState::Running {
continue;
}
if !matches!(pkg.health.as_deref(), Some("starting" | "unhealthy" | "1")) {
continue;
}
let Some(port) = pkg
.installed
.as_ref()
.and_then(|i| i.interface_addresses.get("main"))
.and_then(|a| a.lan_address.as_deref())
.and_then(port_from_url)
.or_else(|| fallback_package_port(id))
else {
continue;
};
if frontend_port_http_ready(port).await {
debug!(app_id = %id, port, "normalizing reachable package health to healthy");
pkg.health = Some("healthy".to_string());
ensure_main_lan_address(pkg, port);
}
}
}
async fn frontend_port_http_ready(port: u16) -> bool {
let Ok(Ok(mut stream)) = tokio::time::timeout(
Duration::from_secs(2),
tokio::net::TcpStream::connect(("127.0.0.1", port)),
)
.await
else {
return false;
};
let request = b"GET / HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n";
if stream.write_all(request).await.is_err() {
return false;
}
let mut buf = [0u8; 64];
let Ok(Ok(n)) = tokio::time::timeout(Duration::from_secs(2), stream.read(&mut buf)).await
else {
return false;
};
if n == 0 {
return false;
}
let head = String::from_utf8_lossy(&buf[..n]);
head.starts_with("HTTP/1.1 2")
|| head.starts_with("HTTP/1.1 3")
|| head.starts_with("HTTP/1.0 2")
|| head.starts_with("HTTP/1.0 3")
}
fn ensure_main_lan_address(pkg: &mut crate::data_model::PackageDataEntry, port: u16) {
let Some(installed) = pkg.installed.as_mut() else {
return;
};
let main = installed
.interface_addresses
.entry("main".to_string())
.or_insert_with(|| crate::data_model::InterfaceAddress {
tor_address: String::new(),
lan_address: None,
});
if main.lan_address.is_none() {
main.lan_address = Some(format!("http://localhost:{port}"));
}
}
fn fallback_package_port(app_id: &str) -> Option<u16> {
match app_id {
"fedimint" | "fedimintd" => Some(8175),
"filebrowser" => Some(8083),
"indeedhub" => Some(7778),
"nginx-proxy-manager" => Some(8081),
"nostr-rs-relay" => Some(18081),
_ => None,
}
}
fn port_from_url(url: &str) -> Option<u16> {
let after_scheme = url.split_once("://").map(|(_, rest)| rest).unwrap_or(url);
let host_port = after_scheme.split('/').next().unwrap_or(after_scheme);
let port = host_port.rsplit_once(':')?.1;
port.parse::<u16>().ok()
}
/// Register Archipelago DWN protocols on startup.
async fn register_dwn_protocols(data_dir: &std::path::Path) -> Result<()> {
use crate::network::dwn_store::{DwnStore, ProtocolDefinition};
@@ -1151,10 +1386,19 @@ mod merge_tests {
// not clobber the transitional state owned by the RPC spawn task.
let existing = make_entry(PackageState::Stopping, Some("healthy"));
let fresh = make_entry(PackageState::Running, Some("starting"));
let merged = merge_preserving_transitional(&existing, &fresh);
let merged = merge_preserving_transitional(&existing, &fresh, true);
assert_eq!(merged.state, PackageState::Stopping);
}
#[test]
fn non_user_stopping_recovers_when_container_is_running() {
let existing = make_entry(PackageState::Stopping, Some("unknown"));
let fresh = make_entry(PackageState::Running, Some("healthy"));
let merged = merge_preserving_transitional(&existing, &fresh, false);
assert_eq!(merged.state, PackageState::Running);
assert_eq!(merged.health.as_deref(), Some("healthy"));
}
#[test]
fn merges_fresh_observability_fields() {
// Non-state observability fields (health, exit_code, installed)
@@ -1164,12 +1408,21 @@ mod merge_tests {
existing.exit_code = None;
let mut fresh = make_entry(PackageState::Running, Some("unhealthy"));
fresh.exit_code = Some(0);
let merged = merge_preserving_transitional(&existing, &fresh);
let merged = merge_preserving_transitional(&existing, &fresh, true);
assert_eq!(merged.state, PackageState::Stopping);
assert_eq!(merged.health.as_deref(), Some("unhealthy"));
assert_eq!(merged.exit_code, Some(0));
}
#[test]
fn stale_removing_recovers_when_container_is_running() {
let existing = make_entry(PackageState::Removing, Some("unknown"));
let fresh = make_entry(PackageState::Running, Some("healthy"));
let merged = merge_preserving_transitional(&existing, &fresh, false);
assert_eq!(merged.state, PackageState::Running);
assert_eq!(merged.health.as_deref(), Some("healthy"));
}
#[test]
fn is_transitional_covers_all_variants() {
for s in [
@@ -1194,4 +1447,29 @@ mod merge_tests {
assert!(!is_transitional(&s), "{:?} should NOT be transitional", s);
}
}
#[test]
fn installing_uses_longer_stale_timeout_than_other_transitions() {
assert!(transitional_stuck_timeout(&PackageState::Installing) > TRANSITIONAL_STUCK_TIMEOUT);
assert_eq!(
transitional_stuck_timeout(&PackageState::Stopping),
TRANSITIONAL_STUCK_TIMEOUT
);
}
#[test]
fn absent_stopping_transitions_to_stopped() {
assert_eq!(
absent_transitional_replacement(&PackageState::Stopping),
Some(PackageState::Stopped)
);
}
#[test]
fn absent_installing_still_waits_for_owner() {
assert_eq!(
absent_transitional_replacement(&PackageState::Installing),
None
);
}
}

Some files were not shown because too many files have changed in this diff Show More