Compare commits

..
220 Commits
Author SHA1 Message Date
archipelago e474a2b4c9 chore: sync generated release artifacts 2026-06-12 03:15:24 -04:00
archipelago 00c32688f8 chore: release v1.7.85-alpha 2026-06-12 03:14:59 -04:00
archipelago d6f108d818 chore: snapshot release workspace 2026-06-12 03:00:15 -04:00
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
archipelagoandClaude Opus 4.7 4ec6ca98c1 chore: release v1.7.45-alpha
Resilience-validated release. Three full sweeps of the new resilience
harness against .228 confirm no shipstoppers.

Big user-visible:
- Bitcoin RPC auth durably correct via host-rendered nginx.conf bind-mount,
  replaces fragile post-start exec that failed under restricted-cap rootless
  podman ("crun: write cgroup.procs: Permission denied")
- Multi-container stack installs (indeedhub, immich, btcpay, mempool) now
  emit phase events at every boundary so the progress bar advances
- Apps no longer vanish from the dashboard mid-install (absent-scanner skips
  packages in transitional states)
- Indeedhub fresh installs work end-to-end (was 8500+ restart loop): five
  missing env vars (DATABASE_PORT, QUEUE_HOST, QUEUE_PORT,
  S3_PRIVATE_BUCKET_NAME, AES_MASTER_SECRET) added to install code
- Tailscale install fixed: --entrypoint string was being passed as a single
  shell-line arg; switched to custom_args array
- Catalog cleaned of broken entries (dwn, endurain, ollama removed; nextcloud
  restored on docker.io)
- Bitcoin Core update path uses correct image (was looking for nonexistent
  lfg2025/bitcoin:28.4)
- ISO installs now allocate swap on the encrypted data partition

Infra:
- New resilience harness (scripts/resilience/) — black-box state-machine
  tester, every app × every transition. Run before each release.

Sweep #3 final: PASS 107 / FAIL 12 / SKIP 14. The 12 fails are 1 cosmetic
(homeassistant trusted_hosts), 8 harness/timing false-positives, and 3
non-shipstopper tracked items. Down from 23 in baseline sweep #1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 12:31:45 -04:00
archipelago dffa7e99bb chore: release v1.7.44-alpha 2026-04-28 15:03:04 -04:00
archipelago 8f83b37d51 feat(orchestrator): complete container migration and release hardening 2026-04-28 15:00:58 -04:00
archipelago 4d05705315 feat(self-update): sync and rebuild UI containers on OTA
self-update.sh previously rebuilt only the backend binary and Vue
frontend. The custom UI containers (archy-bitcoin-ui, archy-lnd-ui,
archy-electrs-ui) were left untouched forever. That meant any change to
docker/<ui>/{Dockerfile, nginx.conf, index.html, ...} never reached a
running node through OTA; it required a manual SSH + rebuild. This is
exactly why the lnd-ui port fix didnt reach .228 in v1.7.43-alpha.

Add a sync-and-rebuild stage:

  1. Hash each docker/<ui>/ tree (content-only, path-stable via
     `cd && find` so src and dst compare equal when identical).
  2. rsync changed trees to /opt/archipelago/docker/<ui>/.
  3. For each changed UI: rebuild image as the archipelago user
     (rootless podman), then stop+remove+recreate the container using
     the canonical spec from scripts/container-specs.sh. Port mappings,
     caps, memory, and security opts all come from the spec, so the
     runtime cant drift from the tree.

Also install first-boot-containers.sh into /opt/archipelago/scripts/ so
a later reconciler run or reboot picks up current orchestration logic.

Idempotent: if no UI tree changed since the last update, the whole stage
is a no-op beyond the hash compare. Verified end-to-end on .228 with a
synthetic change to lnd-ui: detection, sync, build, recreate, and HTTP
200 on both the direct container port and the host-nginx /app/lnd/
proxy.
2026-04-23 15:48:53 -04:00
archipelago 05b41f8946 fix(lnd-ui): align container port across all specs
The LND UI container was unreachable on .228 after the v1.7.43-alpha
deploy because three sources of truth disagreed on which port nginx
listens on inside the container:

  - docker/lnd-ui/nginx.conf        listen 8081
  - docker/lnd-ui/Dockerfile        EXPOSE 8080
  - apps/lnd-ui/manifest.yml        host networking, ports: []
  - scripts/first-boot-containers.sh  -p 8081:8080
  - scripts/deploy-to-target.sh        -p 8081:80     (de-facto)
  - scripts/deploy-tailscale.sh        -p 8081:80
  - scripts/container-specs.sh        SPEC_PORTS=8081:80

Result: podman published host 8081 to container port 80, but no one was
listening on 80 inside, so connections were reset. Canonicalize on
container:80 with host:8081 publish, matching the three deploy paths
already in agreement.

Changes:
  - docker/lnd-ui/nginx.conf: listen 8081 -> listen 80
  - docker/lnd-ui/Dockerfile: EXPOSE 8080 -> EXPOSE 80
  - apps/lnd-ui/manifest.yml: replace host-network (never true) with
    bridge networking and explicit 8081:80 port mapping, correcting a
    documentation-vs-reality mismatch
  - scripts/first-boot-containers.sh: -p 8081:8080 -> -p 8081:80, and
    fix the internal-port comment

Verified on .228 after rebuild: curl http://127.0.0.1:8081/ returns HTTP
200 and the /app/lnd/ host-nginx proxy resolves cleanly.
2026-04-23 15:42:49 -04:00
archipelago ed73e4709b chore(release): archive ISO build recipes, tarball-only releases
Releases no longer ship as bootable ISOs. Archipelago updates are
distributed as the backend binary plus a frontend tarball referenced by
releases/manifest.json. Nodes OTA-update via scripts/self-update.sh.

Filebrowser and AIUI remain bundled inside the frontend tarball and
deployed atomically, verified present in v1.7.43-alpha release artifact
(189 AIUI files, filebrowser-client bundle).

Archived under image-recipe/_archived/ (resurrectable if ISO distribution
is reintroduced):
  - build-auto-installer-iso.sh
  - build-unbundled-iso.sh
  - test-iso-qemu.sh
  - scripts/convert-iso-to-disk.sh
  - BUILD-ISO-STATUS.md, ISO-BUILD-CHECKLIST.md
  - branding/isohdpfx.bin
  - .gitea/workflows/build-iso-dev.yml

Updated release process docs to drop ISO references:
  - scripts/create-release.sh (next-steps text)
  - docs/BETA-RELEASE-CHECKLIST.md
  - docs/hotfix-process.md
  - README.md
2026-04-23 15:36:00 -04:00
archipelago 0bd4e49a8c docs(release-notes): v1.7.43-alpha bullet for AIUI preservation fix 2026-04-23 13:22:28 -04:00
archipelago 310c709aba chore(release): bump version to 1.7.43-alpha 2026-04-23 13:21:58 -04:00
archipelago dbf755e908 fix(aiui): bundle demo/aiui in self-update and ISO builds so updates never wipe it
Every OTA self-update and every ISO capture was implicitly relying on
/opt/archipelago/web-ui/aiui/ already being present on disk. Any node that
had its web-ui directory atomically swapped (for example by a manual
deployment shipping only neode-ui dist output) lost aiui entirely and the
AI Assistant tab fell through to the "needs to be enabled" placeholder.

self-update.sh: drop the rsync --exclude aiui preservation trick and
instead stage demo/aiui into the freshly-built dist tree before rsync.
demo/aiui in the repo is now the source of truth; every update overwrites
the on-disk copy with a matching version rather than carrying forward
whatever stale bundle happened to survive.

build-auto-installer-iso.sh: prepend demo/aiui to the AIUI search list so
ISO builds from a fresh repo clone pick it up automatically, without
requiring a side-checkout of the AIUI project or a live dev server.

This matches create-release-manifest.sh which already bakes demo/aiui
into the release tarball (lines 86-89).
2026-04-23 13:21:49 -04:00
archipelago 2572688468 docs(release-notes): v1.7.43-alpha bullets for chunking, avatar, outbox, parser
Four production-code fixes merit user-visible mention: the transport
chunking data-corruption fix (real user-affecting bug for multi-chunk
mesh payloads), the avatar u16 overflow panic (backend crash on certain
seeds), the outbox TTL boundary, and the image-versions parser hardening.
2026-04-23 13:03:49 -04:00
archipelago 4bf35f95e6 test: repair stale test fixtures across identity, mesh, update, wallet, fips
Several tests had drifted from the current production behavior:

- identity_manager: create() already auto-provisions a Nostr key, so the
  explicit create_nostr_key() call failed with "already exists". Rewrite
  the test to assert on record.nostr_npub from create() directly.
- mesh/protocol: test_build_app_start read the app name from frame[4..]
  but the v2 layout is [0:marker][1-2:len][3:cmd][4:version][5..:name].
  test_identity_broadcast_roundtrip expected input DID = output DID but
  the v2 decoder derives DID from the ed25519 pubkey, so the roundtrip
  compares against did_key_from_pubkey_hex(&pub) now.
- mesh/bitcoin_relay: test_build_block_header_announcement asserted
  sig.is_some(), but the builder intentionally emits an unsigned envelope
  to fit the 160-byte LoRa limit; assert sig.is_none(). Also widen
  placeholder hashes to the required 64 hex chars (32 bytes).
- update: load_mirrors() now merges default mirrors post-migration, so
  the roundtrip test must assert the custom mirror survives alongside
  the defaults rather than strict equality.
- wallet/cashu: test_proof_c_as_pubkey used hex that is not on the curve;
  replace with the secp256k1 generator point G so parsing succeeds.
- fips: test_status_reports_no_key_pre_onboarding asserted npub.is_none(),
  which fails on dev boxes where the fips daemon is already running. Keep
  the !key_present assertion and drop the npub one.
2026-04-23 13:02:45 -04:00
archipelago 4edc420459 test(credentials): seed identity/node_key in test helper so encrypt/decrypt works
Credentials tests created a fresh tempdir and immediately invoked
encrypt/decrypt, but load_encryption_key reads <dir>/identity/node_key
which did not exist, so every test failed with "node key not found".
Add a test_dir_with_node_key() helper that writes a deterministic 32-byte
key and switch all 8 call sites to it.
2026-04-23 13:02:28 -04:00
archipelago 7af048cc1a fix(session): add test-only constructor so tests do not read real sessions
SessionStore::new() reads /var/lib/archipelago/sessions.json, which on
any node with an active dashboard contains live sessions that pollute
test state and cause intermittent failures. Introduce a cfg(test) only
new_for_tests(PathBuf) constructor and switch the test suite to it so
tests always start from a clean tempdir.
2026-04-23 13:02:22 -04:00
archipelago 2843cc1e84 fix(container/image_versions): reject entries that are not image references
The parser retained any key ending in _IMAGE, so a harmless-looking
variable like NOT_AN_IMAGE="something" would be treated as a pinned
container image. Add a value-shape check: the value must contain both
a registry separator (/) and a tag separator (:) to qualify.
2026-04-23 13:02:15 -04:00
archipelago c5ea41d0cb fix(mesh/outbox): expire messages with zero TTL immediately
is_expired used age > ttl_secs, so a message with ttl_secs=0 whose age
rounded to 0 seconds was considered live forever. Switch to >= so the
zero-TTL boundary expires on the first check, matching the intuitive
meaning of TTL and the behavior the tests assert.
2026-04-23 13:02:07 -04:00
archipelago 9d42645aa3 fix(avatar): prevent u16 overflow panic when seed byte is large
hue_color and accent_color computed (seed as u16) * 360, which overflows
u16 when seed >= 182 — debug builds panicked, release wrapped silently.
Widen to u32 before the multiplication.

This also unblocks several identity_manager tests that constructed avatars
through master_node_svg and were aborting on the panic.
2026-04-23 13:02:01 -04:00
archipelago f6efe2f356 fix(transport/chunking): stop overwriting first 4 bytes of user data
encode_chunked() split the payload into shards first, then overwrote
the first 4 bytes of shard 0 with a u32 length header, then re-ran
Reed-Solomon to regenerate parity over the now-corrupted shards. The
decoder correctly read the length header and trimmed `[4..4+len]`
from the reconstructed buffer, but those first 4 bytes had already
been destroyed on the encode side, so every chunked mesh payload
lost its first 4 bytes.

Restructure: reserve 4 bytes for the length header up front, build
a single contiguous [len][data][pad] buffer, then split into shards.
Parity is computed over the correct shards on the first pass, no
double-encode needed.

Update test_chunk_roundtrip_medium: 500 bytes + 4-byte header = 504
bytes, which is 5 data shards (ceil(504/124)), not 4. The old test
assertion was wrong all along and masked the corruption bug because
it only checked the roundtripped bytes, which is exactly what we
need to verify. New assertion is correct.

Verified: all 7 transport::chunking tests pass.
2026-04-23 12:29:10 -04:00
archipelago c4efb30382 docs(release-notes): v1.7.43-alpha bullet for install-log fix; prune stale RESUME note 2026-04-23 12:04:20 -04:00
archipelago cd6f8bad70 fix(install-log): pre-create /var/log/archipelago/ so non-root backend can write
The backend runs as `archipelago` and calls `install_log()` to append
audit lines to the install log on every install / update / remove /
start / stop / restart. Target path was /var/log/archipelago-container-installs.log,
which does not exist and cannot be created by the service because
/var/log/ is root-owned. OpenOptions errors were silently swallowed,
so the log was never written on any node.

Ship a tmpfiles.d rule that pre-creates /var/log/archipelago/ and
container-installs.log with archipelago:archipelago ownership. Move
the const path to match, keeping logs inside the directory logrotate
already rotates (image-recipe/configs/logrotate.conf). Install the
rule from both the ISO build and self-update, and apply it
immediately on self-update so existing nodes get a working log
without needing a reboot.

Verified on .228: file created, backend user can write, backend
binary rebuilt with new const.
2026-04-23 12:02:46 -04:00
archipelago 9f3d66e24e docs(release-notes): v1.7.43-alpha bullet for self-update script refresh
Document that OTA updates now refresh the reconcile helper scripts,
closing the deploy gap that kept fixes to those scripts from
reaching existing nodes.
2026-04-23 11:51:04 -04:00
archipelago a272a79706 fix(self-update): install reconcile scripts on OTA updates
The OTA self-update path only refreshed image-versions.sh, leaving
reconcile-containers.sh and container-specs.sh frozen at whatever
version was baked into the ISO that originally provisioned the
node. Any fix to those scripts (notably the --create-missing flag
and the DISK_GB detection fix shipped this round) never reached
existing nodes, and on .228 both scripts were outright missing
because the node predated their inclusion in the ISO recipe.

Install all three helper scripts to /opt/archipelago/scripts/ on
every self-update run. Also preserve the legacy copy of
image-versions.sh at /opt/archipelago/image-versions.sh for any
older backend binaries still looking there first.
2026-04-23 10:07:53 -04:00
archipelago 694e5b0a9d fix(update): pass --create-missing when rollback recreates a destroyed container
The update flow removes the old container before starting the new
one. If the update fails after removal, the rollback path tries
`podman start <name>` first, then falls back to reconcile. But
reconcile without --create-missing treats the now-absent container
as an optional one that the install flow will (re)create later,
and skips it. Result: container stays destroyed until someone
notices and runs reconcile manually.

Add --create-missing to the rollback reconcile invocation so the
fallback actually rebuilds the container from its canonical spec.

Fixes the failure mode observed on .228 where a bitcoin-knots
update left the node with no bitcoin-knots container at all.
2026-04-23 10:06:55 -04:00
archipelago 0f1ad47aec docs(release-notes): v1.7.43-alpha bullets for disk-detection and rollback recovery
Add two user-facing release notes for fixes shipped this round:
- Full-archive Bitcoin nodes no longer silently get pruned on reconcile
  because the disk-size check was reading the OS partition.
- Failed updates can now recover via reconcile --create-missing instead
  of leaving a destroyed container behind.
2026-04-23 10:02:32 -04:00
archipelago 06dcdafda4 fix(specs): measure DISK_GB at /var/lib/archipelago, not /
The reconcile spec for bitcoin-knots auto-enables prune=550 when
DISK_GB < 1000. DISK_GB was measured via `df /`, which on every
archy install reports the ~30 GB OS partition because user data
lives on a separate encrypted /var/lib/archipelago volume.

Result: every archy node with a 2 TB data drive was silently being
configured as a pruned node, and any bitcoin-knots container
recreated by reconcile would delete its historical blocks down to
the 550 MB prune window on next start.

Observed on .228 (2 TB box): blocks dir went from 384 GB to 926 MB
after a reconcile-triggered restart. Historical archive unrecoverable
without full re-IBD from genesis.

Fix: check /var/lib/archipelago first (where bitcoin data actually
lives). Fall back to / only on first-boot before the data partition
is mounted.
2026-04-23 09:54:16 -04:00
archipelago 92612ddc70 feat(reconcile): add --create-missing flag for recovering from failed-update rollbacks
Context: when package update fails after remove-old-container but
before reconcile-recreate, the rollback path in update.rs tries to
restart the old container by name. If the container is already gone
(removed in step 3 of the update), rollback fails silently and the
node is left with no live container for that app but on-disk data
still intact. This is exactly the state .228 ended up in after the
reconcile-script-missing bug killed bitcoin-knots and lnd.

Reconcile was designed to only repair existing containers for
optional apps (SPEC_OPTIONAL=true): it skips "not installed" entries
on the assumption that the install RPC creates them. That safety
check is correct for normal operation but blocks recovery when an
optional-marked container has been destroyed by a failed update.

Fix: add --create-missing flag that overrides the SPEC_OPTIONAL skip.
When set, reconcile treats absent containers exactly the same as
broken containers — it creates them from the canonical spec using
the existing on-disk data directory. Narrow-scope override; the
default behaviour is unchanged.

Updated --help to document all four flags.

Verified on .228: after the failed bitcoin-core update took out both
bitcoin-knots and lnd, running reconcile --container=bitcoin-knots
--create-missing --force (as the archipelago user, not root —
podman is rootless) brought bitcoin-knots back using the pruned
chainstate at /var/lib/archipelago/bitcoin. Repeated for lnd. All
containers now running; electrumx reconnecting; UIs recovering.

Does NOT fix the underlying update-flow rollback hole (rollback
should be able to re-create a container from spec, not just restart
by name). That is a separate commit — this flag is the manual
recovery tool plus the primitive the improved rollback will call.
2026-04-23 09:42:19 -04:00
archipelago 353825b66c docs: release-note image-versions fix, add marketplace QA tracker, update RESUME
- AccountInfoSection.vue: append 5th bullet to v1.7.43-alpha entry
  explaining that update-available badges and version comparisons
  work again now that the pinned-image catalog is found at the
  correct deployed path.

- docs/MARKETPLACE-QA.md: new tracker for the upcoming app-by-app
  install walk on .228. Documents the per-app fix workflow, the
  four layers we might need to fix at (app recipe, registry image,
  backend orchestrator, frontend), status-key table for tracking
  each catalog entry, and the release-notes policy for the walk.

- docs/RESUME.md: refresh with a9908597 commit, updated binary md5
  on .228, and split Immediate Next Step into Phase 1 (browser
  verification) and Phase 2 (marketplace walk) with a pointer to
  the new tracker.
2026-04-23 09:32:41 -04:00
archipelago 12f93cc15e fix(image-versions): locate image-versions.sh at its actual deployed path
The Rust search path listed /opt/archipelago/image-versions.sh and
scripts/image-versions.sh (repo-relative for dev), but the image
recipe deploys the file to /opt/archipelago/scripts/image-versions.sh.
Production nodes therefore silently failed every lookup: find_file
returned None, load_image_versions returned an empty HashMap, and
both pinned_image_for_app and pinned_images_for_stack returned no
matches.

Symptom on deployed nodes: every container scan emitted
"image-versions.sh not found in any search path" at DEBUG level, and
the version-comparison logic in docker_packages.rs plus the
update-check logic in api/rpc/package/update.rs silently degraded to
no-op — users would not see update-available badges and upgrade RPCs
could not resolve pinned targets.

Fix: put the canonical deployed path first in PATHS, keep the older
/opt/archipelago/image-versions.sh as a fallback for not-yet-updated
nodes, and retain scripts/image-versions.sh as the dev-repo-relative
fallback. Verified on .228: backend now logs "Parsed 57 image
versions from /opt/archipelago/scripts/image-versions.sh" on scan.

Pre-existing test_parse_image_versions failure in this module is
unrelated (the NOT_AN_IMAGE assertion was broken before this change
because the parser's _IMAGE-suffix retain keeps it). Leaving that for
the general cargo-test cleanup pass.
2026-04-23 09:29:15 -04:00
archipelago 4faac9cb74 docs(resume): add RESUME.md for context-restart recovery
Consolidated single-file snapshot of plan + progress for a fresh
OpenCode session to pick up the install UX polish work:

- Where we are: v1.7.43-alpha shipped, 5 commits on main, deployed
  to .228, browser verification in progress.
- Immediate next step: await user's verification results from
  https://192.168.1.228/ browser checklist.
- Working layout: SSHFS mount, ssh archy / archy228, deploy recipes.
- Architecture patterns: async-spawn lifecycle, phase-based install
  progress, scanner kick, .23 auto-purge migration.
- Backlog: Vaultwarden exit-on-start, install log perms, 22 stale
  cargo test failures, historical changelog entries left intact.
- User preferences: "best long-term first", one-by-one, no push,
  Bitcoin-only, conventional commits.

Complements STATUS.md (which remains the engineering log) with a
tighter resume-the-work narrative focused on the current round.
2026-04-23 09:14:36 -04:00
archipelago b62b731db0 docs(status): record rounds 3-5 + config migration + changelog as shipped
Adds a new top section to STATUS.md covering v1.7.43-alpha:

- Round 3: phase-based install progress bar
- Round 4: post-install scanner kick for instant Launch button
- Round 5: .23 VPS retirement, .168 promoted to Server 1
- Config migration: auto-purge .23 from saved registry/mirror JSONs
- Changelog: new v1.7.43-alpha entry in AccountInfoSection

All 5 commits, deployment md5, verification notes, and git remote
cleanup captured. Round 2 rollback command still valid for the full
stack since backups predate every round in this session.
2026-04-23 09:09:02 -04:00
archipelago 6c8cb50679 docs(changelog): add v1.7.43-alpha entry covering async lifecycle + .23 retirement
Four release-note bullets describing the user-visible changes shipped
in this round:

- async-spawn install/update/uninstall (UI no longer freezes)
- phase-based install progress bar (Preparing through Finalizing)
- scanner kick post-install (Launch button appears immediately)
- .23 Hetzner VPS retired, .168 OVH promoted to Server 1 with
  auto-purge migration for existing nodes

Matches the tone of existing changelog entries: what changed from the
operator's perspective, not internal implementation detail.
2026-04-23 09:07:29 -04:00
archipelago 28e38a36a9 fix(config): auto-purge decommissioned .23 VPS from saved registry/mirror configs
load_registries + load_mirrors normally only ADD missing defaults to
the persisted JSON — explicit removals stick. After retiring the .23
Hetzner VPS we need the opposite: existing nodes have .23 baked into
their saved configs and would spend seconds per install/update timing
out against a dead host until the operator manually removes it via
the Settings UI.

Add a targeted one-time migration in both loaders: if any saved entry
has 23.182.128.160 in its URL, drop it on load and rewrite the file.
This is an exception to the usual "explicit removals stick" rule —
the user never chose to add this mirror, it was a default.

Narrow-scope migration (one hardcoded IP match, no schema version)
because the cost/benefit of a general migration system isn't worth
it for a single decommissioned host. Future retirements can follow
the same pattern.
2026-04-23 08:51:26 -04:00
archipelago d9d5fa65e5 chore: retire .23 VPS mirror, promote .168 OVH to primary
The Hetzner VPS at 23.182.128.160 was decommissioned. Replace it
everywhere with the OVH VPS at 146.59.87.168, which was previously
the tertiary mirror.

  - update.rs: drop DEFAULT_TERTIARY_MIRROR_URL, promote .168 into
    the secondary slot as "Server 1 (OVH)"; tx1138 becomes Server 2.
    Default mirror list shrinks from 3 to 2.
  - container/registry.rs: default RegistryConfig drops .23, promotes
    .168 to Server 1 / priority 0, tx1138 stays Server 2 / priority 10.
  - api/rpc/package/config.rs: trusted-registry allowlist swaps .23
    for .168.
  - api/handler/mod.rs: app-catalog fallback URL uses .168.
  - neode-ui/views/marketplace/marketplaceData.ts: REGISTRY uses .168.
  - scripts/image-versions.sh: ARCHY_REGISTRY_FALLBACK uses .168.
  - image-recipe/build-auto-installer-iso.sh: installer ISO registries
    use .168 (both podman registries.conf and backend registries.json).

Tests updated to assert on the new 2-entry default lists (registry +
mirror). URL-parser fixture tests in update.rs retain .23 strings —
they exercise string-parsing logic, not mirror policy.

Git remotes: dropped `gitea-vps` and the .23 push URL on the `origin`
multi-push alias (not part of this commit — pure working-copy change).
2026-04-23 08:22:32 -04:00
archipelago 980c1b25f4 fix(install): kick scanner post-install so Launch button appears immediately
After install completes, the async-spawn wrapper wrote state=Running
but the skeletal install-time manifest (interfaces: None) persisted
until the next scheduled 60s scan. The frontend saw state=running but
hasUI=false and hid the Launch button for up to a full minute.

Add a shared Notify/watch pair between RpcHandler and the scan loop:
  - scan_kick (Notify): scan loop selects! between the 60s interval
    and this notify, running immediately on either.
  - scan_tick (watch<u64>): scan loop bumps the counter after each
    completed scan so callers can await completion.

Install and update success paths now call kick_scanner_and_wait before
flipping to Running. The scan merges via merge_preserving_transitional
(state stays Installing/Updating, manifest refreshed from live podman
with interfaces.main.ui populated from real port bindings). 2s timeout
falls back to pre-fix behavior on slow podman — no regression.
2026-04-23 07:59:03 -04:00
archipelago 7e62ea07f7 feat(install): phase-based progress bar replaces unparseable pull bytes
Podman emits zero parseable progress when stderr is piped (no TTY), so
the old byte-counter regex never matched in real installs. Users saw
0% for the whole pull, then a jump to 95%, then silence through
create-container, health-check, and post-install hooks.

Replace with 7 explicit lifecycle phases wired through install.rs and
update.rs: Preparing (5%), PullingImage (20%), CreatingContainer (70%),
StartingContainer (80%), WaitingHealthy (88%), PostInstall (95%),
Done (100%). Each maps to a fixed UI progress and status message.

Frontend PHASE_INFO mapper in stores/server.ts prioritizes phase when
present, falls back to byte-counter for legacy. A Math.max forward-only
guard ensures the bar never regresses. Deleted the duplicate watcher
in Discover.vue that was fighting the store's watcher with stale byte
logic. Added shimmer CSS on the fill (with prefers-reduced-motion
opt-out) so the bar looks alive during long phases.
2026-04-23 07:58:43 -04:00
archipelago 576ff1a6de docs(status): mark install/uninstall/update async-spawn as shipped 2026-04-23 06:58:45 -04:00
archipelago 49b98e0271 fix(rpc): empty icon in transient install entry to avoid broken-image flicker
create_installing_entry hardcoded /assets/img/app-icons/<id>.png for
every new install. About half the app icons ship as .svg or .webp
(lnd.svg, vaultwarden.webp, bitcoin-knots.webp, mempool.webp), so the
browser 404s on the wrong extension and renders the default broken-image
glyph for the 10-30s window before the scanner refreshes with real
manifest data.

Send empty icon. The frontend's icon computed in AppCard.vue falls
through to curatedMap which has correct extensions for bundled apps,
and handleImageError still guards any remaining misses with a
placeholder SVG.
2026-04-23 06:58:12 -04:00
archipelago 702b5d64d3 fix(ui): shorten install/uninstall/update timeouts for async RPCs
With the backend flipped to async-spawn, install/uninstall/update return
immediately with a { status, package_id } envelope. Client timeouts of
45m/11m were a leftover from synchronous handlers and masked real RPC
failures.

Drop all install/uninstall/update RPC timeouts to 15s. Progress and
terminal state still arrive through the live state stream — the RPC
only needs to confirm the spawn was accepted.

Return-type annotations updated in rpc-client.ts and stores/server.ts.
Five direct rpcClient.call sites across Marketplace.vue, Discover.vue,
and MarketplaceAppDetails.vue updated with the shorter timeout.
2026-04-23 06:58:02 -04:00
archipelago 1ad889608f feat(rpc): async-spawn install/uninstall/update lifecycle
Extend the async-spawn treatment previously shipped for Stop/Start/Restart
to the three remaining long-running lifecycle RPCs. Each wrapper validates
params, rejects duplicate in-flight ops, flips state to the transitional
variant (Installing/Removing/Updating), then spawns the existing inner
handler on tokio. RPC returns immediately with { status, package_id }; the
spawn task owns the terminal state write.

Install and update success arms explicitly set state=Running. The scan
loop merge (merge_preserving_transitional) refuses to overwrite
transitional states, so the spawn task must write the terminal state.
Uninstall's inner handler removes the entry entirely, so no explicit
terminal write is needed there.

Dispatcher and handler now thread self as Arc<Self> / &Arc<Self> so
spawned tasks can hold their own Arc without extra field cloning.

Transient install entry uses empty icon string. Hardcoding
/assets/img/app-icons/<id>.png 404s for apps that ship .svg or .webp
assets, which produces a broken-image flicker until the scanner refreshes
with manifest data. Empty string causes the frontend's icon computed to
fall through to the curated map, which has correct extensions.

Removed the inner "already updating" guard in update.rs — the wrapper
now owns duplicate-op detection for all three operations.
2026-04-23 06:57:50 -04:00
archipelago 0ea4f96de9 docs(status): mark async-spawn lifecycle fix as shipped
Records the four landed commits, the .228 deploy (binary + frontend
paths, backups, md5), the manual LND Stop verification, and the
rollback incantation. Leaves the older "NEXT SESSION" design block
in place as historical reference with a note that it's stale.

Adds a follow-ups list: chaos matrix is now unblocked, bundled-app
RPCs are still sync (deprecate or mirror-async?), transitional_since
is in-memory only, and there are 22 pre-existing test failures in
unrelated modules that should get their own cleanup pass.
2026-04-23 05:30:45 -04:00
archipelago a8158b1ef5 fix(ui): single-button lifecycle control with transitional labels
The app card and details view previously used a pair of Start/Stop
buttons whose labels were driven off isAppLoading(), a client-side
"I just clicked the button" flag. When the backend's graceful stop
took longer than the RPC round-trip (up to 600s on bitcoin-core),
the flag cleared while the container was still shutting down, the
UI flipped back to "Running" as soon as the next 10s scan saw the
still-alive container, and the user had no indication the stop was
still in flight.

Now that the backend flips PackageState to Stopping / Starting /
Restarting / Installing / Updating / Removing for the duration of
each lifecycle operation and the scan loop preserves those states,
the UI can drive its label off the container state itself. A single
full-width primary button replaces the Start/Stop pair. Its label,
color, and disabled state come from getAppVisualState(), which
collapses resting states (exited/created/paused/installed) into
"stopped" and passes transitional states through untouched.

Changes:

- container-client.ts: widen ContainerStatus.state union to include
  the six transitional variants plus "installed". Add
  restartContainer() calling the new container-restart RPC.
- stores/container.ts: add getAppVisualState() computed and the
  restartContainer() action.
- ContainerApps.vue: single primary button (Start / Stop / Starting
  / Stopping / Restarting etc.) plus a separate circular Restart
  button visible only when running. Critically, handleStartApp and
  handleStopApp now route through store.startContainer and
  stopContainer (which call container-start / container-stop, the
  async RPCs) instead of the legacy synchronous bundled-app-start /
  bundled-app-stop path. Transitional-state polling widened from
  just "created" to the full set of transitional variants.
- ContainerAppDetails.vue: same single-button pattern, Restart
  button now calls container-restart instead of the old
  stop-sleep-start sequence, added 2s polling interval for
  transitional states.
- components/ContainerStatus.vue: widen state prop to match the
  shared union, render transitional labels with a trailing ellipsis
  and a yellow dot.

No new tests — this is presentation logic. Manual verification on
.228 will confirm the end-to-end async path: click Stop on LND,
button becomes "Stopping" in under a second, stays that way for
roughly 5 minutes, then flips to "Start" with a grey dot. The UI
must never revert to "Running" mid-stop.
2026-04-23 05:20:15 -04:00
archipelago cd69c3b2f6 fix(state): preserve transitional state across container scans
The 30s package scan loop used to blindly overwrite every package
entry from podman inspect. While a user-initiated Stop / Start /
Restart was in flight, the RPC spawn task would flip the state to
Stopping / Starting / Restarting, the next scan would see podman
still reporting "running" (for the duration of the graceful stop,
up to 600s for bitcoin-core), and clobber the transitional state
back to Running. The dashboard would then flip Running -> Stopping
-> Running -> Stopped, making it look like the stop had silently
failed until it eventually completed.

The merge loop now treats transitional variants (Stopping, Starting,
Restarting, Installing, Updating, Removing, and the three backup
variants) as owned by the RPC spawn task. For those variants,
merge_preserving_transitional keeps the existing state while still
taking live observability fields (health, exit_code, installed,
lan_address, manifest, static_files, available_update) from the
fresh scan so the UI continues to see live health readings.

Adds an escape hatch via a per-scan transitional_since side table:
if a package has been in a transitional state for more than 1200s
(2x the longest graceful stop at 600s on bitcoin-core), the scan
loop assumes the spawn task died without cleanup and overrides with
podman's live state. Prevents a crashed background task from wedging
a package in Stopping forever.

Three unit tests cover the merge rule, the observability passthrough,
and the transitional-variant classifier.
2026-04-23 05:15:13 -04:00
archipelago 39dd1d9dcc fix(rpc): async container stop/start/restart; widen state mapping
RPC handlers no longer block on podman operations. container-stop on
bitcoin-core used to hold the connection for up to 600s while the UI
showed a frozen spinner; it now returns in under a second with
{status: stopping} after flipping the package state to Stopping and
broadcasting over WebSocket. Same treatment for container-start and
the new container-restart route.

Widens container-list state mapping to emit the transitional variants
(stopping, starting, restarting, installing, updating, removing,
installed, and the backup states) instead of collapsing them to
"unknown". Keeps the mapping in sync with the UI ContainerStatus.state
union so the dashboard can render the right transitional label.

Mirrors the treatment in package/runtime.rs for package.start,
package.stop, and package.restart. The body of each handler is lifted
into pure do_package_* helpers that the background task runs; state
flipping is bracketed around the spawn with revert on error. The
pre-existing post-start exit-check verification and restart stop+start
fallback run inside the spawned task, not the RPC body.

Adds container-restart route to the dispatcher. mark_user_stopped
continues to run BEFORE the spawn, preserving the ordering contract
with the crash recovery layer at runtime.rs:145-148.
2026-04-23 04:59:45 -04:00
archipelago 5baced5f5b feat(rpc): spawn_transitional helper for async lifecycle ops
Introduces a new RPC-layer helper that bridges the synchronous
ContainerOrchestrator trait with RPC handlers that must return in <1s.

The helper flips the package state to a transitional variant
(Stopping / Starting / Restarting) in the StateManager so WebSocket
clients see the live label immediately, then tokio::spawns the
actual orchestrator call. On success it writes the final state; on
error it reverts to the pre-transition state and logs via
install_log().

The ContainerOrchestrator trait stays synchronous so the reconciler,
boot flow, unit tests, and chaos harness keep deterministic
behaviour. Async only lives in the RPC layer.

Not wired to any handler yet — Commit 2 consumes this helper.
Widens install_log visibility from pub(super) to
pub(in crate::api::rpc) so the new sibling module can reach it.
2026-04-23 04:55:52 -04:00
archipelago cad63bdd76 docs: STATUS.md — FUSE/SSHFS development loop section
Dedicated section covering the file-ops-via-mount + git/cargo-via-ssh
split that makes this dev setup work. Includes:

- Exact running mount command (pulled from ps)
- macFUSE + sshfs-mac brew install path
- Health check + recovery sequence for when mount hangs (it will)
- Full which-path-for-which-operation table
- Don't-do list (cargo from mount, rsync without AppleDouble exclude, etc)
- Cache caveat and inode-sharing note between mount and SSH views

No code change.
2026-04-23 04:51:53 -04:00
archipelago bb2e3fab42 docs: STATUS.md — complete SSH/key/sudo/deploy reference for next session
Expands NEXT SESSION header with fully verified access info so a fresh
agent has zero ambiguity:

- SSH key inventory across laptop, .116, .228 (every file, purpose noted)
- Actual SSH config aliases (archy, archy228) with IdentitiesOnly
- Verified connectivity matrix (laptop -> both; .116 -> .228; .228 has no outbound key)
- Corrected sudo state: .228 sudoers file is /etc/sudoers.d/archipelago
  (not archipelago-ci); .116 has archipelago-ci + archipelago-wg scope-limited drop-ins
- SSHFS mount source command + AppleDouble gotcha
- Cargo over SSH PATH gotcha + detached build pattern for >2min timeout
- End-to-end deploy-to-.228 recipe (build, SCP, atomic swap, verify)
- Git workflow rules (no push, no amend, no force, conventional commits)

Removes duplicate host-reference block that the prior edit left trailing.
No code change.
2026-04-23 04:49:45 -04:00
archipelago 6a5fab709a docs: STATUS.md — dashboard Stop UX bug diagnosis + async-spawn fix plan
Captures full design for the next session:
- Full bug sequence (5.5min blocking RPC + 30s scan clobbering transitional state)
- 4-commit implementation order with exact file:line targets
- Single-button UI spec with full label table
- Verification gates including manual LND stop test on .228
- Architectural decision: spawn lives in RPC layer, orchestrator trait stays sync

No code change yet; next session implements.
2026-04-23 04:45:12 -04:00
archipelago 2a2f10608b docs: STATUS.md — .228 dashboard bugs fixed (macaroon + ExtraHost) 2026-04-23 04:17:56 -04:00
archipelago 7257f72f4a fix(first-boot): use podman host-gateway magic for host.containers.internal
The previous code computed HOST_GATEWAY from `ip route show default` to
work around an alleged podman 4.3.x limitation. Two problems:

1. The comment was wrong. Podman 4.4+ supports --add-host=host-gateway
   natively, and we ship 5.4.2.

2. More critically, `ip route show default` returns the LAN router
   (e.g. 192.168.1.254) — the gateway to the internet, not the gateway
   to the host. Every container configured with DAEMON_URL or
   --bitcoind.rpchost=host.containers.internal was therefore dialing
   the WiFi router instead of the host machine, silently failing.

Symptoms this caused on .228:
- LND crash-looped with "dial tcp 192.168.1.254:8332: connection refused"
- Dashboard showed no LND connect details or QR
- ElectrumX DAEMON_URL broken; stuck at 2 KB index for days
- Any service reaching bitcoin-core through the `archy-net` bridge

Replace the computed value with the literal string "host-gateway",
which podman translates to the correct in-network gateway at container
start. Also drop the stale HOST_GATEWAY reference in the Tor-bootstrap
branch (it always fell back to TARGET_IP anyway). Verified on .228:
after recreating bitcoin-core/electrumx/lnd with the new flag, LND
reached the chain backend, ElectrumX resumed indexing, and the
dashboard /lnd-connect-info endpoint succeeded.
2026-04-23 04:16:42 -04:00
archipelago 30b31b3670 fix(lnd): read admin macaroon via sudo fallback
LND's admin.macaroon is owned by a rootless-podman subordinate UID
(typically 100000) with mode 640. The archipelago server runs as UID
1000 and cannot read the file directly, which caused every dashboard
LND RPC (getinfo, connect-info, export-channel-backup) and lnd_client
to fail with "Failed to read LND admin macaroon".

Add a read_lnd_admin_macaroon() helper that first tries a direct read
(for operators who have relaxed permissions) then falls back to
`sudo -n cat`, mirroring the pattern already used for Tor hidden
service hostnames in handle_lnd_connect_info. Centralise the canonical
macaroon path as LND_ADMIN_MACAROON_PATH and route all four callers
through the helper.

Verified on .228: GET /lnd-connect-info now returns 200 with cert,
macaroon, and tor_onion fields. Dashboard QR/connect-string UI
unblocked.
2026-04-23 04:15:44 -04:00
archipelago 28819d1197 docs: STATUS.md through Step 9 (.228 hot-swap verified)
Logs Step 9 acceptance evidence, the two bugs caught and fixed during
the hot-swap (parse_memory_limit IEC suffix bug in 732df1b8 and
cgroup Delegate in ba83f9bc), and outlines the Step 10 plan for .116.
2026-04-23 03:46:23 -04:00
archipelago 80765c5755 feat(systemd): delegate cgroup controllers to archipelago.service
Adds Delegate=memory pids cpu io to the archipelago.service unit.

Context: the service runs as User=archipelago under system.slice with
rootless podman. When podman creates transient libpod-*.scope units for
containers under user.slice, systemd needs the caller to hold
CAP_SYS_ADMIN on the target cgroup subtree \u2014 which happens iff
Delegate= lists the controllers we want to set. Without Delegate, any
future code path that goes through the podman CLI (runtime.rs) instead
of the libpod HTTP API (podman_client.rs) would hit MemoryMax
rejections that have exactly the same symptom as the bug I just fixed
in parse_memory_limit but with a completely different root cause.

Belt-and-braces: current production path uses PodmanClient and was
fixed in the preceding commit. But the DockerRuntime CLI path in
runtime.rs:262-268 (cmd.arg("--memory")) is still reachable via
AutoRuntime fallback on hosts without podman, and future rust
orchestrator code may legitimately need cgroup delegation. This
directive is no-op harmful on hosts that already delegate upstream
(systemd gracefully handles duplicate/nested delegation).
2026-04-23 03:44:36 -04:00
archipelago 8acf7d1112 fix: parse_memory_limit accepts Ki/Mi/Gi IEC binary suffixes
The libpod HTTP API path (PodmanClient::create_container) ran manifest
memory_limit values like "128Mi" through parse_memory_limit which
lowercased+trim_end_matches("m"), leaving "128i" which parse::<f64>()
rejected. The resulting None became 0 via .unwrap_or(0), and podman
serialised that into the OCI config as memory.limit:0. At container
start time systemd then rejected MemoryMax=0 with "Value specified in
MemoryMax is out of range".

Silently wrong for every manifest in apps/ that uses Kubernetes-style
suffixes (all of them). Became visible on .228 when Step 9 first
exercised the ProdContainerOrchestrator path for bitcoin-ui and lnd-ui
installs \u2014 the old first-boot-containers.sh bash script used podman
run --memory 128m directly, which podman-the-CLI parses correctly, so
the bug never surfaced before.

Two parts:
- parse_memory_limit now recognises Ki/Mi/Gi/Ti (IEC binary, what k8s
  and our manifests use), kB/MB/GB/TB (SI decimal), k/K/m/M/g/G/t/T
  (docker shorthand, treated as IEC binary for backwards compat), and
  bare byte integers. Filters out zero/negative results.
- create_container omits the memory/cpu fields entirely when the
  manifest has no limit or parsing fails, rather than emitting 0. The
  libpod API treats absent as unlimited; 0 is "set MemoryMax=0" which
  systemd rightly rejects. Defence in depth against the next weird
  suffix someone puts in a manifest.

Six regression tests in the new tests module cover IEC, SI, shorthand,
raw bytes, invalid input (empty/garbage/0/negative), and whitespace.
2026-04-23 03:44:23 -04:00
archipelago c396be8068 feat(iso): Step 8a — retire archipelago-reconcile systemd timer
BootReconciler (in-process, 30s interval, spawned from main.rs as of
Step 6 commit 48f08aa3) fully replaces the timer-driven bash
reconciliation path. Delete the systemd unit + timer and their
ISO-builder touchpoints.

Removed:
- image-recipe/configs/archipelago-reconcile.service
- image-recipe/configs/archipelago-reconcile.timer
- image-recipe/build-auto-installer-iso.sh L412-413 (COPY unit+timer)
- image-recipe/build-auto-installer-iso.sh L449 (systemctl enable)
- image-recipe/build-auto-installer-iso.sh L542-543 (cp to WORK_DIR)

Kept (intentionally):
- scripts/reconcile-containers.sh
- scripts/container-specs.sh

Reason: core/archipelago/src/api/rpc/package/update.rs still invokes
reconcile-containers.sh at two sites (OTA update + rollback paths).
Porting those call sites to ContainerOrchestrator::upgrade() requires
manifests for every container update.rs might touch — that scope
belongs in Step 8b. Until then the script stays on disk, just no
longer runs on a periodic timer.

No Rust code changes. cargo check -p archipelago clean, 6 pre-existing
warnings. Skipped full ISO rebuild validation per user decision —
edits are 5 textual deletions with zero behavioral ambiguity; Step 9
live hot-swap on .228 will catch any regression.
2026-04-23 03:04:58 -04:00
archipelago 236a2dee85 docs: split Step 8 into 8a/8b/8c
Discovered during Step 8 execution that first-boot-containers.sh
creates 30+ containers with per-container logic (wallet loads, DB
init, rpcauth derivations, post-create health waits) and does
substantial non-container setup (secret gen, rootless-podman subuid
chowns, Tor hostnames, WireGuard, firewall, nostr-relay). Only 3 of
the 30+ containers have manifests today (the UIs from Step 7).

Deleting the bash in a single step bricks first-boot on fresh
installs. Split into:

- 8a: delete reconcile-containers.sh + container-specs.sh + reconcile
  systemd unit + timer. BootReconciler fully covers these. Safe,
  atomic, no manifest porting required.
- 8b: port remaining ~25 containers into apps/<id>/manifest.yml. One
  manifest per commit, validated against current bash behavior.
  Multi-day scope.
- 8c: rename first-boot-containers.sh -> first-boot-setup.sh, strip
  container ops, keep secret/dir/Tor/WG/firewall setup. Final
  one-way door, requires 8b complete.
2026-04-23 02:34:43 -04:00
archipelago 758d3e47d8 docs: STATUS.md through Step 7 2026-04-23 02:21:01 -04:00
archipelago 3e9c192b48 feat(container): bitcoin-ui pre-start hook renders nginx.conf from embedded template
Replaces the first-boot-containers.sh sed/envsubst approach with a
Rust-native render step bound into the ContainerOrchestrator lifecycle.

- New container::bitcoin_ui module: embeds the nginx.conf template via
  include_str!, reads the plaintext RPC password from
  /var/lib/archipelago/secrets/bitcoin-rpc-password, substitutes
  {{BITCOIN_RPC_AUTH}} with base64(archipelago:<password>), and atomic-
  writes (tmp + rename) to /var/lib/archipelago/bitcoin-ui/nginx.conf.
  Idempotent: byte-compares before writing so unchanged input is a
  no-op (no inode churn, no restart cascade).
- ProdContainerOrchestrator gains run_pre_start_hooks(app_id) returning
  HookOutcome::{Rewritten, Unchanged}. Fires in install_fresh before
  create_container, and in ensure_running: on Running + Rewritten
  triggers a restart; on Stopped re-renders then starts.
- bitcoin-ui Dockerfile no longer COPYs a default.conf; the file now
  arrives via runtime bind-mount of the rendered config. If the bind-
  mount is ever missing, nginx starts with no site configured and
  returns 404 everywhere — safe failure vs. serving upstream RPC with
  a stale Authorization header.
- apps/{bitcoin,electrs,lnd}-ui/manifest.yml land as first-class
  manifests. bitcoin-ui declares the bind-mount target and a dependency
  on bitcoin-core; electrs-ui and lnd-ui declare their own deps and
  health checks.
- 8 new unit tests on the render fn (idempotency, rotation, trimming,
  missing/empty secret, template invariants) plus an integration test
  asserting install(bitcoin-ui) actually lands a substituted nginx.conf
  on disk via the hook. 39/39 container:: tests pass
  (test_parse_image_versions pre-existing failure unchanged, out of
  scope).
2026-04-23 02:19:52 -04:00
archipelago ba8bd0bb86 docs: STATUS.md through Step 6 2026-04-22 19:20:17 -04:00
archipelago 6a0809d386 feat(container): wire ProdContainerOrchestrator + BootReconciler into main
Step 6 of the rust-orchestrator migration. Construct the container
orchestrator once in main.rs, call load_manifests + adopt_existing
immediately after Config::load, log the adoption report, and spawn
BootReconciler::run_forever with the 30s default interval. Thread the
orchestrator through Server::new -> ApiHandler::new -> RpcHandler::new
so the reconciler and RPC layer share one instance.

Wire a tokio::sync::Notify through the SIGTERM/SIGINT shutdown path so
the reconciler exits cleanly alongside the server drain. Uses notify_one
so the signal stores a permit if the reconciler is mid reconcile_all
when the signal fires.

Delete the commented-out run_boot_reconciliation block in main.rs that
documented the prior bash-script approach being unsafe on unbundled
installs — the new reconciler is manifest-driven and only touches apps
present in /opt/archipelago/apps, fixing that concern.

cargo check -p archipelago clean (6 pre-existing dead-code warnings on
trait methods not yet exercised until Step 9 hot-swap). Container test
suite 43/44 pass; the one failure (container::image_versions::
test_parse_image_versions) is pre-existing and unrelated.
2026-04-22 19:20:13 -04:00
archipelago 81c1613040 feat(container): BootReconciler — periodic reconcile loop for prod orchestrator
Step 5 of the rust-orchestrator migration. New file boot_reconciler.rs holds a
small Tokio task that calls ProdContainerOrchestrator::reconcile_all() on a
30-second cadence (answered design Q3).

  * BootReconciler::new(orch, interval, shutdown) — shutdown is an Arc<Notify>
    so callers can trigger a graceful exit without pulling in tokio-util.
  * run_forever(self) — does one reconcile immediately, then loops on
    tokio::select! { sleep_until | shutdown.notified() }. Shutdown interrupts
    the sleep but never an in-flight reconcile_all call.
  * Per-pass outcomes are logged at debug/warn; failures never propagate out
    because reconcile_all already absorbs per-app errors into ReconcileReport.

Four tokio::test(start_paused = true) tests verify the loop cadence against a
CountingRuntime test double:
  * initial_pass_fires_immediately — first reconcile runs with no delay
  * second_pass_fires_after_interval — second pass fires after exactly
    interval elapses in paused-clock time
  * shutdown_terminates_loop — notify_one() lets run_forever return
  * failure_in_one_pass_does_not_stop_loop — the loop keeps ticking even when
    the first pass had to install a missing container

Not wired into main.rs yet — that is Step 6. Re-exported from container::mod
as BootReconciler + RECONCILER_DEFAULT_INTERVAL for the wire-up step.
2026-04-22 19:04:34 -04:00
archipelago 89199bb03b docs: update STATUS.md — Step 4 done, Step 5 next
Records acceptance evidence for Steps 1-4 (container tests 21/21 pass, build
clean with expected unused-method warnings) and queues the BootReconciler
implementation for Step 5.
2026-04-22 18:57:43 -04:00
archipelago ca299e70e8 chore: gitignore macOS AppleDouble files from SSHFS writes
The laptop mounts ~/Projects/archy over SSHFS and macOS finder / Spotlight
sidecars write ._<name> resource-fork files alongside every edit. They are
noise; keep them out of git.
2026-04-22 18:56:58 -04:00
archipelago 40a6eaca72 feat(container): ContainerOrchestrator trait, RpcHandler uses it in prod
Step 4 of the rust-orchestrator migration. Unifies the container lifecycle
surface behind a single trait so the RPC layer stops caring whether it is
talking to the dev or prod orchestrator.

  * New trait core/archipelago/src/container/traits.rs: ContainerOrchestrator
    with install / start / stop / restart / remove / upgrade / status / list /
    logs / health, all keyed by app_id. Every method is async_trait-based.

  * ProdContainerOrchestrator: the lifecycle methods are moved from inherent
    impl into the trait impl (avoids name-shadowing recursion). Adoption and
    reconcile remain inherent since only main.rs / BootReconciler call them.

  * DevContainerOrchestrator: new trait impl that forwards to the existing
    Dev-named methods, applying the dev container-name + port-offset rules
    internally. New load_manifest_for() helper resolves app_id to
    <data_dir>/apps/<app_id>/manifest.yml so trait-level install(app_id)
    works in dev too. install_container(manifest, path) stays inherent for
    the manifest-path RPC shape.

  * RpcHandler now holds Option<Arc<dyn ContainerOrchestrator>> and, when in
    dev mode, a separate Option<Arc<DevContainerOrchestrator>> for the
    manifest_path install RPC. In prod mode RpcHandler::new() constructs a
    ProdContainerOrchestrator and calls load_manifests() at startup.

  * All seven container-* RPC guards no longer say dev mode required.
    container-install still requires dev mode because its manifest_path
    argument has no prod meaning; every other container RPC now works in both
    modes via the trait.

BOOT STILL DOES NOT USE THIS. main.rs wire-up (Step 6) and BootReconciler
(Step 5) come next. Until then the prod orchestrator is constructed but nothing
populates /opt/archipelago/apps so it has zero manifests to manage, matching
the pre-Step-4 behaviour.

Verification: cargo build -p archipelago clean (11 expected unused method
warnings for methods not yet wired from main.rs). cargo test -p archipelago:
all 21 container::* tests pass (16 prod_orchestrator + 5 others). 24 other
test failures are pre-existing and unrelated (identity_manager / session /
wallet / mesh / credentials — all independently flaky on file-backed state).
2026-04-22 18:56:52 -04:00
archipelago e103925a4e feat(container): ProdContainerOrchestrator with build-or-pull, adoption, reconcile
Step 3 of the rust-orchestrator-migration. New file prod_orchestrator.rs (999 LOC)
implements the full public surface that will replace scripts/first-boot-containers.sh:

  * install / start / stop / restart / remove / upgrade / status / list / logs / health
  * adopt_existing: read-only scan that claims containers matching our manifests by
    name, without recreating — preserves the v1.7.42 fixture on .116.
  * reconcile_all: level-triggered, per-app failures collected rather than aborting.
  * install_fresh: build-or-pull (Step 2 trait methods), relative build contexts
    resolved against the manifest directory.

Naming rule (answered design Q1): UI app IDs (bitcoin-ui/electrs-ui/lnd-ui) get the
archy- prefix; backends keep their bare ID. An explicit extensions.container_name
always wins. Codified in compute_container_name() with unit tests for all three tiers.

Concurrency (answered design Q4): per-app tokio::sync::Mutex<()> created lazily,
protecting every mutating op against the reconciler loop. Acquiring the per-app
lock only needs a read lock on the map, so independent apps do not serialize.

16 tests: 3 sync naming rule tests + 13 tokio async tests covering install (pull,
build-absent, build-present, relative-context), reconcile (noop/exited/missing/
mixed-failure), adopt-by-name, upgrade sequence ordering, list filtering, health
state mapping, and unknown-app-id rejection. All pass.

Not wired into main.rs yet — that is Step 6. Crate builds clean with expected
unused warnings for the new re-exports.
2026-04-22 18:32:31 -04:00
archipelago 56af57a6f8 feat(container): runtime trait gains image_exists + build_image
Adds two methods to ContainerRuntime so the upcoming ProdContainerOrchestrator
can inspect local image storage and build images from BuildConfig:

- image_exists(image_ref) -> Result<bool>: local-storage check only, does
  not consult registries. Distinguishes exit 0 (present) from exit 1
  (absent) from other failures (environment error).
- build_image(&BuildConfig) -> Result<()>: shells out to podman/docker
  build with -t, -f, deterministically-sorted --build-arg pairs, and the
  context path last.

Implemented on all three runtimes:
- PodmanRuntime: new podman_cli helper shells out alongside the existing
  HTTP API calls (build and image inspect are awkward over the HTTP API)
- DockerRuntime: native docker CLI, same exit-code semantics
- AutoRuntime: delegates to the selected inner runtime

Argv construction extracted into pure build_args_for_podman helper so it
can be unit-tested without a real podman. 4 new tests cover minimal args,
custom Dockerfile path, deterministic build-arg sorting (guards against
HashMap iteration non-determinism), and context-is-last (positional arg
placement is load-bearing for podman build).

Step 2 of docs/rust-orchestrator-migration.md. 25/25 tests pass.
2026-04-22 17:46:47 -04:00
archipelago 919055f3f1 feat(container): add build source to manifest schema
ContainerConfig.image is now Option<String>, mutually exclusive with a new
optional ContainerConfig.build: Option<BuildConfig>. Exactly one of image
or build must be present, enforced in AppManifest::validate.

Adds ResolvedSource enum (Pull | Build) and ContainerConfig::resolve +
::image_ref helpers so the orchestrator can treat pull and build uniformly.
All 26 existing pull-only manifests continue to parse unchanged
(covered by existing_pull_only_manifests_still_parse test).

Call sites updated: podman_client, runtime::DockerRuntime, dev_orchestrator.
Dev orchestrator errors out cleanly on Build sources until Step 2 lands
build_image support on the runtime trait.

Step 1 of docs/rust-orchestrator-migration.md. 10 new unit tests, all pass.

Also includes: docs/rust-orchestrator-migration.md (design spec) and
docs/STATUS.md resume section for the next session.
2026-04-22 17:46:36 -04:00
archipelago 0ac673deb4 release(v1.7.42-alpha): bitcoin RPC retry wrapper so syncing nodes stop flashing red
Closes failure mode adjacent to FM3 (docs/bulletproof-containers.md): on
a syncing pruned node, bitcoind's RPC thread blocks for 5-10s during block
validation. The old 10s client-side timeout was rejecting roughly 30% of
UI calls even though the node was perfectly healthy. 20x stress test on
the live .116 node (caught in IBD catch-up at block 797k) used to drop
10 of 20 calls; now drops 0 of 20.

What changed:
- core/archipelago/src/api/rpc/bitcoin.rs: bitcoin_rpc_call now retries up
  to 3 times with 500ms and 1500ms backoffs between attempts. Only
  transient transport errors (timeout, connect refused, send/recv IO)
  trigger retry. A well-formed bitcoind error response is surfaced
  immediately - real RPC bugs are never masked.
- Per-attempt hard deadline (tokio::time::timeout, 15s) layered on top
  of reqwest's own timeout, so DNS starvation or TLS wedging can't
  steal the entire retry budget.
- handle_bitcoin_getinfo client builder gained a 3s connect_timeout
  so a dead bitcoind is fast-failed inside the first attempt instead
  of eating the whole 15s.
- Retry policy extracted into a RetryConfig struct so tests can dial
  down timeouts to ~100ms per attempt. Production defaults live in
  RetryConfig::production().

Not changed (tracked as follow-up):
- mesh/mod.rs bitcoin_rpc_getblockcount and related helpers use the
  same 10s-timeout pattern. Not migrated to the new wrapper in this
  release; scheduled for v1.7.43 alongside the render_bitcoin_conf
  work.
- lnd/info.rs and electrs_status have similar 10s/15s timeouts but
  different failure profiles - audit first, migrate only the ones
  that actually exhibit the bug.

Tests: 6 new unit tests under api::rpc::bitcoin::tests, all passing.
Uses an in-process hyper server (already a transitive dep) to simulate
bitcoind responses; no new crates required.
  - happy_path_first_attempt: no retry when first attempt succeeds
  - retries_on_timeout_then_succeeds: first attempt times out, second
    succeeds, returns OK (uses a short-timeout RetryConfig so the test
    runs in <1s instead of 15s)
  - retries_exhausted_on_persistent_connect_refused: all attempts fail
    against a closed port, error bubbles up, elapsed time confirms
    backoffs actually ran
  - does_not_retry_on_rpc_level_error: bitcoind-returned error body is
    surfaced immediately, no retry
  - does_not_retry_parse_errors: non-JSON response (e.g. 503 with html
    body) is NOT retried - guards against the tempting "retry all
    non-2xx" mistake that would mask real bitcoind misconfig
  - retry_budget_invariants: asserts total wall-time ceiling stays
    under 60s so a bumped constant can't silently hang a UI call
    forever

Validated live on .116: 20/20 bitcoin.getinfo calls succeed during IBD
catch-up (chain at block 797419 -> 797464), vs ~40% baseline under the
old 10s timeout. Worst-case latency was 48.9s during peak validation;
happy-path latency (cached result) remains 28-77ms.
2026-04-22 16:46:28 -04:00
archipelago d1bcf271f9 release(v1.7.41-alpha): post-OTA auto-rollback so a bad release cannot strand the fleet
Closes failure mode FM5 from docs/bulletproof-containers.md: the v1.7.38 +
v1.7.39 rollouts left every affected node on an unreachable UI (nginx 500)
with no recovery path short of SSH. This release adds a self-check
guardrail to the update flow.

What changed:
- apply_update() writes a pending-verify marker with old+new version and
  a 150s deadline immediately before scheduling the service restart.
- verify_pending_update() runs from main.rs startup. If the marker is
  present and within its freshness window, the new binary waits 15s for
  nginx + backend to settle, then probes https://127.0.0.1/ every 5s for
  up to 90s (self-signed certs accepted).
- On any probe success within the window, the marker is cleared and
  nothing else happens.
- On window-exhaust, the new binary:
    1. Moves the broken /opt/archipelago/web-ui to web-ui.failed.<ts>
       (quarantined, not deleted, so we can post-mortem).
    2. Restores web-ui.bak on top of web-ui.
    3. Calls rollback_update() to restore the previous binary.
    4. Updates state.current_version to reflect the rollback.
    5. systemctl --no-block restart archipelago so the OLD binary boots.
- Markers older than 10 minutes are treated as stale and cleared without
  probing, so a crashed-during-startup marker from weeks ago cannot
  spontaneously roll back a healthy node on a later reboot.
- rollback_update() binary copy now goes through host_sudo instead of
  tokio::fs::copy, so it escapes the service's ProtectSystem=strict
  mount namespace. Without this, the rollback silently failed with
  EROFS on /usr/local/bin and orphaned the rollback - the exact
  opposite of what auto-rollback is for.

Tests: 4 new unit tests in update::tests covering marker round-trip,
absent-marker noop, no-panic on verify_pending_update with nothing to
verify, and an invariant assert that the 90s probe window stays below
the 600s stale threshold. All passing.

Side fix: scripts/create-release-manifest.sh was dying with exit 141
(SIGPIPE from tar tvzf pipe head pipe awk) under set -euo pipefail.
Replaced with a single awk NR==1 that doesn't short-circuit the upstream
pipe, so the release-build flow is idempotent again.
2026-04-22 16:14:35 -04:00
DorianandClaude Opus 4.7 85417de952 release(v1.7.40-alpha): fix tarball root perms at source so OTA can't 500 again
v1.7.38 and v1.7.39 both shipped with `./` inside the frontend tarball marked
drwx------ (700). Tar extraction preserves archive perms, so every node that
pulled the OTA landed with /opt/archipelago/web-ui at 700, nginx (www-data)
returned 500 "permission denied" on every page, and the browser showed
"Internal Server Error nginx". .116 hit this on both v1.7.38 and v1.7.39
rollouts. The v1.7.39 runtime self-heal in main.rs was the wrong layer —
systemd's ReadOnlyPaths namespace made /opt/archipelago read-only from inside
the archipelago service, so chmod from there returned EROFS.

Root cause: create-release-manifest.sh used mktemp -d (700 default umask) for
staging, then tar preserved that 700 in the archive's root entry.

Fix the archive itself:
- chmod 755 staging dir + `find -type d -exec chmod 755` + `-type f chmod 644`
  before tar, so the on-disk entries are correct.
- tar --owner=0 --group=0 --mode='u=rwX,go=rX' to normalize archive perms
  belt-and-braces in case file-mode drift ever reappears.
- Post-tar verify: `tar tvzf | head -1` must show drwxr-xr-x at root, or
  the release script aborts before the manifest is even generated.

Binary unchanged semantically — the main.rs self-heal stays in as a last-
resort belt (can't hurt on nodes whose FS isn't namespace-isolated), and the
update.rs in-extractor chmod stays in so v1.7.40-onwards extractors are
double-safe. The authoritative fix is the archive.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 13:54:44 -04:00
DorianandClaude Opus 4.7 b8d084368e release(v1.7.39-alpha): hotfix web-ui perms after OTA (nginx 500) + startup self-heal
v1.7.38 shipped with an OTA bug: the tar-extracted staging dir inherited 700
perms and nginx (www-data) returned 500/403 on every request after the swap.
.116 hit this on rollout; had to chmod by hand to recover.

- update.rs: after extraction, explicitly chmod 755 dirs + 644 files on the
  new staging dir before the mv into place, so nginx can stat/serve them.
- main.rs: self-heal on startup — if /opt/archipelago/web-ui is not
  world-readable, run `sudo chmod -R u=rwX,go=rX` to repair. This is what
  rescues nodes upgrading from v1.7.37/v1.7.38, since their extractor
  (running on the old binary) doesn't have the chmod fix yet — the new
  binary's first boot fixes the mess before nginx serves a single request.

Everything v1.7.38 shipped is still in this release:
- auth.rs auto-heals is_onboarding_complete() from setup_complete +
  password_hash so nodes don't bounce back to /onboarding/intro after
  browser clear / reboot / update
- useOnboarding tri-state: backend-unreachable no longer defaults to intro
- login sounds gated by isFirstInstallPhase() — silent after onboarding,
  typing sounds unaffected
- FIPS app / Nostr Relay / Nostr VPN / Routstr / Penpot removed from
  catalog + frontend + Rust + docker + icons; 15 image versions deleted
  from tx1138, .168, gitea-local
- AIUI baked into release tarball via demo/aiui/
- prebuild hook syncs app-catalog/catalog.json → public/catalog.json

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 13:26:54 -04:00
DorianandClaude Opus 4.7 36a6101026 release(v1.7.38-alpha): onboarding auto-heal + silent returning logins + app-store trim
- auth.rs now infers onboarding-complete from setup_complete + password_hash so
  nodes stop bouncing users through the intro wizard after browser clear / update
  / reboot; the flag self-heals to disk on next check
- frontend: "backend uncertain" no longer defaults to /onboarding/intro —
  useOnboarding returns null + callers poll / retry instead of flashing the wizard
- login sounds (synthwave, welcome voice, pop, whoosh, oomph) gated by
  isFirstInstallPhase(); typing sounds unaffected
- removed FIPS app, Nostr Relay, Nostr VPN, Routstr, Penpot from catalog,
  frontend config, Rust AppMetadata + install dispatch + install_penpot_stack;
  docker/fips-ui + docker/nostr-vpn-ui + apps/penpot dirs and 5 icons deleted;
  15 image versions deleted from tx1138, .168, gitea-local registries (.160
  Gitea was 502 at release time — follow-up)
- AIUI baked into frontend release tarball via demo/aiui/; deploy-to-target
  falls back to demo/aiui/ when the AIUI sibling checkout is missing
- prebuild hook syncs app-catalog/catalog.json → public/catalog.json so the
  two copies can no longer drift (was the source of the "apps still visible"
  bug — public/ had stale data)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 13:02:24 -04:00
DorianandClaude Opus 4.7 cfc98c600e release(v1.7.37-alpha): bitcoin-core install fixes + dynamic node UI + full-archive default
Install flow
- api/rpc/package/install.rs: always append the literal image URL as a
  last-resort pull candidate in do_pull_image, so images not carried by
  any configured mirror (docker.io/bitcoin/bitcoin:28.4) still install
  instead of masquerading as a generic pull failure across every mirror.
- api/rpc/package/install.rs: write_bitcoin_conf now skips on any stat
  error, not just "file exists". Once bitcoin-knots' first-boot chowns
  /var/lib/archipelago/bitcoin into the container's user namespace (700
  perms, UID 100100/100101), the archipelago daemon can't even traverse
  in — try_exists returns Err which unwrap_or(false) treated as "not
  present" and drove a doomed write. Now errors out of the directory
  traversal are treated as "conf already owned by container user" and
  the write is skipped. Mirrors the lnd.conf pattern.
- api/rpc/package/install.rs: drop the hardcoded `prune=550` from the
  conf default. Operators with multi-TB drives shouldn't be silently
  pruned; users who want a pruned node can set it in bitcoin.conf
  themselves. Full archive is the only honest default.
- api/rpc/package/config.rs: bitcoin-core now passes explicit
  -server/-rpcbind/-rpcallowip/-rpcport/-printtoconsole/-datadir CLI
  args. Vanilla bitcoin/bitcoin:28.4 has no entrypoint wrapper and
  reads conf + argv only; without these the RPC listens on 127.0.0.1
  inside the container and rootlessport can't reach it, so the
  bitcoin-ui companion gets 502 on every /bitcoin-rpc/ call.
  Bitcoin Knots keeps its own entrypoint-driven defaults.
- container/docker_packages.rs: split bitcoin-core out of the shared
  AppMetadata arm. bitcoin-core now surfaces as "Bitcoin Core" with
  bitcoin-core.svg and a Reference-implementation description; the
  bitcoin + bitcoin-knots ids keep the Knots branding. Fixes the home
  card showing "Bitcoin Knots" for a Core install.

Bitcoin node UI (docker/bitcoin-ui)
- index.html: impl name/tagline/logo now dynamic. applyImplBranding()
  reads subversion from getnetworkinfo — /Satoshi:X/Knots:Y/ resolves
  to Bitcoin Knots, plain /Satoshi:X/ resolves to Bitcoin Core. Both
  get their own icon and subtitle. Settings modal replaced its
  hardcoded Regtest/txindex=1/port-18443 placeholders with live values
  from getblockchaininfo + getindexinfo + getzmqnotifications.
- index.html: new Storage info card (Full Archive · X GB /
  Pruned · X GB from blockchainInfo.pruned + size_on_disk) visible on
  the main dashboard, same level as Network. Settings modal mirrors it
  with the prune height when applicable.
- Dockerfile + assets/: bitcoin-core.svg, bitcoin-knots.webp, and the
  bg-network.jpg used by the dashboard are now COPY'd into the image
  under /usr/share/nginx/html/assets. Previously the <img src> pointed
  at paths that 404'd into the SPA fallback and the onerror handler
  hid the broken logo silently.

Frontend
- appSession/appSessionConfig.ts: add bitcoin-core to APP_PORTS (8334),
  HTTPS_PROXY_PATHS (/app/bitcoin-ui/), and APP_TITLES (Bitcoin Core).
  Without these the AppSessionFrame showed "No URL found for
  bitcoin-core" and the home/app-list title fell through to the raw id.
- settings/AccountInfoSection.vue: backfill What's New entries for
  v1.7.31 through v1.7.37 that had been missed in earlier cuts.

Release plumbing
- releases/v1.7.37-alpha/: binary + frontend tarball.
- releases/manifest.json: v1.7.37-alpha, sha256/size refreshed.
- Cargo.toml / package.json: version bumps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 11:03:47 -04:00
DorianandClaude Opus 4.7 e206e1fc94 fix(catalog): prefix bitcoin-core image with docker.io/ so the install validator accepts it
The trusted-registry allowlist in api/rpc/package/config.rs splits the
image on '/' and matches the first segment against a fixed set (docker.io,
ghcr.io, git.tx1138.com, 23.182.128.160:3000, ghcr.io, localhost). A bare
'bitcoin/bitcoin:28.4' splits to registry="bitcoin" which isn't on the
list, so the install RPC was returning 'Invalid Docker image format'.

Live catalogs on .160 and gitea-local already hotfixed directly; these
static copies keep ISO builds and the final hardcoded fallback in sync.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 09:18:49 -04:00
DorianandClaude Opus 4.7 9cf1177b73 release(v1.7.36-alpha): bitcoin-core in App Store + Sovereignty Stack + dynamic catalog URL
- neode-ui/public/assets/img/app-icons/bitcoin-core.svg (NEW): 256×256
  Umbrel community Bitcoin icon sourced from getumbrel.github.io/
  umbrel-apps-gallery/bitcoin/icon.svg. Referenced by the static
  catalog, the curated fallback, and the upstream lfg2025/app-catalog
  entry so every surface shows the same image.
- app-catalog/catalog.json + neode-ui/public/catalog.json: add
  bitcoin-core (v28.4) entry pointing at bitcoin/bitcoin:28.4. Same
  entry pushed to the lfg2025/app-catalog repo on .160 and the local
  gitea mirror so nodes see it without needing a full archipelago
  update. Sovereignty Stack entry added to FEATURED_DEFINITIONS with
  a description that frames it as a Knots alternative, not a rival.
- core/archipelago/src/api/handler/mod.rs: handle_app_catalog_proxy
  is now instance-scoped (&self) and derives its upstream list from
  load_registries — each active container registry contributes one
  `<scheme>://<reg.url>/app-catalog/raw/branch/main/catalog.json` URL
  in priority order (scheme follows tls_verify). When the operator
  switches mirrors in Settings, the App Store now follows. Falls back
  to the legacy hardcoded .160/tx1138 pair only when registry config
  can't be loaded, so the App Store still renders on nodes that
  haven't persisted one yet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 09:06:10 -04:00
DorianandClaude Opus 4.7 a7048f6d8e release(v1.7.35-alpha): rootless-netns self-heal + app update button + bitcoin-core 28.4 + Node DID unification
- core/archipelago/src/bootstrap.rs (NEW): embed scripts/container-doctor.sh
  and image-recipe/configs/archipelago-doctor.{service,timer} via
  include_str! and sync to disk + enable the timer on every archipelago
  startup. Idempotent (content-hash compare), dev-box symlink guard keeps
  the git checkout untouched, best-effort (warn-only on failure) so
  bootstrap never blocks server readiness. Wired in main.rs as a
  background tokio task.
- scripts/container-doctor.sh: add fix_rootless_netns_egress(). Detects
  when the rootless-netns has lost its pasta tap (container-to-container
  still works but outbound DNS/TCP fails) via an nsenter probe into
  aardvark-dns; with a two-probe 10s debounce to rule out transients and
  a host-precheck that bails out if the host itself is offline. When the
  rootless-netns is truly broken, does a graceful podman stop --all /
  start --all so pasta + aardvark-dns rebuild the netns from scratch.
  Bitcoin-knots and every other outbound container recover in one cycle.
- core/archipelago/src/update.rs: host_sudo → pub(crate) so bootstrap.rs
  can reuse the existing systemd-run escape hatch.
- apps/bitcoin-core/manifest.yml: bump app version 24.0.0 → 28.4.0 and
  image bitcoin/bitcoin:24.0 → bitcoin/bitcoin:28.4. Resources aligned
  with the real container-specs.sh large-disk tune (4 GiB memory cap,
  cpu_limit: 0 so bitcoind can run -par=auto across every core).
- neode-ui/src/views/apps/AppCard.vue + Apps.vue: add an Update button
  + Updating spinner to every app card that has available-update set.
  Wires through serverStore.updatePackage(id) — the same RPC the detail
  view already calls. common.update / common.updating i18n keys added in
  en.json and es.json.
- core/archipelago/src/identity_manager.rs: add create_from_signing_key()
  that mirrors an existing Ed25519 key as a manager-level identity with
  a deterministic id (`node-<pubkey16>`). Idempotent across restarts,
  gets the hex-SVG master avatar.
- core/archipelago/src/server.rs: the auto-create path on first boot now
  mirrors the node's own signing_key (seed-derived on onboarded installs)
  as a "Node" identity instead of generating a random "Default" keypair.
  Once this ships, the DID on the Web5 DID Status card (via node.did
  RPC), the Node entry on the Identities page (via identity.list), and
  the DID used for peer-to-peer connects (via server_info.pubkey) all
  resolve to the same seed-derived pubkey.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 08:29:56 -04:00
DorianandClaude Opus 4.7 06feb85aa5 release(v1.7.34-alpha): re-seed onboarding cache + rotating login bg + drop re-login zoom
- useOnboarding.ts: when the backend gives a definitive answer
  (true/false, not a null retry failure), re-seed the
  neode_onboarding_complete localStorage flag accordingly. Fixes the
  case where a user clears site data on an already-onboarded node —
  OnboardingWrapper's useVideoBackground computed reads localStorage
  synchronously, so without this re-seed the intro video would fire
  again on /login even though RootRedirect correctly sent them
  straight to /login.
- OnboardingWrapper.vue: login background now rotates through
  bg-intro-1..6 on each /login mount, with the current index
  persisted to localStorage (neode_login_bg_idx) so subsequent
  logouts advance rather than repeat the same image.
- Dashboard.vue: subsequent-login branch drops the 1.2s showZoomIn
  entirely. Only the first dashboard entry after onboarding plays
  the full zoom + glitch reveal; every re-login now just fades in
  with the welcome typing (~300ms).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 05:42:52 -04:00
DorianandClaude Opus 4.7 aa0677be57 release(v1.7.33-alpha): onboarding/login UX fixes + PWA cache bust
- useOnboarding.ts: prefer the backend over localStorage when checking
  onboarding completion. The old order (localStorage first) meant any
  browser that had ever onboarded a node would treat every new fresh
  node as already-onboarded and skip the wizard, dumping the user
  straight at the inline set-password form. Backend is now authoritative;
  localStorage stays as the offline fallback.
- OnboardingWrapper.vue: skip the intro video on `/login` once
  `neode_onboarding_complete` is set. Returning logged-out users now
  get the static lock-screen background + glitch overlay instead of
  replaying the full intro on every logout.
- RootRedirect.vue: when the health check fails, only show the full
  BootScreen if the node was never onboarded. For already-onboarded
  nodes (i.e. an OTA-update blip), keep the spinner and poll the
  health endpoint every 2s for up to 60s before falling back to the
  boot screen. Fixes the "fake boot loader" / "server starting up"
  screens flashing on every successful update.
- loginTransition store: new `justCompletedOnboarding` flag distinct
  from `justLoggedIn`. Set true only by the inline setup-password
  flow (handleSetup). Dashboard.vue branches on it: full glitch+zoom
  reveal for the post-onboarding entry, quick zoom + welcome typing
  on every other login (no triple glitch flashes, ~1.2s vs 8s).
- vite.config.ts: bump assets cache from `assets-cache-v2` to
  `assets-cache-v3` so service workers running the previous bundle
  invalidate their cache and pick up the new UI cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 04:45:33 -04:00
DorianandClaude Opus 4.7 974fce5870 release(v1.7.32-alpha): fix frontend tarball layout + mDNS shutdown hang
- HOTFIX: v1.7.31-alpha's frontend tarball was packaged with a
  `neode-ui/` top-level directory instead of the flat layout v1.7.30
  and earlier used. Nodes that applied v1.7.31 ended up with
  `/opt/archipelago/web-ui/neode-ui/index.html` instead of
  `/opt/archipelago/web-ui/index.html`, and nginx returned 403/500.
  v1.7.32's tarball is built with `tar -C web/dist/neode-ui .` so
  files land directly at web-ui root. Broken nodes auto-heal on this
  update (web-ui dir is replaced).
- transport/lan.rs: add Drop impl that calls ServiceDaemon::shutdown()
  on the mdns_sd daemon. Without this the OS thread it spawns, plus
  the blocking `receiver.recv()` task, keep the tokio runtime alive
  past SIGTERM — long enough for systemd's TimeoutStopSec to SIGKILL
  the service and mark it Failed. Was visible on every update:
  "shut down cleanly" logged, then 15s later systemd forcibly kills.
- main.rs: after logging "Archipelago shut down cleanly", call
  `std::process::exit(0)` explicitly. Belt-and-suspenders against
  any future non-daemon thread creeping in (reqwest resolver pool,
  etc.) and causing the same SIGKILL regression.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 03:52:22 -04:00
DorianandClaude Opus 4.7 682b93f2d6 release(v1.7.31-alpha): idempotent IndeedHub install + auto-merge default mirrors/registries + 3rd OVH update mirror
- Backend: install.rs registry reachability probe now strips the
  `host[:port]/namespace` suffix before appending `/v2/` (the Docker
  V2 API lives at the host root, not under the namespace) and accepts
  HTTP 405 in addition to 200/401 as "registry daemon alive". This
  fixes false "unreachable" reports on the Test button for Gitea and
  other registries that protect their /v2/ endpoint.
- Backend: stacks.rs install_indeedhub_stack now force-removes any
  leftover indeedhub-* containers and indeedhub-net before creating
  the stack. A partial install (or the old first-boot stub racing the
  installer) used to leave containers around that blocked re-install
  with "name already in use". Re-running the App Store install now
  self-heals.
- Backend: registry.rs load_registries auto-merges any default
  registry URLs missing from the saved config (appended with priority
  max+10+i, persisted). Lets new default mirrors (e.g. Server 3 OVH)
  roll out to existing nodes without manual config edits. Explicit
  removals still stick — URLs absent from disk AND absent from
  defaults stay gone.
- Backend: update.rs adds DEFAULT_TERTIARY_MIRROR_URL at
  http://146.59.87.168:3000/ (Server 3 OVH) to default_mirrors, with
  the same auto-merge-on-load behavior as registries. Test updated
  for 3-mirror default (.160, tx1138, .168).
- Scripts: dropped the first-boot IndeedHub stub (~38 lines in
  first-boot-containers.sh §8b). It predated the proper stack
  installer, raced it, and was the main source of the name-conflict
  mess the stacks.rs cleanup above now also guards against.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 03:26:09 -04:00
DorianandClaude Opus 4.7 18f0929614 release(v1.7.30-alpha): live install/uninstall progress + cleaner pull waterfall
- Backend: unified pull-progress streaming across primary AND fallback
  registries. Earlier code only streamed for the primary attempt; if it
  failed fast (VPS 404, etc.) the UI froze at 0% until the fallback
  finished. The waterfall now uses a single shared helper that streams
  podman stderr through update_install_progress for every URL tried.
- Backend: PackageDataEntry gains uninstall_stage, set at each phase of
  handle_package_uninstall ("Stopping containers (i/total)",
  "Cleaning up volumes", "Removing app data"). State flips to Removing
  during the pipeline.
- Frontend: MarketplaceAppCard renders the live progress bar with byte
  counts during installs, matching the System Update download bar style.
- Frontend: AppCard renders the live uninstall stage label per app.
  Modal closes immediately on confirm so concurrent uninstalls each
  show their own progress on their own card.
- Cleanup: removed dead helpers (image_candidates, rewrite_for_primary,
  primary_image_url, pull_from_registries_with_skip) made unused by
  the install.rs refactor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 19:11:36 -04:00
DorianandClaude Opus 4.7 1709149ebd release(v1.7.29-alpha): VPS as default app registry + settings UI
- New Settings → App registries page (/dashboard/settings/registries)
  that mirrors the update-mirrors experience: list of configured
  registries, test reachability, set primary, add/remove. New
  registry.set-primary RPC; existing registry.{list,add,remove,test}
  reused.
- Default RegistryConfig flipped: VPS (23.182.128.160:3000/lfg2025) is
  now Server 1 (primary), tx1138 is Server 2 (fallback).
- Install pipeline now rewrites the first pull to the primary registry
  URL before attempting it. Before this, installs always hit whichever
  registry the image was hardcoded to, so changing the primary didn't
  actually affect where images came from. On failure, the existing
  fallback walk skips the primary (already tried) and walks the rest.
- App catalog proxy UPSTREAMS order flipped so the catalog follows the
  same VPS-first rule.
- Reboot overlay: animated "a" logo now sits in the center of the ring
  (matches the screensaver composition). Extracted the logo-wrapper
  pattern inline.

7/7 registry tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 15:54:07 -04:00
DorianandClaude Opus 4.7 2664074210 release(v1.7.28-alpha): reboot progress overlay + VPS default primary
- New reboot progress overlay: full-screen black with the screensaver's
  pulsing ring, rebooting → reconnecting → back-online → stalled stages,
  elapsed counter, auto-reload on health-check success, manual reload
  button at 3 min stall. Mirrors the existing update overlay.
- Ring extracted from Screensaver.vue into a reusable ScreensaverRing
  component so the reboot overlay reuses the same animation.
- default_mirrors() now puts the VPS as Server 1 (primary) and tx1138 as
  Server 2 — new nodes fetch manifests from VPS first; existing nodes
  keep whatever mirror order they've customized.
- What's New entry prepended for v1.7.28-alpha.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 15:06:37 -04:00
DorianandClaude Opus 4.7 9868991900 release(v1.7.27-alpha): mirror transparency — served-by line + one-click test button
- New "Served by {mirror}" line on the System Update page so operators can see
  which mirror actually served the available manifest (vs. which is configured
  primary). Backend threads the served URL through UpdateState.manifest_mirror.
- New update.test-mirror RPC + per-row lightning-bolt button that pings a
  mirror and renders reachable/latency or error inline under the URL.
- UI polish on the mirrors section: Set Primary, Remove, and the new Test
  action are compact icon buttons; add-mirror form moved into a dialog.
- "What's New" block prepended for v1.7.27-alpha.

21/21 update module tests pass. vue-tsc + vite build clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 13:05:42 -04:00
DorianandClaude Opus 4.7 0d15ca588a release(v1.7.26-alpha): mirror list + origin-relative download URLs
Adds a multi-mirror manifest fetch. `check_for_updates` walks a
configurable list (data_dir/update-mirrors.json) in priority order
and falls through to the next mirror on any HTTP / parse / timeout
failure. Two defaults bake in: Server 1 (git.tx1138.com) and Server 2
(23.182.128.160:3000).

Critical fix: after parsing a manifest, rewrite every component's
`download_url` so its origin matches the manifest URL we fetched.
Before this, the manifest hard-coded absolute URLs pointing at one
specific server — so even when a node fetched the manifest from a
faster mirror, the actual 200MB download went back to the slow
original. Now the faster mirror wins end-to-end.

New RPCs: update.list-mirrors, update.add-mirror, update.remove-mirror,
update.set-primary-mirror. New UI section on the System Update page
for operator management. 5 new unit tests for origin parsing and
manifest rewriting (21/21 green).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 10:09:28 -04:00
DorianandClaude Opus 4.7 1c1416cc1a release(v1.7.25-alpha): TCP transport for public FIPS mesh + modal cleanup
Re-adds the TCP transport (`0.0.0.0:8443`) to the rendered fips.yaml
alongside UDP. Upstream factory default enables both; we had
inadvertently narrowed to UDP-only when the yaml rewriter was last
touched, which left nodes unable to reach fips.v0l.io (the public
anchor only answers on TCP right now) or talk across networks that
block UDP.

Backend startup now compares the installed yaml against the current
rendered schema and restarts whichever fips unit is active when they
differ — so OTA-upgrading nodes pick up the new transport without
anyone having to click Reconnect.

Dropped the earlier plan to auto-add federated peers as seed anchors:
invites don't carry a FIPS-reachable IP:port, and once TCP reconnects
the public mesh, federated peers become npub-routable without needing
a seed entry.

Seed Anchors modal cleanup: replaced malformed header icon with a
three-arc broadcast glyph, and the close button now matches the
What's New modal (embedded in the card header, same icon + hover
style) instead of the earlier floating off-design placeholder.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 09:25:53 -04:00
484 changed files with 54415 additions and 11805 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
+23
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/
@@ -73,3 +78,21 @@ loop/loop.log.bak
# Separate repos nested in tree
web/
._*
# Resilience harness reports (generated, contains session cookies)
scripts/resilience/reports/
# Codex / pnpm / python caches / editor backups
.codex
.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
}
}
+332
View File
@@ -1,5 +1,337 @@
# Changelog
## v1.7.85-alpha (2026-06-12)
- ElectrumX now runs with less cache pressure and more memory headroom, reducing the restart loop seen during sync catch-up.
- Portainer is pinned to `2.19.4` instead of `latest`, avoiding schema-drift restarts from surprise image updates.
- LND receive-address creation now asks for a native SegWit address and returns clearer wallet/readiness failures when an address is not available.
- Fleet telemetry now carries server name, hostname, and server URL, and the Fleet dashboard shows those names instead of hashed node ids.
- Trusted federation peers are still auto-added transitively, but the local node no longer imports itself back into the fleet list.
- Validation passed locally for the touched frontend helpers, `git diff --check`, and Rust formatting.
## 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.
- Kiosk mode now includes a browser safe-area path for HDMI displays that crop edges, and self-update refreshes kiosk launcher/systemd files so display fixes ship to existing nodes. The experimental X11 scaling safe-area is opt-in to avoid stretching TV output.
- Wi-Fi setup now reports scan errors instead of showing an empty network list, supports retrying scans from the modal, parses escaped `nmcli` SSIDs correctly, and can join open networks without forcing a WPA password.
- Bitcoin Core now matches Bitcoin Knots for restricted relay RPC support, including the txrelay secret injection and transaction broadcast whitelist.
- The restricted Bitcoin relay whitelist now includes `submitpackage` and `gettxout`, covering newer wallet/package-relay broadcast flows without opening wallet/admin RPC.
- 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.
- Multi-container apps show real install progress. IndeedHub (7), BTCPay (4), Mempool (3), Immich (3) — bar advances through Preparing → Pulling → Creating → Done instead of sitting at 0% until the very end.
- Apps no longer disappear from the dashboard mid-install. The container scanner now respects in-flight installs and updates instead of evicting an entry while its containers are still being created.
- IndeedHub installs cleanly on a fresh node. Five missing environment variables fixed; Nostr sign-in works on first install.
- Tailscale install no longer fails with "executable not found". Container command was a malformed shell string; now a proper command array.
- Removed three catalog entries that hung installs for ten minutes (dwn, endurain, ollama — no source images in our registries). Restored Nextcloud, sourced from docker.io.
- Bitcoin Core update path uses the correct image name (was pulling from a non-existent path).
- New ISO installs now allocate swap (sized to RAM, capped at 8GB, on the encrypted data partition). Without swap, container image builds and memory spikes were hitting OOM under load.
## v1.7.44-alpha (2026-04-28)
43de3b73 feat(orchestrator): complete container migration and release hardening
ce39430b feat(self-update): sync and rebuild UI containers on OTA
72dec5aa fix(lnd-ui): align container port across all specs
83aacdf2 chore(release): archive ISO build recipes, tarball-only releases
All notable changes to Archipelago will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
+1 -1
View File
@@ -122,7 +122,7 @@ echo ""
# Install custom app dependencies
echo "Installing custom app dependencies..."
for app in did-wallet endurain morphos-server router web5-dwn; do
for app in did-wallet endurain morphos-server router; do
if [ -d "apps/$app" ]; then
echo " - Installing $app dependencies..."
cd "apps/$app"
+12 -6
View File
@@ -20,8 +20,8 @@
- **Mempool** block explorer and fee estimator
- **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.
### Self-Hosted Apps (29)
Bitcoin, 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)
@@ -101,14 +101,20 @@ npm run build # Production build → web/dist/neode-ui/
./scripts/deploy-to-target.sh --both # Deploy to both LAN servers
```
### Build ISO
### Release (tarball-only)
Releases ship as a backend binary and a frontend tarball referenced by
`releases/manifest.json`. Nodes OTA-update via `scripts/self-update.sh`.
```bash
ssh archipelago@<server>
cd ~/archy/image-recipe
sudo ./build-auto-installer-iso.sh
./scripts/create-release.sh 1.2.3
git push gitea-local main --tags
git push gitea-vps2 main --tags
```
ISO builds are archived under `image-recipe/_archived/` and not part of the
release deliverable.
## Architecture
```
+449 -156
View File
@@ -1,7 +1,7 @@
{
"version": 2,
"updated": "2026-04-12T00:00:00Z",
"registry": "git.tx1138.com/lfg2025",
"updated": "2026-04-22T00:00:00Z",
"registry": "146.59.87.168:3000/lfg2025",
"featured": {
"id": "indeedhub",
"banner": "/assets/img/featured/indeedhub-banner.jpg",
@@ -11,232 +11,525 @@
},
"apps": [
{
"id": "bitcoin-knots", "title": "Bitcoin Knots", "version": "28.1.0",
"description": "Run a full Bitcoin node. Validate and relay blocks and transactions.",
"id": "bitcoin-knots",
"title": "Bitcoin Knots",
"version": "28.1.0",
"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", "tier": "core",
"dockerImage": "git.tx1138.com/lfg2025/bitcoin-knots:latest",
"author": "Bitcoin Knots",
"category": "money",
"tier": "core",
"dockerImage": "146.59.87.168:3000/lfg2025/bitcoin-knots:latest",
"repoUrl": "https://github.com/bitcoinknots/bitcoin"
},
{
"id": "lnd", "title": "LND", "version": "0.18.4",
"description": "Lightning Network Daemon. Fast Bitcoin payments through Lightning.",
"id": "bitcoin-core",
"title": "Bitcoin Core",
"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": "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 implementation by Lightning Labs. Enables instant, low-cost Bitcoin payments.",
"icon": "/assets/img/app-icons/lnd.svg",
"author": "Lightning Labs", "category": "money", "tier": "core",
"dockerImage": "git.tx1138.com/lfg2025/lnd:v0.18.4-beta",
"author": "Lightning Labs",
"category": "money",
"tier": "core",
"dockerImage": "146.59.87.168:3000/lfg2025/lnd:v0.18.4-beta",
"repoUrl": "https://github.com/lightningnetwork/lnd",
"requires": ["bitcoin-knots"]
"requires": [
"bitcoin-knots"
]
},
{
"id": "btcpay-server", "title": "BTCPay Server", "version": "1.13.7",
"description": "Self-hosted Bitcoin payment processor.",
"id": "btcpay-server",
"title": "BTCPay Server",
"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": "git.tx1138.com/lfg2025/btcpayserver:1.13.7",
"author": "BTCPay Server Foundation",
"category": "commerce",
"tier": "core",
"dockerImage": "docker.io/btcpayserver/btcpayserver:2.3.9",
"repoUrl": "https://github.com/btcpayserver/btcpayserver",
"requires": ["bitcoin-knots"]
"requires": [
"bitcoin-knots"
]
},
{
"id": "mempool", "title": "Mempool Explorer", "version": "3.0.0",
"description": "Self-hosted Bitcoin blockchain and mempool visualizer.",
"id": "mempool",
"title": "Mempool Explorer",
"version": "3.0.0",
"description": "Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.",
"icon": "/assets/img/app-icons/mempool.webp",
"author": "Mempool", "category": "money", "tier": "core",
"dockerImage": "git.tx1138.com/lfg2025/mempool-frontend:v3.0.0",
"author": "Mempool",
"category": "money",
"tier": "core",
"dockerImage": "146.59.87.168:3000/lfg2025/mempool-frontend:v3.0.0",
"repoUrl": "https://github.com/mempool/mempool",
"requires": ["bitcoin-knots", "electrumx"]
"requires": [
"bitcoin-knots",
"electrumx"
]
},
{
"id": "electrumx", "title": "ElectrumX", "version": "1.18.0",
"description": "Electrum protocol server. Index the blockchain for fast wallet lookups.",
"icon": "/assets/img/app-icons/electrumx.webp",
"author": "Luke Childs", "category": "money", "tier": "core",
"dockerImage": "git.tx1138.com/lfg2025/electrumx:v1.18.0",
"id": "electrumx",
"title": "ElectrumX",
"version": "1.18.0",
"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",
"dockerImage": "146.59.87.168:3000/lfg2025/electrumx:v1.18.0",
"repoUrl": "https://github.com/spesmilo/electrumx",
"requires": ["bitcoin-knots"]
"requires": [
"bitcoin-knots"
]
},
{
"id": "indeedhub", "title": "IndeeHub", "version": "1.0.0",
"description": "Bitcoin documentary streaming with Nostr identity.",
"id": "indeedhub",
"title": "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.",
"icon": "/assets/img/app-icons/indeedhub.png",
"author": "IndeeHub", "category": "community",
"dockerImage": "git.tx1138.com/lfg2025/indeedhub:1.0.0",
"author": "IndeeHub",
"category": "community",
"dockerImage": "146.59.87.168:3000/lfg2025/indeedhub:1.0.0",
"repoUrl": "https://github.com/indeedhub/indeedhub"
},
{
"id": "botfights", "title": "BotFights", "version": "1.1.0",
"description": "Bot arena + 2-player arcade fighter with controller support and Adventure Mode.",
"id": "botfights",
"title": "BotFights",
"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.",
"icon": "/assets/img/app-icons/botfights.svg",
"author": "BotFights", "category": "community",
"dockerImage": "git.tx1138.com/lfg2025/botfights:1.1.0",
"repoUrl": "https://botfights.net"
"author": "BotFights",
"category": "community",
"dockerImage": "146.59.87.168:3000/lfg2025/botfights:1.1.0",
"repoUrl": "https://botfights.net",
"containerConfig": {
"ports": [
"9100:9100"
],
"volumes": [
"/var/lib/archipelago/botfights:/app/server/data"
],
"env": [
"NODE_ENV=production",
"PORT=9100",
"FIGHT_LOOP_ENABLED=true",
"ARCHY_EMBEDDED=1"
]
}
},
{
"id": "gitea", "title": "Gitea", "version": "1.23",
"description": "Self-hosted Git service with container registry, CI/CD, issue tracking.",
"id": "gitea",
"title": "Gitea",
"version": "1.23",
"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",
"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.",
"id": "filebrowser",
"title": "File Browser",
"version": "2.27.0",
"description": "Baseline Archipelago file manager service.",
"icon": "/assets/img/app-icons/file-browser.webp",
"author": "File Browser", "category": "data", "tier": "core",
"author": "File Browser",
"category": "data",
"tier": "core",
"dockerImage": "git.tx1138.com/lfg2025/filebrowser:v2.27.0",
"repoUrl": "https://github.com/filebrowser/filebrowser"
"repoUrl": "https://github.com/filebrowser/filebrowser",
"containerConfig": {
"ports": [
"8083:80"
],
"volumes": [
"/var/lib/archipelago/filebrowser:/srv",
"/var/lib/archipelago/filebrowser-data:/data"
],
"args": [
"--database=/data/database.db",
"--root=/srv",
"--address=0.0.0.0",
"--port=80"
]
}
},
{
"id": "vaultwarden", "title": "Vaultwarden", "version": "1.30.0",
"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",
"title": "Vaultwarden",
"version": "1.30.0",
"description": "Self-hosted password vault with zero-knowledge encryption.",
"icon": "/assets/img/app-icons/vaultwarden.webp",
"author": "Vaultwarden", "category": "data", "tier": "recommended",
"dockerImage": "git.tx1138.com/lfg2025/vaultwarden:1.30.0-alpine",
"repoUrl": "https://github.com/dani-garcia/vaultwarden"
"author": "Vaultwarden",
"category": "data",
"tier": "recommended",
"dockerImage": "146.59.87.168:3000/lfg2025/vaultwarden:1.30.0-alpine",
"repoUrl": "https://github.com/dani-garcia/vaultwarden",
"containerConfig": {
"ports": [
"8082:80"
],
"volumes": [
"/var/lib/archipelago/vaultwarden:/data"
]
}
},
{
"id": "searxng", "title": "SearXNG", "version": "2024.1.0",
"description": "Privacy-respecting metasearch engine.",
"id": "searxng",
"title": "SearXNG",
"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": "git.tx1138.com/lfg2025/searxng:latest",
"repoUrl": "https://github.com/searxng/searxng"
"author": "SearXNG",
"category": "data",
"tier": "recommended",
"dockerImage": "146.59.87.168:3000/lfg2025/searxng:latest",
"repoUrl": "https://github.com/searxng/searxng",
"containerConfig": {
"ports": [
"8888:8080"
],
"volumes": [
"/var/lib/archipelago/searxng:/etc/searxng"
]
}
},
{
"id": "nostr-rs-relay", "title": "Nostr Relay", "version": "0.9.0",
"description": "Your own Nostr relay. Store events locally, relay for friends.",
"icon": "/assets/img/app-icons/nostr-rs-relay.svg",
"author": "scsiblade", "category": "nostr",
"dockerImage": "git.tx1138.com/lfg2025/nostr-rs-relay:0.9.0",
"repoUrl": "https://sr.ht/~gheartsfield/nostr-rs-relay/"
},
{
"id": "fedimint", "title": "Fedimint", "version": "0.10.0",
"description": "Federated Bitcoin mint with privacy through federated guardians.",
"id": "fedimint",
"title": "Fedimint",
"version": "0.10.0",
"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": "git.tx1138.com/lfg2025/fedimintd:v0.10.0",
"author": "Fedimint",
"category": "money",
"dockerImage": "146.59.87.168:3000/lfg2025/fedimintd:v0.10.0",
"repoUrl": "https://github.com/fedimint/fedimint"
},
{
"id": "ollama", "title": "Ollama", "version": "0.5.4",
"description": "Run AI models locally. Private and on your hardware.",
"icon": "/assets/img/app-icons/ollama.png",
"author": "Ollama", "category": "data",
"dockerImage": "git.tx1138.com/lfg2025/ollama:latest",
"repoUrl": "https://github.com/ollama/ollama"
"id": "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": "nextcloud", "title": "Nextcloud", "version": "28",
"description": "Your own private cloud. File sync, calendars, contacts.",
"icon": "/assets/img/app-icons/nextcloud.webp",
"author": "Nextcloud", "category": "data",
"dockerImage": "git.tx1138.com/lfg2025/nextcloud:28",
"repoUrl": "https://github.com/nextcloud/server"
},
{
"id": "jellyfin", "title": "Jellyfin", "version": "10.8.13",
"id": "jellyfin",
"title": "Jellyfin",
"version": "10.8.13",
"description": "Free media server. Stream movies, music, and photos.",
"icon": "/assets/img/app-icons/jellyfin.webp",
"author": "Jellyfin", "category": "data",
"dockerImage": "git.tx1138.com/lfg2025/jellyfin:10.8.13",
"repoUrl": "https://github.com/jellyfin/jellyfin"
"author": "Jellyfin",
"category": "data",
"dockerImage": "146.59.87.168:3000/lfg2025/jellyfin:10.8.13",
"repoUrl": "https://github.com/jellyfin/jellyfin",
"containerConfig": {
"ports": [
"8096:8096"
],
"volumes": [
"/var/lib/archipelago/jellyfin/config:/config",
"/var/lib/archipelago/jellyfin/cache:/cache"
]
}
},
{
"id": "immich", "title": "Immich", "version": "1.90.0",
"id": "immich",
"title": "Immich",
"version": "1.90.0",
"description": "High-performance photo and video backup with ML.",
"icon": "/assets/img/app-icons/immich.png",
"author": "Immich", "category": "data",
"dockerImage": "git.tx1138.com/lfg2025/immich-server:release",
"author": "Immich",
"category": "data",
"dockerImage": "146.59.87.168:3000/lfg2025/immich-server:release",
"repoUrl": "https://github.com/immich-app/immich"
},
{
"id": "homeassistant", "title": "Home Assistant", "version": "2024.1",
"description": "Open-source home automation.",
"id": "homeassistant",
"title": "Home Assistant",
"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": "git.tx1138.com/lfg2025/home-assistant:2024.1",
"repoUrl": "https://github.com/home-assistant/core"
"author": "Home Assistant",
"category": "home",
"dockerImage": "146.59.87.168:3000/lfg2025/home-assistant:2024.1",
"repoUrl": "https://github.com/home-assistant/core",
"containerConfig": {
"ports": [
"8123:8123"
],
"volumes": [
"/var/lib/archipelago/home-assistant:/config"
],
"env": [
"TZ=UTC"
]
}
},
{
"id": "grafana", "title": "Grafana", "version": "10.2.0",
"description": "Analytics and monitoring dashboards.",
"id": "grafana",
"title": "Grafana",
"version": "10.2.0",
"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": "git.tx1138.com/lfg2025/grafana:10.2.0",
"repoUrl": "https://github.com/grafana/grafana"
"author": "Grafana Labs",
"category": "data",
"tier": "recommended",
"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", "title": "Tailscale", "version": "1.78.0",
"id": "tailscale",
"title": "Tailscale",
"version": "1.78.0",
"description": "Zero-config VPN with WireGuard mesh networking.",
"icon": "/assets/img/app-icons/tailscale.webp",
"author": "Tailscale", "category": "networking", "tier": "recommended",
"dockerImage": "git.tx1138.com/lfg2025/tailscale:stable",
"repoUrl": "https://github.com/tailscale/tailscale"
"author": "Tailscale",
"category": "networking",
"tier": "recommended",
"dockerImage": "146.59.87.168:3000/lfg2025/tailscale:stable",
"repoUrl": "https://github.com/tailscale/tailscale",
"containerConfig": {
"ports": [
"8240:8240"
],
"volumes": [
"/var/lib/archipelago/tailscale:/var/lib/tailscale"
],
"env": [
"TS_STATE_DIR=/var/lib/tailscale"
],
"args": [
"sh",
"-c",
"tailscaled --tun=userspace-networking & 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": "uptime-kuma", "title": "Uptime Kuma", "version": "1.23.0",
"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:2.19.4",
"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",
"title": "Uptime Kuma",
"version": "1.23.0",
"description": "Self-hosted uptime monitoring.",
"icon": "/assets/img/app-icons/uptime-kuma.webp",
"author": "Uptime Kuma", "category": "data", "tier": "recommended",
"dockerImage": "git.tx1138.com/lfg2025/uptime-kuma:1",
"repoUrl": "https://github.com/louislam/uptime-kuma"
"author": "Uptime Kuma",
"category": "data",
"tier": "recommended",
"dockerImage": "146.59.87.168:3000/lfg2025/uptime-kuma:1",
"repoUrl": "https://github.com/louislam/uptime-kuma",
"containerConfig": {
"ports": [
"3002:3001"
],
"volumes": [
"/var/lib/archipelago/uptime-kuma:/app/data"
],
"env": [
"TZ=UTC"
],
"args": [
"--",
"node",
"server/server.js"
]
}
},
{
"id": "nostr-vpn", "title": "Nostr VPN", "version": "0.3.7",
"description": "Tailscale-style mesh VPN with Nostr control plane.",
"icon": "/assets/img/app-icons/nostr-vpn.svg",
"author": "Martti Malmi", "category": "networking",
"dockerImage": "git.tx1138.com/lfg2025/nostr-vpn:v0.3.7",
"repoUrl": "https://github.com/mmalmi/nostr-vpn"
},
{
"id": "fips", "title": "FIPS", "version": "0.1.0",
"description": "Free Internetworking Peering System. Encrypted mesh network.",
"icon": "/assets/img/app-icons/fips.svg",
"author": "Jim Corgan", "category": "networking",
"dockerImage": "git.tx1138.com/lfg2025/fips:v0.1.0",
"repoUrl": "https://github.com/jmcorgan/fips"
},
{
"id": "routstr", "title": "Routstr", "version": "0.4.3",
"description": "Decentralized AI inference proxy with Cashu ecash.",
"icon": "/assets/img/app-icons/routstr.svg",
"author": "Routstr", "category": "community",
"dockerImage": "git.tx1138.com/lfg2025/routstr:v0.4.3",
"repoUrl": "https://github.com/routstr/routstr-core"
},
{
"id": "dwn", "title": "Decentralized Web Node", "version": "0.4.0",
"description": "Own your data with DID-based access control.",
"icon": "/assets/img/app-icons/dwn.svg",
"author": "TBD", "category": "data",
"dockerImage": "git.tx1138.com/lfg2025/dwn-server:main",
"repoUrl": "https://github.com/TBD54566975/dwn-server"
},
{
"id": "endurain", "title": "Endurain", "version": "0.8.0",
"description": "Self-hosted fitness tracking. Strava alternative.",
"icon": "/assets/img/app-icons/endurain.png",
"author": "Endurain", "category": "data",
"dockerImage": "git.tx1138.com/lfg2025/endurain:0.8.0",
"repoUrl": "https://github.com/joaovitoriasilva/endurain"
},
{
"id": "penpot", "title": "Penpot", "version": "2.4",
"description": "Open-source design platform. Self-hosted Figma alternative.",
"icon": "/assets/img/app-icons/penpot.webp",
"author": "Penpot", "category": "data",
"dockerImage": "git.tx1138.com/lfg2025/penpot-frontend:2.4",
"repoUrl": "https://github.com/penpot/penpot"
},
{
"id": "photoprism", "title": "PhotoPrism", "version": "240915",
"id": "photoprism",
"title": "PhotoPrism",
"version": "240915",
"description": "AI-powered photo management with facial recognition.",
"icon": "/assets/img/app-icons/photoprism.svg",
"author": "PhotoPrism", "category": "data",
"dockerImage": "git.tx1138.com/lfg2025/photoprism:240915",
"repoUrl": "https://github.com/photoprism/photoprism"
"author": "PhotoPrism",
"category": "data",
"dockerImage": "146.59.87.168:3000/lfg2025/photoprism:240915",
"repoUrl": "https://github.com/photoprism/photoprism",
"containerConfig": {
"ports": [
"2342:2342"
],
"volumes": [
"/var/lib/archipelago/photoprism:/photoprism/storage"
],
"env": [
"PHOTOPRISM_ADMIN_PASSWORD=archipelago",
"PHOTOPRISM_DEFAULT_LOCALE=en"
]
}
},
{
"id": "nextcloud",
"title": "Nextcloud",
"version": "29",
"description": "Your own private cloud. File sync, calendars, contacts.",
"icon": "/assets/img/app-icons/nextcloud.webp",
"author": "Nextcloud",
"category": "data",
"dockerImage": "146.59.87.168:3000/lfg2025/nextcloud:29",
"repoUrl": "https://github.com/nextcloud/server",
"containerConfig": {
"ports": [
"8085:80"
],
"volumes": [
"/var/lib/archipelago/nextcloud:/var/www/html"
]
}
}
]
}
+1 -3
View File
@@ -8,7 +8,6 @@
| bitcoin-knots | 8332 (RPC), 8333 (P2P) | v28.1 |
| lnd | 9735 (P2P), 10009 (gRPC), 8080 (REST) | v0.17.4-beta |
| btcpay-server | 23000 (HTTP) | v1.13.5 |
| thunderhub | 3010 (HTTP) | v0.13.31 |
| mempool | 4080 (HTTP) | v2.5.0 |
| electrumx | 50001 (TCP), 50002 (SSL) | latest |
| fedimint | 8173 (API), 8174 (Web) | v0.10.0 |
@@ -33,7 +32,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
@@ -44,7 +42,7 @@ cd apps
./build.sh <app-id> # Build specific app
```
Custom apps with local source: `router`, `did-wallet`, `web5-dwn`. All other apps use official container images.
Custom apps with local source: `router`, `did-wallet`. All other apps use official container images.
## App Structure
+1 -5
View File
@@ -17,15 +17,13 @@ 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 |
| did-wallet | 8083 | TCP | Web UI | 18083 |
| router | 8084, 5353, 1900 | TCP/UDP | Web UI, mDNS, SSDP | 18084, 15353, 11900 |
| web5-dwn | 3000 | TCP | HTTP API | 13000 |
| meshtastic | 4403, 1883 | TCP | HTTP API, MQTT | 14403, 11883 |
## Development Ports (Offset: +10000)
@@ -47,7 +45,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 |
@@ -55,7 +52,6 @@ In development mode, all ports are offset by 10000 to avoid conflicts with produ
| Strfry | http://localhost:18082 |
| DID Wallet | http://localhost:18083 |
| Router | http://localhost:18084 |
| Web5 DWN | http://localhost:13000 |
| Meshtastic | http://localhost:14403 |
## Port Conflict Resolution
+2 -4
View File
@@ -30,14 +30,13 @@ cd apps
./build.sh
```
This will build all apps that have Dockerfiles. Standard apps (bitcoin-core, lnd, etc.) will use their official images, while custom apps (router, did-wallet, web5-dwn) will be built from source.
This will build all apps that have Dockerfiles. Standard apps (bitcoin-core, lnd, etc.) will use their official images, while custom apps (router, did-wallet) will be built from source.
### Build Specific App
```bash
./build.sh router
./build.sh did-wallet
./build.sh web5-dwn
```
## Running Apps via Archipelago
@@ -64,7 +63,6 @@ In development mode, apps are accessible on offset ports:
- **Router**: http://localhost:18084
- **DID Wallet**: http://localhost:18083
- **Web5 DWN**: http://localhost:13000
- **Nostr RS Relay**: http://localhost:18081
- **Strfry**: http://localhost:18082
@@ -72,7 +70,7 @@ See [PORTS.md](./PORTS.md) for complete port mapping.
## Development Workflow
### For Custom Apps (router, did-wallet, web5-dwn)
### For Custom Apps (router, did-wallet)
1. **Make changes** to source code in `apps/<app-id>/src/`
2. **Rebuild** the container:
+1 -3
View File
@@ -8,7 +8,6 @@ Containerized applications for the Archipelago Bitcoin Node OS. All apps run in
- **bitcoin-knots** — Full Bitcoin node (v28.1)
- **lnd** — Lightning Network Daemon (v0.17.4-beta)
- **btcpay-server** — Payment processor (v1.13.5)
- **thunderhub** — Lightning management UI (v0.13.31)
- **mempool** — Block explorer and fee estimator (v2.5.0)
- **electrumx** — Electrum server
- **fedimint** — Federated Bitcoin minting (v0.10.0)
@@ -18,12 +17,11 @@ Containerized applications for the Archipelago Bitcoin Node OS. All apps run in
- **nostrudel** — Nostr web client (v0.40.0)
### Web5 & Identity
- **web5-dwn** — Decentralized Web Node (v0.4.0)
- **did-wallet** — Web5 DID Wallet
### 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)
+49
View File
@@ -0,0 +1,49 @@
app:
id: archy-btcpay-db
name: BTCPay Postgres
version: 15.17
description: Postgres backend for BTCPay and NBXplorer.
container:
image: git.tx1138.com/lfg2025/postgres:15.17
pull_policy: if-not-present
network: archy-net
data_uid: "100998:100998"
secret_env:
- key: POSTGRES_PASSWORD
secret_file: btcpay-db-password
dependencies:
- storage: 20Gi
resources:
memory_limit: 1Gi
disk_limit: 20Gi
security:
capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE]
readonly_root: false
network_policy: isolated
ports: []
volumes:
- type: bind
source: /var/lib/archipelago/postgres-btcpay
target: /var/lib/postgresql/data
options: [rw]
environment:
- POSTGRES_DB=btcpay
- POSTGRES_USER=btcpay
health_check:
type: tcp
endpoint: localhost:5432
interval: 30s
timeout: 5s
retries: 3
bitcoin_integration:
rpc_access: none
sync_required: false
+51
View File
@@ -0,0 +1,51 @@
app:
id: archy-mempool-db
name: Mempool MariaDB
version: 11.4.10
description: MariaDB backend for the mempool explorer stack.
container:
image: git.tx1138.com/lfg2025/mariadb:11.4.10
pull_policy: if-not-present
network: archy-net
data_uid: "100998:100998"
secret_env:
- key: MYSQL_PASSWORD
secret_file: mempool-db-password
- key: MYSQL_ROOT_PASSWORD
secret_file: mysql-root-db-password
dependencies:
- storage: 20Gi
resources:
memory_limit: 512Mi
disk_limit: 20Gi
security:
capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE]
readonly_root: false
network_policy: isolated
ports: []
volumes:
- type: bind
source: /var/lib/archipelago/mysql-mempool
target: /var/lib/mysql
options: [rw]
environment:
- MYSQL_DATABASE=mempool
- MYSQL_USER=mempool
health_check:
type: tcp
endpoint: localhost:3306
interval: 30s
timeout: 5s
retries: 3
bitcoin_integration:
rpc_access: none
sync_required: false
+44
View File
@@ -0,0 +1,44 @@
app:
id: archy-mempool-web
name: Mempool Web
version: 3.0.0
description: Frontend web UI for mempool explorer.
container_name: mempool
container:
image: git.tx1138.com/lfg2025/mempool-frontend:v3.0.0
pull_policy: if-not-present
network: archy-net
dependencies:
- app_id: mempool-api
version: ">=3.0.0"
resources:
memory_limit: 512Mi
security:
capabilities: []
readonly_root: false
network_policy: isolated
ports:
- host: 4080
container: 8080
protocol: tcp
environment:
- FRONTEND_HTTP_PORT=8080
- BACKEND_MAINNET_HTTP_HOST=mempool-api
health_check:
type: http
endpoint: http://localhost:8080
path: /
interval: 30s
timeout: 5s
retries: 3
bitcoin_integration:
rpc_access: none
sync_required: false
+64
View File
@@ -0,0 +1,64 @@
app:
id: archy-nbxplorer
name: NBXplorer
version: 2.6.0
description: BTCPay blockchain indexer service.
container:
image: git.tx1138.com/lfg2025/nbxplorer:2.6.0
pull_policy: if-not-present
network: archy-net
secret_env:
- key: NBXPLORER_BTCRPCPASSWORD
secret_file: bitcoin-rpc-password
- key: BTCPAY_DB_PASS
secret_file: btcpay-db-password
dependencies:
- app_id: bitcoin-core
version: ">=26.0"
- app_id: archy-btcpay-db
version: ">=15.17"
resources:
memory_limit: 2Gi
disk_limit: 20Gi
security:
capabilities: []
readonly_root: false
network_policy: isolated
ports:
- host: 32838
container: 32838
protocol: tcp
volumes:
- type: bind
source: /var/lib/archipelago/nbxplorer
target: /data
options: [rw]
environment:
- NBXPLORER_DATADIR=/data
- NBXPLORER_NETWORK=mainnet
- NBXPLORER_CHAINS=btc
- NBXPLORER_BIND=0.0.0.0:32838
- NBXPLORER_BTCRPCURL=http://bitcoin-knots:8332
- NBXPLORER_BTCRPCUSER=archipelago
- NBXPLORER_BTCNODEENDPOINT=bitcoin-knots:8333
- NBXPLORER_NOAUTH=1
- NBXPLORER_POSTGRES=Username=btcpay;Password=${BTCPAY_DB_PASS};Host=archy-btcpay-db;Port=5432;Database=nbxplorer
health_check:
type: http
endpoint: http://localhost:32838
path: /
interval: 30s
timeout: 30s
retries: 5
bitcoin_integration:
rpc_access: read-only
sync_required: true
+66 -33
View File
@@ -1,61 +1,94 @@
app:
id: bitcoin-core
name: Bitcoin Core
version: 24.0.0
description: Full Bitcoin node implementation. The reference implementation of the Bitcoin protocol.
version: 28.4.0
description: Reference Bitcoin Core node with dynamic prune/full-mode startup based on host disk.
container_name: bitcoin-core
container:
image: bitcoin/bitcoin:24.0
image_signature: cosign://...
pull_policy: verify-signature
image: 146.59.87.168:3000/lfg2025/bitcoin:28.4
pull_policy: if-not-present
network: archy-net
entrypoint: ["sh", "-lc"]
custom_args:
# Sync-speed flags: -par=0 uses every core (was capped at 2 by
# --cpus=2, now removed for bitcoin/electrumx). -dbcache sized to
# the IBD sweet spot - 4GB on full nodes, 1GB on pruned. Container
# --memory=8g (config.rs::get_memory_limit) leaves headroom for
# mempool + connections.
- >-
BITCOIND="$(command -v bitcoind || true)";
if [ -z "$BITCOIND" ]; then
BITCOIND="$(find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1)";
fi;
if [ -z "$BITCOIND" ]; then
echo "bitcoind not found in image" >&2;
exit 127;
fi;
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,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
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" -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 # Minimum disk space for mainnet
- storage: 500Gi
resources:
cpu_limit: 2
memory_limit: 2Gi
cpu_limit: 0
memory_limit: 4Gi
disk_limit: 500Gi
security:
capabilities: [] # No special capabilities needed
readonly_root: true
no_new_privileges: true
user: 1000
seccomp_profile: default
capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE]
readonly_root: false
network_policy: isolated
apparmor_profile: bitcoin-core
ports:
- host: 8332
container: 8332
protocol: tcp # RPC
protocol: tcp
- host: 8333
container: 8333
protocol: tcp # P2P
protocol: tcp
volumes:
- type: bind
source: /var/lib/archipelago/bitcoin
target: /home/bitcoin/.bitcoin
options: [rw]
environment:
- NETWORK=mainnet
- RPC_USER=${BITCOIN_RPC_USER}
- RPC_PASSWORD=${BITCOIN_RPC_PASSWORD}
- PRUNE=0 # Full node (set to 550 for pruned)
- BITCOIN_RPC_USER=archipelago
health_check:
type: http
endpoint: http://localhost:8332
path: /
type: tcp
endpoint: localhost:8332
interval: 30s
timeout: 5s
retries: 3
bitcoin_integration:
rpc_access: admin
sync_required: true
testnet_support: true
testnet_support: false
pruning_support: true
+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,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
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
+56
View File
@@ -0,0 +1,56 @@
app:
id: bitcoin-ui
name: Bitcoin UI
version: 1.0.0
description: |
Archipelago-native HTTP proxy + static site for interacting with the
Bitcoin Core / Bitcoin Knots JSON-RPC. Runs nginx inside a container
and reverse-proxies /bitcoin-rpc/ to 127.0.0.1:8332 on the host. The
upstream Authorization header is substituted from
/var/lib/archipelago/secrets/bitcoin-rpc-password by the prod
orchestrator's pre-start hook, rendered into an nginx.conf that is
bind-mounted read-only at container start.
container:
build:
context: /opt/archipelago/docker/bitcoin-ui
dockerfile: Dockerfile
tag: localhost/bitcoin-ui:local
dependencies:
- app_id: bitcoin-core
resources:
memory_limit: 128Mi
security:
readonly_root: false
network_policy: host
# Host networking: nginx listens on 8334 directly on the host IP, and
# proxies to 127.0.0.1:8332 which is where the bitcoin backend binds
# its RPC. `ports:` is intentionally empty because host networking
# bypasses port mapping.
ports: []
volumes:
# Bind-mount the rendered nginx.conf read-only. The prod orchestrator
# renders /var/lib/archipelago/bitcoin-ui/nginx.conf on every install
# and every reconcile pass, substituting the base64 RPC auth from
# the plaintext password secret. If the rendered bytes change (the
# password rotated, or the template was updated by OTA), the
# reconciler restarts this container so nginx re-reads the config.
- type: bind
source: /var/lib/archipelago/bitcoin-ui/nginx.conf
target: /etc/nginx/conf.d/default.conf
options: [ro]
environment: []
health_check:
type: http
endpoint: http://127.0.0.1:8334
path: /
interval: 30s
timeout: 5s
retries: 3
+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
+55 -36
View File
@@ -1,66 +1,85 @@
app:
id: btcpay-server
name: BTCPay Server
version: 1.12.0
version: 2.3.9
description: Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries.
container:
image: btcpayserver/btcpayserver:1.12.0
image_signature: cosign://...
pull_policy: verify-signature
image: docker.io/btcpayserver/btcpayserver:2.3.9
pull_policy: if-not-present
network: archy-net
secret_env:
- key: BTCPAY_BTCRPCPASSWORD
secret_file: bitcoin-rpc-password
- key: BTCPAY_DB_PASS
secret_file: btcpay-db-password
derived_env:
- key: BTCPAY_HOST
template: "{{HOST_IP}}:23000"
dependencies:
- app_id: bitcoin-core
version: ">=26.0"
- app_id: lnd
version: ">=0.18.0"
- app_id: archy-btcpay-db
version: ">=15.17"
- app_id: archy-nbxplorer
version: ">=2.6.0"
resources:
cpu_limit: 2
memory_limit: 2Gi
disk_limit: 20Gi
security:
capabilities: [NET_BIND_SERVICE]
readonly_root: true
no_new_privileges: true
user: 1000
seccomp_profile: default
capabilities: []
readonly_root: false
network_policy: isolated
apparmor_profile: btcpay
ports:
- host: 80
container: 80
- host: 23000
container: 49392
protocol: tcp
- host: 443
container: 443
protocol: tcp
volumes:
- type: bind
source: /var/lib/archipelago/btcpay
target: /datadir
options: [rw]
environment:
- BTCPAY_NETWORK=mainnet
- BTCPAY_CHAIN=btc
- BTCPAY_BTCEXPLORERURL=http://bitcoin-core:8332
- BTCPAY_LIGHTNING=type=lnd-rest;server=http://lnd:8080;allowinsecure=true
- ASPNETCORE_URLS=http://0.0.0.0:49392
- BTCPAY_PROTOCOL=http
- BTCPAY_CHAINS=btc
- BTCPAY_BTCEXPLORERURL=http://archy-nbxplorer:32838
- BTCPAY_BTCRPCURL=http://bitcoin-knots:8332
- BTCPAY_BTCRPCUSER=archipelago
- BTCPAY_POSTGRES=Username=btcpay;Password=${BTCPAY_DB_PASS};Host=archy-btcpay-db;Port=5432;Database=btcpay
health_check:
type: http
endpoint: http://localhost
path: /health
endpoint: http://localhost:49392
path: /
interval: 30s
timeout: 5s
retries: 3
timeout: 30s
retries: 5
bitcoin_integration:
rpc_access: read-only
sync_required: true
lightning_integration:
payment_processing: true
payment_processing: false
invoice_management: true
interfaces:
main:
name: Web UI
description: BTCPay Server dashboard
type: ui
port: 23000
protocol: http
path: /
metadata:
launch:
open_in_new_tab: true
-3
View File
@@ -10,8 +10,6 @@ app:
pull_policy: if-not-present
dependencies:
- app_id: web5-dwn
version: ">=1.0.0"
- storage: 2Gi
resources:
@@ -40,7 +38,6 @@ app:
options: [rw]
environment:
- DWN_ENDPOINT=http://web5-dwn:3000
- WALLET_STORAGE=/app/wallet
health_check:
-1
View File
@@ -34,5 +34,4 @@ app.post('/api/wallet/did/create', async (req, res) => {
// Start server
app.listen(port, '0.0.0.0', () => {
console.log(`DID Wallet listening on port ${port}`);
console.log(`DWN endpoint: ${process.env.DWN_ENDPOINT || 'http://web5-dwn:3000'}`);
});
+38
View File
@@ -0,0 +1,38 @@
app:
id: electrs-ui
name: Electrs UI
version: 1.0.0
description: |
Archipelago-native HTTP frontend for electrs/electrumx status. Runs
nginx inside a container, serves static assets, and proxies
/electrs-status to the archipelago backend on 127.0.0.1:5678.
container:
build:
context: /opt/archipelago/docker/electrs-ui
dockerfile: Dockerfile
tag: localhost/electrs-ui:local
dependencies: []
resources:
memory_limit: 64Mi
security:
readonly_root: false
network_policy: host
# Host networking: nginx listens on 50002 directly on the host IP.
ports: []
volumes: []
environment: []
health_check:
type: http
endpoint: http://127.0.0.1:50002
path: /
interval: 30s
timeout: 5s
retries: 3
+64
View File
@@ -0,0 +1,64 @@
app:
id: electrumx
name: ElectrumX
version: 1.18.0
description: Electrum server indexing Bitcoin chain data for lightweight wallet queries.
container:
image: 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:$(printenv BITCOIN_RPC_PASS)@bitcoin-knots:8332/";
exec electrumx_server
secret_env:
- key: BITCOIN_RPC_PASS
secret_file: bitcoin-rpc-password
dependencies:
- app_id: bitcoin-knots
version: ">=26.0"
- storage: 50Gi
resources:
cpu_limit: 0
memory_limit: 6Gi
disk_limit: 50Gi
security:
capabilities: [DAC_OVERRIDE]
readonly_root: false
network_policy: isolated
ports:
- host: 50001
container: 50001
protocol: tcp
volumes:
- type: bind
source: /var/lib/archipelago/electrumx
target: /data
options: [rw]
environment:
- COIN=Bitcoin
- DB_DIRECTORY=/data
- SERVICES=tcp://:50001,rpc://0.0.0.0:8000
- CACHE_MB=1024
- MAX_SEND=10000000
health_check:
type: tcp
endpoint: localhost:50001
interval: 30s
timeout: 5s
retries: 3
bitcoin_integration:
rpc_access: read-only
sync_required: true
pruning_support: false
-6
View File
@@ -1,6 +0,0 @@
node_modules
dist
*.log
.git
.gitignore
README.md
-37
View File
@@ -1,37 +0,0 @@
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
RUN npm ci --only=production
# Copy source code
COPY . .
# Build the application
RUN npm run build
# Production stage
FROM node:20-alpine
WORKDIR /app
# Copy built application
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
# Create non-root user
RUN addgroup -g 1000 appuser && \
adduser -D -u 1000 -G appuser appuser && \
mkdir -p /app/data && \
chown -R appuser:appuser /app
USER appuser
EXPOSE 8080
ENV ENDURAIN_DATA_DIR=/app/data
CMD ["node", "dist/index.js"]
-50
View File
@@ -1,50 +0,0 @@
app:
id: endurain
name: Endurain
version: 1.0.0
description: Endurain application platform. Custom application runtime.
container:
image: archipelago/endurain:1.0.0
image_signature: cosign://...
pull_policy: if-not-present
dependencies:
- storage: 2Gi
resources:
cpu_limit: 2
memory_limit: 1Gi
disk_limit: 2Gi
security:
capabilities: []
readonly_root: true
no_new_privileges: true
user: 1000
seccomp_profile: default
network_policy: isolated
apparmor_profile: endurain
ports:
- host: 8085
container: 8080
protocol: tcp # Web UI
volumes:
- type: bind
source: /var/lib/archipelago/endurain
target: /app/data
options: [rw]
environment:
- ENDURAIN_ENV=production
- ENDURAIN_DATA_DIR=/app/data
health_check:
type: http
endpoint: http://localhost:8085
path: /health
interval: 30s
timeout: 5s
retries: 3
-1161
View File
File diff suppressed because it is too large Load Diff
-20
View File
@@ -1,20 +0,0 @@
{
"name": "endurain",
"version": "1.0.0",
"description": "Endurain application platform",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "ts-node src/index.ts"
},
"dependencies": {
"express": "^4.18.2"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^20.10.0",
"typescript": "^5.3.3",
"ts-node": "^10.9.2"
}
}
-27
View File
@@ -1,27 +0,0 @@
import express from 'express';
const app = express();
const port = 8080;
// Middleware
app.use(express.json());
// Health check endpoint
app.get('/health', (req, res) => {
res.json({ status: 'ok', service: 'endurain', version: '1.0.0' });
});
// API endpoints
app.get('/api/info', (req, res) => {
res.json({
name: 'Endurain',
version: '1.0.0',
status: 'running'
});
});
// Start server
app.listen(port, '0.0.0.0', () => {
console.log(`Endurain listening on port ${port}`);
console.log(`Data directory: ${process.env.ENDURAIN_DATA_DIR || '/app/data'}`);
});
-16
View File
@@ -1,16 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
+73
View File
@@ -0,0 +1,73 @@
app:
id: fedimint-gateway
name: Fedimint Gateway
version: 0.10.0
description: Fedimint gateway service with automatic LND-or-LDK backend selection.
container:
image: git.tx1138.com/lfg2025/gatewayd:v0.10.0
pull_policy: if-not-present
network: archy-net
entrypoint: ["sh", "-lc"]
custom_args:
- >-
if [ -f /lnd/tls.cert ] && [ -f /lnd/data/chain/bitcoin/mainnet/admin.macaroon ]; then
exec gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash "$FEDI_HASH" --network bitcoin --bitcoind-url http://host.archipelago:8332 --bitcoind-username "$FM_BITCOIND_USERNAME" --bitcoind-password "$FM_BITCOIND_PASSWORD" lnd --lnd-rpc-host lnd:10009 --lnd-tls-cert /lnd/tls.cert --lnd-macaroon /lnd/data/chain/bitcoin/mainnet/admin.macaroon;
else
exec gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash "$FEDI_HASH" --network bitcoin --bitcoind-url http://host.archipelago:8332 --bitcoind-username "$FM_BITCOIND_USERNAME" --bitcoind-password "$FM_BITCOIND_PASSWORD" ldk --ldk-lightning-port 9737 --ldk-alias archipelago-gateway;
fi
secret_env:
- key: FM_BITCOIND_PASSWORD
secret_file: bitcoin-rpc-password
- key: FEDI_HASH
secret_file: fedimint-gateway-hash
data_uid: "1000:1000"
dependencies:
- app_id: bitcoin-core
version: ">=26.0"
- app_id: fedimint
version: ">=0.10.0"
resources:
cpu_limit: 2
memory_limit: 2Gi
disk_limit: 10Gi
security:
capabilities: []
readonly_root: true
network_policy: isolated
ports:
- host: 8176
container: 8176
protocol: tcp
- host: 9737
container: 9737
protocol: tcp
volumes:
- type: bind
source: /var/lib/archipelago/fedimint-gateway
target: /data
options: [rw]
- type: bind
source: /var/lib/archipelago/lnd
target: /lnd
options: [ro]
environment:
- FM_BITCOIND_USERNAME=archipelago
health_check:
type: http
endpoint: http://localhost:8176
path: /
interval: 30s
timeout: 5s
retries: 3
bitcoin_integration:
rpc_access: admin
sync_required: true
+50 -25
View File
@@ -3,56 +3,72 @@ app:
name: Fedimint
version: 0.10.0
description: Federated Bitcoin minting service with built-in Guardian UI. Privacy-preserving Bitcoin custody.
container:
image: fedimint/fedimintd:v0.10.0
image_signature: cosign://...
image: 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
- key: FM_API_URL
template: ws://{{HOST_MDNS}}:8174
secret_env:
- key: FM_BITCOIND_PASSWORD
secret_file: bitcoin-rpc-password
data_uid: "1000:1000"
dependencies:
- app_id: bitcoin-core
version: ">=24.0"
version: ">=26.0"
- storage: 20Gi
resources:
cpu_limit: 4
memory_limit: 4Gi
disk_limit: 20Gi
security:
capabilities: []
readonly_root: true
no_new_privileges: true
user: 1000
seccomp_profile: default
network_policy: isolated
apparmor_profile: fedimint
ports:
- host: 8173
container: 8173
protocol: tcp # P2P
protocol: tcp
- host: 8174
container: 8174
protocol: tcp # API
- host: 8175
protocol: tcp
# 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 # Built-in Guardian UI
protocol: tcp
volumes:
- type: bind
source: /var/lib/archipelago/fedimint
target: /fedimint
target: /data
options: [rw]
environment:
- FM_DATA_DIR=/fedimint
- FM_BITCOIND_URL=http://bitcoin-core:8332
- FM_BITCOIND_USERNAME=${BITCOIN_RPC_USER}
- FM_BITCOIND_PASSWORD=${BITCOIN_RPC_PASSWORD}
- FM_DATA_DIR=/data
- FM_BITCOIND_URL=http://bitcoin-knots:8332
- FM_BITCOIND_USERNAME=archipelago
- FM_BITCOIN_NETWORK=bitcoin
- FM_BIND_P2P=0.0.0.0:8173
- FM_BIND_API=0.0.0.0:8174
- FM_BIND_UI=0.0.0.0:8175
health_check:
type: http
endpoint: http://localhost:8175
@@ -60,7 +76,16 @@ app:
interval: 30s
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
+53
View File
@@ -0,0 +1,53 @@
app:
id: filebrowser
name: File Browser
version: 2.27.0
description: Baseline Archipelago file manager service.
container:
image: git.tx1138.com/lfg2025/filebrowser:v2.27.0
pull_policy: if-not-present
network: archy-net
custom_args: ["--config", "/data/.filebrowser.json"]
data_uid: "100000:100000"
dependencies:
- storage: 10Gi
resources:
memory_limit: 256Mi
disk_limit: 10Gi
security:
capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE, NET_BIND_SERVICE]
readonly_root: false
network_policy: isolated
ports:
- host: 8083
container: 80
protocol: tcp
volumes:
- type: bind
source: /var/lib/archipelago/filebrowser
target: /srv
options: [rw]
- type: bind
source: /var/lib/archipelago/filebrowser-data
target: /data
options: [rw]
environment: []
health_check:
type: http
endpoint: http://localhost:80
path: /health
interval: 30s
timeout: 5s
retries: 3
bitcoin_integration:
rpc_access: none
sync_required: false
+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: git.tx1138.com/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
+44
View File
@@ -0,0 +1,44 @@
app:
id: lnd-ui
name: LND UI
version: 1.0.0
description: |
Archipelago-native HTTP frontend for LND. Runs nginx inside a
container and serves static assets. LND connection info is fetched
via an absolute URL that the host nginx routes to the archipelago
backend on 127.0.0.1:5678, so no upstream auth is baked in.
container:
build:
context: /opt/archipelago/docker/lnd-ui
dockerfile: Dockerfile
tag: localhost/lnd-ui:local
dependencies:
- app_id: lnd
resources:
memory_limit: 64Mi
security:
readonly_root: false
network_policy: bridge
# Bridge networking via archy-net. Container nginx listens on 80;
# host nginx proxies /app/lnd/ -> 127.0.0.1:18083 -> container:80.
ports:
- host: 18083
container: 80
protocol: tcp
volumes: []
environment: []
health_check:
type: http
endpoint: http://127.0.0.1:18083
path: /
interval: 30s
timeout: 5s
retries: 3
+29 -31
View File
@@ -1,67 +1,65 @@
app:
id: lnd
name: Lightning Network Daemon
version: 0.18.0
name: LND
version: 0.18.4
description: Lightning Network implementation by Lightning Labs. Enables instant, low-cost Bitcoin payments.
container:
image: lightninglabs/lnd:v0.18.0
image_signature: cosign://...
pull_policy: verify-signature
image: 146.59.87.168:3000/lfg2025/lnd:v0.18.4-beta
pull_policy: if-not-present
network: archy-net
secret_env:
- key: BITCOIND_RPCPASS
secret_file: bitcoin-rpc-password
data_uid: "100000:100000"
dependencies:
- app_id: bitcoin-core
version: ">=26.0"
resources:
cpu_limit: 2
memory_limit: 1Gi
disk_limit: 10Gi
security:
capabilities: [NET_BIND_SERVICE]
readonly_root: true
no_new_privileges: true
user: 1000
seccomp_profile: default
capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE, NET_RAW]
readonly_root: false
network_policy: isolated
apparmor_profile: lnd
ports:
- host: 9735
container: 9735
protocol: tcp # P2P
protocol: tcp
- host: 10009
container: 10009
protocol: tcp # gRPC
- host: 8080
protocol: tcp
- host: 18080
container: 8080
protocol: tcp # REST
protocol: tcp
volumes:
- type: bind
source: /var/lib/archipelago/lnd
target: /root/.lnd
options: [rw]
environment:
- BITCOIND_HOST=bitcoin-core
- BITCOIND_RPCUSER=${BITCOIN_RPC_USER}
- BITCOIND_RPCPASS=${BITCOIN_RPC_PASSWORD}
- BITCOIND_HOST=bitcoin-knots
- BITCOIND_RPCUSER=archipelago
- NETWORK=mainnet
health_check:
type: http
endpoint: http://localhost:8080
path: /v1/getinfo
type: tcp
endpoint: localhost:10009
interval: 30s
timeout: 5s
retries: 3
bitcoin_integration:
rpc_access: admin
sync_required: true
lightning_integration:
channel_management: true
payment_routing: true
+69
View File
@@ -0,0 +1,69 @@
app:
id: mempool-api
name: Mempool API
version: 3.0.0
description: Backend API for mempool explorer.
container:
image: git.tx1138.com/lfg2025/mempool-backend:v3.0.0
pull_policy: if-not-present
network: archy-net
secret_env:
- key: CORE_RPC_PASSWORD
secret_file: bitcoin-rpc-password
- key: DATABASE_PASSWORD
secret_file: mempool-db-password
dependencies:
- app_id: bitcoin-knots
version: ">=26.0"
- app_id: electrumx
version: ">=1.18.0"
- app_id: archy-mempool-db
version: ">=11.4.10"
resources:
memory_limit: 2Gi
disk_limit: 20Gi
security:
capabilities: []
readonly_root: false
network_policy: isolated
ports:
- host: 8999
container: 8999
protocol: tcp
volumes:
- type: bind
source: /var/lib/archipelago/mempool
target: /data
options: [rw]
environment:
- MEMPOOL_BACKEND=electrum
- ELECTRUM_HOST=electrumx
- ELECTRUM_PORT=50001
- ELECTRUM_TLS_ENABLED=false
- CORE_RPC_HOST=bitcoin-knots
- CORE_RPC_PORT=8332
- CORE_RPC_USERNAME=archipelago
- DATABASE_ENABLED=true
- DATABASE_HOST=archy-mempool-db
- DATABASE_DATABASE=mempool
- DATABASE_USERNAME=mempool
health_check:
type: http
endpoint: http://localhost:8999
path: /api/v1/backend-info
interval: 30s
timeout: 5s
retries: 3
bitcoin_integration:
rpc_access: read-only
sync_required: true
pruning_support: false
+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 @@
# Ollama - uses official image
FROM ollama/ollama:latest
# Default configuration is in the image
# No additional setup needed
-50
View File
@@ -1,50 +0,0 @@
app:
id: ollama
name: Ollama
version: 0.1.0
description: Run large language models locally. Privacy-preserving AI on your node.
container:
image: ollama/ollama:0.6.2
image_signature: cosign://...
pull_policy: if-not-present
dependencies:
- storage: 50Gi # Models can be large
resources:
cpu_limit: 4
memory_limit: 8Gi # LLMs need lots of RAM
disk_limit: 50Gi
security:
capabilities: []
readonly_root: false # Ollama needs write access for models
no_new_privileges: true
user: 1000
seccomp_profile: default
network_policy: isolated
apparmor_profile: ollama
ports:
- host: 11434
container: 11434
protocol: tcp # API
volumes:
- type: bind
source: /var/lib/archipelago/ollama
target: /root/.ollama
options: [rw]
environment:
- OLLAMA_HOST=0.0.0.0:11434
- OLLAMA_KEEP_ALIVE=24h
health_check:
type: http
endpoint: http://localhost:11434
path: /api/tags
interval: 30s
timeout: 10s
retries: 3
-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
-5
View File
@@ -1,5 +0,0 @@
# Penpot - uses official image
FROM penpot/penpot:latest
# Default configuration is in the image
# No additional setup needed
-51
View File
@@ -1,51 +0,0 @@
app:
id: penpot
name: Penpot
version: 2.0.0
description: Open-source design and prototyping platform. Design tools for teams.
container:
image: penpotapp/frontend:2.13.3
image_signature: cosign://...
pull_policy: if-not-present
dependencies:
- storage: 10Gi
resources:
cpu_limit: 4
memory_limit: 4Gi
disk_limit: 10Gi
security:
capabilities: []
readonly_root: true
no_new_privileges: true
user: 1000
seccomp_profile: default
network_policy: isolated
apparmor_profile: penpot
ports:
- host: 8089
container: 80
protocol: tcp # Web UI
volumes:
- type: bind
source: /var/lib/archipelago/penpot
target: /app/data
options: [rw]
environment:
- PENPOT_PUBLIC_URI=http://localhost:8089
- PENPOT_DATABASE_URI=postgresql://penpot:penpot@penpot-db:5432/penpot
- PENPOT_REDIS_URI=redis://penpot-redis:6379
health_check:
type: http
endpoint: http://localhost:8089
path: /api/health
interval: 30s
timeout: 5s
retries: 3
+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:2.19.4
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
-6
View File
@@ -1,6 +0,0 @@
node_modules
dist
*.log
.git
.gitignore
README.md
-38
View File
@@ -1,38 +0,0 @@
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
RUN npm ci
# Copy source code
COPY . .
# Build the application
RUN npm run build
# Production stage
FROM node:20-alpine
WORKDIR /app
# Copy built application
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
# Create non-root user
RUN addgroup -g 1000 appuser && \
adduser -D -u 1000 -G appuser appuser && \
mkdir -p /app/data && \
chown -R appuser:appuser /app
USER appuser
EXPOSE 3000
ENV DWN_STORAGE_PATH=/app/data
ENV DID_METHOD=key
CMD ["node", "dist/index.js"]
-35
View File
@@ -1,35 +0,0 @@
# Web5 DWN (Decentralized Web Node)
Personal data store for Web5. Store and sync your decentralized data across devices.
## Building
```bash
# From the apps directory
./build.sh web5-dwn
# Or manually
cd web5-dwn
docker build -t archipelago/web5-dwn:latest .
```
## Development
```bash
cd web5-dwn
npm install
npm run dev
```
## Ports
- **3000**: HTTP API (dev: 13000)
## Running Locally
```bash
docker run -p 3000:3000 \
-v /tmp/archipelago-dev/web5-dwn:/app/data \
-e DWN_STORAGE_PATH=/app/data \
archipelago/web5-dwn:latest
```
-55
View File
@@ -1,55 +0,0 @@
app:
id: web5-dwn
name: Decentralized Web Node
version: 1.0.0
description: Personal data store for Web5. Store and sync your decentralized data across devices.
container:
image: archipelago/web5-dwn:1.0.0
image_signature: cosign://...
pull_policy: if-not-present
dependencies:
- storage: 5Gi
resources:
cpu_limit: 1
memory_limit: 512Mi
disk_limit: 5Gi
security:
capabilities: []
readonly_root: true
no_new_privileges: true
user: 1000
seccomp_profile: default
network_policy: isolated
apparmor_profile: web5-dwn
ports:
- host: 3000
container: 3000
protocol: tcp # HTTP API
volumes:
- type: bind
source: /var/lib/archipelago/web5-dwn
target: /app/data
options: [rw]
environment:
- DWN_STORAGE_PATH=/app/data
- DID_METHOD=key
health_check:
type: http
endpoint: http://localhost:3000
path: /health
interval: 30s
timeout: 5s
retries: 3
web5_integration:
did_support: true
dwn_protocol: true
sync_enabled: true
-2747
View File
File diff suppressed because it is too large Load Diff
-21
View File
@@ -1,21 +0,0 @@
{
"name": "web5-dwn",
"version": "1.0.0",
"description": "Decentralized Web Node for Web5",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "ts-node src/index.ts"
},
"dependencies": {
"express": "^4.18.2",
"@web5/api": "^0.9.0"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^20.10.0",
"typescript": "^5.3.3",
"ts-node": "^10.9.2"
}
}
-34
View File
@@ -1,34 +0,0 @@
import express from 'express';
const app = express();
const port = 3000;
// Middleware
app.use(express.json());
// Health check endpoint
app.get('/health', (req, res) => {
res.json({ status: 'ok', service: 'web5-dwn' });
});
// DWN API endpoints
app.post('/dwn', async (req, res) => {
// Placeholder for DWN protocol implementation
res.json({
status: 'ok',
message: 'DWN protocol endpoint (placeholder)'
});
});
app.get('/dwn', async (req, res) => {
res.json({
status: 'ok',
message: 'DWN query endpoint (placeholder)'
});
});
// Start server
app.listen(port, '0.0.0.0', () => {
console.log(`Web5 DWN listening on port ${port}`);
console.log(`Storage path: ${process.env.DWN_STORAGE_PATH || '/app/data'}`);
});
-16
View File
@@ -1,16 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
+2 -1
View File
@@ -80,13 +80,14 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "archipelago"
version = "1.7.24-alpha"
version = "1.7.85-alpha"
dependencies = [
"anyhow",
"archipelago-container",
"archipelago-performance",
"archipelago-security",
"argon2",
"async-trait",
"base64 0.21.7",
"bcrypt",
"bip39",
+4 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "archipelago"
version = "1.7.24-alpha"
version = "1.7.85-alpha"
edition = "2021"
description = "Archipelago Bitcoin Node OS - Native backend"
authors = ["Archipelago Team"]
@@ -103,6 +103,9 @@ mdns-sd = "0.18"
# Systemd watchdog notification
sd-notify = "0.4"
# Trait objects for async methods (container orchestrator trait, Step 4)
async-trait = "0.1"
[dev-dependencies]
tokio-test = "0.4"
tempfile = "3.10"
+44 -12
View File
@@ -10,6 +10,7 @@ mod websocket;
use crate::api::rpc::RpcHandler;
use crate::blobs::BlobStore;
use crate::config::Config;
use crate::container::{ContainerOrchestrator, DevContainerOrchestrator};
use crate::monitoring::MetricsStore;
use crate::session::{self, SessionStore};
use crate::state::StateManager;
@@ -54,6 +55,8 @@ impl ApiHandler {
config: Config,
state_manager: Arc<StateManager>,
metrics_store: Arc<MetricsStore>,
orchestrator: Option<Arc<dyn ContainerOrchestrator>>,
dev_orchestrator: Option<Arc<DevContainerOrchestrator>>,
) -> Result<Self> {
let session_store = SessionStore::new().await;
let rpc_handler = Arc::new(
@@ -62,6 +65,8 @@ impl ApiHandler {
state_manager.clone(),
metrics_store.clone(),
session_store.clone(),
orchestrator,
dev_orchestrator,
)
.await?,
);
@@ -115,14 +120,40 @@ impl ApiHandler {
/// Server-side fetch of the upstream app catalog so the browser can
/// load it without fighting CORS (git.tx1138.com emits no ACAO) or
/// CSP (the fallback IP-port URL isn't in `connect-src`). Tries the
/// upstream URLs in the same order the frontend used, returns the
/// first 2xx response. 15s total timeout.
async fn handle_app_catalog_proxy() -> Result<Response<hyper::Body>> {
const UPSTREAMS: &[&str] = &[
"https://git.tx1138.com/lfg2025/app-catalog/raw/branch/main/catalog.json",
"http://23.182.128.160:3000/lfg2025/app-catalog/raw/branch/main/catalog.json",
];
/// CSP (the fallback IP-port URL isn't in `connect-src`). The upstream
/// list is derived from the operator's configured container registries
/// so switching mirrors in Settings changes the App Store source too —
/// each active registry contributes one Gitea `raw/branch/main/catalog.json`
/// URL (http or https per `tls_verify`), tried in priority order.
/// If registry config can't be loaded, falls back to the legacy
/// hardcoded pair so the App Store still renders on nodes that haven't
/// persisted a registry config yet. 15s total timeout.
async fn handle_app_catalog_proxy(&self) -> Result<Response<hyper::Body>> {
let mut upstreams: Vec<String> = Vec::new();
if let Ok(config) = crate::container::registry::load_registries(&self.config.data_dir).await
{
for reg in config.active_registries() {
let scheme = if reg.tls_verify { "https" } else { "http" };
// Gitea raw URL: <scheme>://<host>/<namespace>/app-catalog/raw/branch/main/catalog.json.
// reg.url already includes the namespace (e.g. "host/lfg2025"),
// so we just tack on the repo + raw path.
upstreams.push(format!(
"{}://{}/app-catalog/raw/branch/main/catalog.json",
scheme, reg.url
));
}
}
if upstreams.is_empty() {
upstreams.push(
"http://146.59.87.168:3000/lfg2025/app-catalog/raw/branch/main/catalog.json"
.to_string(),
);
upstreams.push(
"https://git.tx1138.com/lfg2025/app-catalog/raw/branch/main/catalog.json"
.to_string(),
);
}
let client = match reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
@@ -136,8 +167,8 @@ impl ApiHandler {
));
}
};
for url in UPSTREAMS {
match client.get(*url).send().await {
for url in &upstreams {
match client.get(url).send().await {
Ok(resp) if resp.status().is_success() => {
if let Ok(bytes) = resp.bytes().await {
return Ok(Response::builder()
@@ -289,7 +320,7 @@ impl ApiHandler {
match (method, path.as_str()) {
// RPC — auth is handled inside rpc handler per-method
(Method::POST, "/rpc/v1") => self.rpc_handler.handle(req_with_bytes).await,
(Method::POST, "/rpc/v1") => self.rpc_handler.clone().handle(req_with_bytes).await,
// Health — unauthenticated, returns JSON with service status
(Method::GET, "/health") => {
@@ -398,6 +429,7 @@ impl ApiHandler {
// Electrs status — unauthenticated (read-only sync status)
(Method::GET, "/electrs-status") => Self::handle_electrs_status().await,
(Method::GET, "/bitcoin-status") => Self::handle_bitcoin_status().await,
// App-catalog proxy — fetches catalog.json from the configured
// upstream URLs server-side so the browser doesn't hit CORS
@@ -408,7 +440,7 @@ impl ApiHandler {
if !self.is_authenticated(&headers).await {
return Ok(Self::unauthorized());
}
Self::handle_app_catalog_proxy().await
self.handle_app_catalog_proxy().await
}
// LND connect info — nginx validates session cookie (presence check),
@@ -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();
+48
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
@@ -206,10 +227,16 @@ impl RpcHandler {
let report = serde_json::json!({
"node_id": node_id,
"node_name": data.server_info.name.clone().filter(|n| !n.trim().is_empty()),
"hostname": system_hostname().await,
"server_url": local_server_url(&self.config.host_ip),
"version": data.server_info.version,
"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()
@@ -483,3 +510,24 @@ impl RpcHandler {
}))
}
}
async fn system_hostname() -> Option<String> {
let output = tokio::process::Command::new("hostname")
.output()
.await
.ok()?;
if !output.status.success() {
return None;
}
let hostname = String::from_utf8_lossy(&output.stdout).trim().to_string();
(!hostname.is_empty()).then_some(hostname)
}
fn local_server_url(host_ip: &str) -> Option<String> {
let host_ip = host_ip.trim();
if host_ip.is_empty() || host_ip == "127.0.0.1" {
None
} else {
Some(format!("https://{host_ip}"))
}
}
+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> {
+427 -28
View File
@@ -3,6 +3,55 @@ use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use zeroize::Zeroize;
/// Retry configuration for [`bitcoin_rpc_post_with_retry`].
///
/// Exposed as a struct (rather than hard-coded constants inside the function)
/// so tests can dial down timeouts to keep the suite fast while still
/// exercising real retry/backoff behavior.
#[derive(Debug, Clone)]
struct RetryConfig {
max_attempts: u32,
attempt_timeout: std::time::Duration,
/// Length must equal `max_attempts - 1` (one backoff between each
/// successive attempt). The last attempt is not followed by a backoff.
backoffs: Vec<std::time::Duration>,
}
impl RetryConfig {
/// Production retry policy: 3 attempts, 15s each, 500ms + 1500ms backoffs.
/// Total worst-case wall time: 3 * 15 + 0.5 + 1.5 = 47s.
fn production() -> Self {
Self {
max_attempts: BITCOIN_RPC_MAX_ATTEMPTS,
attempt_timeout: BITCOIN_RPC_ATTEMPT_TIMEOUT,
backoffs: BITCOIN_RPC_BACKOFFS.to_vec(),
}
}
}
/// Max retry attempts for a single bitcoin_rpc_call invocation.
/// First attempt + 2 retries = 3 total.
const BITCOIN_RPC_MAX_ATTEMPTS: u32 = 3;
/// Per-attempt deadline. Must be >= the reqwest client's own timeout (we
/// build it at 15s in handle_bitcoin_getinfo) — this is the outer safety net.
const BITCOIN_RPC_ATTEMPT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
/// Backoff between attempts. Index 0 = after first failure, 1 = after second, etc.
/// Chosen to absorb bitcoind's typical block-validation stall (2-5s) without
/// adding noticeable latency on the happy path (first attempt succeeds in ~30ms).
const BITCOIN_RPC_BACKOFFS: [std::time::Duration; 2] = [
std::time::Duration::from_millis(500),
std::time::Duration::from_millis(1500),
];
/// Classify a reqwest error as transient (retryable) or fatal.
/// Transient: timeout, connect refused, request/response body IO errors.
/// Fatal: TLS errors, URL parse errors, redirect loops, builder errors.
fn is_transient_transport_error(e: &reqwest::Error) -> bool {
e.is_timeout() || e.is_connect() || e.is_request() || e.is_body()
}
#[derive(Debug, Serialize)]
struct BitcoinInfo {
block_height: u64,
@@ -37,8 +86,15 @@ struct MempoolInfo {
impl RpcHandler {
pub(super) async fn handle_bitcoin_getinfo(&self) -> Result<serde_json::Value> {
// Per-attempt timeout (see bitcoin_rpc_call for retry semantics).
// 15s is enough room for bitcoind to answer getblockchaininfo even
// during block validation; bitcoin_rpc_call wraps each attempt in a
// separate tokio::time::timeout too, so this is belt-and-suspenders.
// connect_timeout is tighter so a dead bitcoind doesn't steal the
// whole attempt budget on TCP connect alone.
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.timeout(std::time::Duration::from_secs(15))
.connect_timeout(std::time::Duration::from_secs(3))
.build()
.context("Failed to create HTTP client")?;
@@ -68,6 +124,19 @@ impl RpcHandler {
Ok(serde_json::to_value(info)?)
}
/// Call a Bitcoin Core JSON-RPC method.
///
/// Retries up to [`BITCOIN_RPC_MAX_ATTEMPTS`] times on transient
/// transport errors (timeout / connection refused / send/recv IO).
/// Does **not** retry when bitcoind responds with a well-formed
/// `{"error": ...}` body — those are real RPC errors and surfacing
/// them quickly is the right behavior.
///
/// Motivation: on a syncing pruned node, bitcoind's RPC thread can block
/// for 5-10 seconds during block validation. A single 10s timeout means
/// ~30% of UI calls error out even though the node is perfectly healthy.
/// With retry + backoff, the UI sees a uniform slow-but-successful
/// response instead of intermittent failures.
async fn bitcoin_rpc_call<T: serde::de::DeserializeOwned>(
&self,
client: &reqwest::Client,
@@ -75,33 +144,15 @@ impl RpcHandler {
params: &[serde_json::Value],
) -> Result<T> {
let (rpc_user, rpc_pass) = crate::bitcoin_rpc::bitcoin_rpc_credentials().await;
let body = serde_json::json!({
"jsonrpc": "1.0",
"id": "archy",
"method": method,
"params": params,
});
let resp = client
.post(crate::constants::BITCOIN_RPC_URL)
.basic_auth(&rpc_user, Some(&rpc_pass))
.json(&body)
.send()
.await
.context("Bitcoin RPC connection failed")?;
let rpc_resp: BitcoinRpcResponse<T> = resp
.json()
.await
.context("Failed to parse Bitcoin RPC response")?;
if let Some(err) = rpc_resp.error {
anyhow::bail!("Bitcoin RPC error: {}", err);
}
rpc_resp
.result
.ok_or_else(|| anyhow::anyhow!("Bitcoin RPC returned null result"))
bitcoin_rpc_post_with_retry(
client,
crate::constants::BITCOIN_RPC_URL,
&rpc_user,
&rpc_pass,
method,
params,
)
.await
}
/// Initialize a Bitcoin Core descriptor wallet with keys derived from the master seed.
@@ -243,3 +294,351 @@ impl RpcHandler {
}))
}
}
/// Free-function counterpart to `RpcHandler::bitcoin_rpc_call`.
///
/// Takes the URL + credentials as parameters so it can be exercised by unit
/// tests against a mock HTTP server without constructing a full `RpcHandler`.
///
/// Production callers go through `RpcHandler::bitcoin_rpc_call`, which loads
/// credentials from the secrets file and points at `BITCOIN_RPC_URL`.
async fn bitcoin_rpc_post_with_retry<T: serde::de::DeserializeOwned>(
client: &reqwest::Client,
url: &str,
rpc_user: &str,
rpc_pass: &str,
method: &str,
params: &[serde_json::Value],
) -> Result<T> {
bitcoin_rpc_post_with_retry_cfg(
client,
url,
rpc_user,
rpc_pass,
method,
params,
&RetryConfig::production(),
)
.await
}
/// Inner implementation with configurable retry policy (for tests).
async fn bitcoin_rpc_post_with_retry_cfg<T: serde::de::DeserializeOwned>(
client: &reqwest::Client,
url: &str,
rpc_user: &str,
rpc_pass: &str,
method: &str,
params: &[serde_json::Value],
cfg: &RetryConfig,
) -> Result<T> {
debug_assert_eq!(
cfg.backoffs.len(),
(cfg.max_attempts - 1) as usize,
"RetryConfig: backoffs.len() must equal max_attempts - 1"
);
let body = serde_json::json!({
"jsonrpc": "1.0",
"id": "archy",
"method": method,
"params": params,
});
let mut last_err: Option<anyhow::Error> = None;
for attempt in 0..cfg.max_attempts {
if attempt > 0 {
let backoff = cfg
.backoffs
.get(attempt as usize - 1)
.copied()
.unwrap_or_else(|| std::time::Duration::from_secs(2));
tracing::warn!(
"bitcoin_rpc({}): attempt {} failed, backing off {:?}",
method,
attempt,
backoff
);
tokio::time::sleep(backoff).await;
}
// Per-attempt hard deadline. Independent of reqwest's built-in timeout
// so we always cap total time even if reqwest blocks on something
// weird (e.g., DNS starvation).
let fut = client
.post(url)
.basic_auth(rpc_user, Some(rpc_pass))
.json(&body)
.send();
let send_result = match tokio::time::timeout(cfg.attempt_timeout, fut).await {
Err(_elapsed) => {
last_err = Some(anyhow::anyhow!(
"Bitcoin RPC send timed out after {:?}",
cfg.attempt_timeout
));
continue; // transient: retry
}
Ok(r) => r,
};
let resp = match send_result {
Ok(r) => r,
Err(e) if is_transient_transport_error(&e) => {
last_err = Some(anyhow::Error::from(e).context("Bitcoin RPC connection failed"));
continue; // transient: retry
}
Err(e) => {
return Err(anyhow::Error::from(e).context("Bitcoin RPC connection failed"));
}
};
let rpc_resp: BitcoinRpcResponse<T> = resp
.json()
.await
.context("Failed to parse Bitcoin RPC response")?;
if let Some(err) = rpc_resp.error {
// RPC-level error: this is a real bitcoind response, not transient.
anyhow::bail!("Bitcoin RPC error: {}", err);
}
return rpc_resp
.result
.ok_or_else(|| anyhow::anyhow!("Bitcoin RPC returned null result"));
}
Err(last_err
.unwrap_or_else(|| anyhow::anyhow!("Bitcoin RPC exhausted retries with no error captured")))
}
#[cfg(test)]
mod tests {
use super::*;
use hyper::service::{make_service_fn, service_fn};
use hyper::{Body, Request, Response, Server, StatusCode};
use std::convert::Infallible;
use std::net::SocketAddr;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
/// Spin up a mock bitcoind HTTP server that behaves according to `handler`.
/// Returns the bound URL and a JoinHandle (dropped = server shutdown via the
/// oneshot cancel channel).
async fn spawn_mock<F, Fut>(
handler: F,
) -> (
String,
tokio::task::JoinHandle<()>,
tokio::sync::oneshot::Sender<()>,
)
where
F: Fn(Request<Body>) -> Fut + Send + Sync + Clone + 'static,
Fut: std::future::Future<Output = Response<Body>> + Send + 'static,
{
let addr = SocketAddr::from(([127, 0, 0, 1], 0));
let make_svc = make_service_fn(move |_| {
let handler = handler.clone();
async move {
Ok::<_, Infallible>(service_fn(move |req| {
let handler = handler.clone();
async move { Ok::<_, Infallible>(handler(req).await) }
}))
}
});
let server = Server::bind(&addr).serve(make_svc);
let url = format!("http://{}", server.local_addr());
let (tx, rx) = tokio::sync::oneshot::channel::<()>();
let handle = tokio::spawn(async move {
let graceful = server.with_graceful_shutdown(async {
let _ = rx.await;
});
let _ = graceful.await;
});
(url, handle, tx)
}
/// Reply body bitcoind would send for a successful getblockcount.
fn ok_reply() -> Body {
Body::from(r#"{"result":42,"error":null,"id":"archy"}"#)
}
fn err_reply() -> Body {
Body::from(r#"{"result":null,"error":{"code":-8,"message":"nope"},"id":"archy"}"#)
}
/// Succeeds on first attempt — should not retry.
#[tokio::test]
async fn happy_path_first_attempt() {
let count = Arc::new(AtomicU32::new(0));
let c = count.clone();
let (url, _h, _tx) = spawn_mock(move |_req| {
let c = c.clone();
async move {
c.fetch_add(1, Ordering::SeqCst);
Response::new(ok_reply())
}
})
.await;
let client = reqwest::Client::builder().build().unwrap();
let v: u64 =
bitcoin_rpc_post_with_retry(&client, &url, "user", "pass", "getblockcount", &[])
.await
.expect("should succeed");
assert_eq!(v, 42);
assert_eq!(count.load(Ordering::SeqCst), 1, "should not have retried");
}
/// HTTP 503 with non-JSON body: produces a JSON-parse error which is NOT
/// classified as transient. Must fail after first attempt.
/// This guards against the tempting mistake of blanket-retrying every
/// non-2xx response — which would mask real bitcoind misconfig.
#[tokio::test]
async fn does_not_retry_parse_errors() {
let count = Arc::new(AtomicU32::new(0));
let c = count.clone();
let (url, _h, _tx) = spawn_mock(move |_req| {
let c = c.clone();
async move {
c.fetch_add(1, Ordering::SeqCst);
Response::builder()
.status(StatusCode::SERVICE_UNAVAILABLE)
.body(Body::from("busy"))
.unwrap()
}
})
.await;
let client = reqwest::Client::builder().build().unwrap();
let result: Result<u64> =
bitcoin_rpc_post_with_retry(&client, &url, "user", "pass", "getblockcount", &[]).await;
assert!(result.is_err(), "non-JSON response should error out");
assert_eq!(
count.load(Ordering::SeqCst),
1,
"parse errors are not retryable"
);
}
/// Connect-refused (port closed) is the canonical transient transport
/// error. Must exhaust BITCOIN_RPC_MAX_ATTEMPTS and the total elapsed
/// time must include at least the sum of the backoffs.
#[tokio::test]
async fn retries_exhausted_on_persistent_connect_refused() {
// Bind a port then immediately drop the listener so the port is closed.
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let closed_url = format!("http://{}", listener.local_addr().unwrap());
drop(listener);
let client = reqwest::Client::builder()
.connect_timeout(std::time::Duration::from_millis(500))
.build()
.unwrap();
let start = std::time::Instant::now();
let result: Result<u64> =
bitcoin_rpc_post_with_retry(&client, &closed_url, "user", "pass", "getblockcount", &[])
.await;
let elapsed = start.elapsed();
assert!(result.is_err(), "connect-refused should exhaust retries");
let min_backoff: std::time::Duration = BITCOIN_RPC_BACKOFFS.iter().sum();
assert!(
elapsed >= min_backoff,
"should have backed off between retries (elapsed={:?}, expected at least {:?})",
elapsed,
min_backoff
);
}
/// The motivating scenario: first attempt times out (bitcoind busy),
/// subsequent attempt succeeds. Uses a short test-only RetryConfig so
/// the test runs in <1s instead of 15s.
#[tokio::test]
async fn retries_on_timeout_then_succeeds() {
let count = Arc::new(AtomicU32::new(0));
let c = count.clone();
// Mock server: first request hangs for 500ms, subsequent requests reply OK.
let (url, _h, _tx) = spawn_mock(move |_req| {
let c = c.clone();
async move {
let n = c.fetch_add(1, Ordering::SeqCst);
if n == 0 {
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
}
Response::new(ok_reply())
}
})
.await;
let client = reqwest::Client::builder().build().unwrap();
// Attempt timeout 100ms < server's 500ms sleep => first attempt times out.
// Backoff 20ms between attempts.
let cfg = RetryConfig {
max_attempts: 3,
attempt_timeout: std::time::Duration::from_millis(100),
backoffs: vec![
std::time::Duration::from_millis(20),
std::time::Duration::from_millis(20),
],
};
let v: u64 = bitcoin_rpc_post_with_retry_cfg(
&client,
&url,
"user",
"pass",
"getblockcount",
&[],
&cfg,
)
.await
.expect("second attempt should succeed");
assert_eq!(v, 42);
assert!(
count.load(Ordering::SeqCst) >= 2,
"expected at least 2 attempts (got {})",
count.load(Ordering::SeqCst)
);
}
/// bitcoind returned a well-formed `{"error": ...}` body. Must NOT retry.
#[tokio::test]
async fn does_not_retry_on_rpc_level_error() {
let count = Arc::new(AtomicU32::new(0));
let c = count.clone();
let (url, _h, _tx) = spawn_mock(move |_req| {
let c = c.clone();
async move {
c.fetch_add(1, Ordering::SeqCst);
Response::new(err_reply())
}
})
.await;
let client = reqwest::Client::builder().build().unwrap();
let result: Result<u64> =
bitcoin_rpc_post_with_retry(&client, &url, "user", "pass", "getblockcount", &[]).await;
assert!(result.is_err());
assert_eq!(
count.load(Ordering::SeqCst),
1,
"RPC-level errors are not transient"
);
}
/// Sanity: retry budget invariants. Chosen to catch regressions where
/// someone bumps these constants without realizing the total worst-case
/// wall time implications.
#[test]
fn retry_budget_invariants() {
assert_eq!(BITCOIN_RPC_MAX_ATTEMPTS, 3);
assert_eq!(
BITCOIN_RPC_BACKOFFS.len(),
(BITCOIN_RPC_MAX_ATTEMPTS - 1) as usize
);
// Total wall-time ceiling:
// 3 attempts * 15s + (0.5s + 1.5s) backoff = 47s
let total: std::time::Duration = BITCOIN_RPC_ATTEMPT_TIMEOUT * BITCOIN_RPC_MAX_ATTEMPTS
+ BITCOIN_RPC_BACKOFFS.iter().sum::<std::time::Duration>();
assert!(total < std::time::Duration::from_secs(60));
}
}
@@ -0,0 +1,963 @@
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) if rpcauth_matches_password(&value, TXRELAY_USER, &password) => value,
_ => {
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 rpcauth_matches_password(rpcauth: &str, username: &str, password: &str) -> bool {
let Some(rest) = rpcauth.strip_prefix(&format!("{username}:")) else {
return false;
};
let Some((salt_hex, expected_hash)) = rest.split_once('$') else {
return false;
};
if salt_hex.is_empty() || expected_hash.is_empty() {
return false;
}
let Ok(mut mac) = Hmac::<Sha256>::new_from_slice(salt_hex.as_bytes()) else {
return false;
};
mac.update(password.as_bytes());
let hash_hex = hex::encode(mac.finalize().into_bytes());
hash_hex.eq_ignore_ascii_case(expected_hash)
}
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()
}
+662 -94
View File
@@ -1,14 +1,25 @@
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(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let orchestrator = self.orchestrator.as_ref().ok_or_else(|| {
anyhow::anyhow!("Container orchestrator not available (dev mode required)")
// The `container-install { manifest_path }` RPC is a dev-mode convenience
// that points at an arbitrary YAML on disk. Production install happens via
// the reconciler (BootReconciler, Step 5) and via the unified
// ContainerOrchestrator::install(app_id) trait call, which can be exposed
// through a separate `container-install-by-id` RPC when needed.
let dev = self.dev_orchestrator.as_ref().ok_or_else(|| {
anyhow::anyhow!("container-install with manifest_path is only available in dev mode")
})?;
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
@@ -45,7 +56,7 @@ impl RpcHandler {
let manifest: archipelago_container::AppManifest =
serde_yaml::from_str(&manifest_content).context("Failed to parse manifest")?;
let container_name = orchestrator
let container_name = dev
.install_container(&manifest, manifest_path)
.await
.context("Failed to install container")?;
@@ -57,10 +68,6 @@ impl RpcHandler {
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let orchestrator = self.orchestrator.as_ref().ok_or_else(|| {
anyhow::anyhow!("Container orchestrator not available (dev mode required)")
})?;
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let app_id = params
.get("app_id")
@@ -68,22 +75,24 @@ impl RpcHandler {
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
validate_app_id(app_id)?;
orchestrator
.start_container(app_id)
.await
.context("Failed to start container")?;
// User explicitly started the app — clear the user-stopped marker so
// crash recovery / health monitor won't second-guess it. Must happen
// BEFORE the spawn (see runtime.rs:145-148 for the symmetric stop
// side and the ordering contract crash recovery depends on).
crate::crash_recovery::clear_user_stopped(&self.config.data_dir, app_id).await;
Ok(serde_json::json!({ "status": "started" }))
// spawn_transitional returns as soon as the background task is
// launched (<1s). The UI sees Starting… immediately via WebSocket.
self.spawn_transitional(Op::Start, app_id.to_string())
.await?;
Ok(serde_json::json!({ "status": "starting" }))
}
pub(super) async fn handle_container_stop(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let orchestrator = self.orchestrator.as_ref().ok_or_else(|| {
anyhow::anyhow!("Container orchestrator not available (dev mode required)")
})?;
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let app_id = params
.get("app_id")
@@ -91,21 +100,51 @@ impl RpcHandler {
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
validate_app_id(app_id)?;
orchestrator
.stop_container(app_id)
.await
.context("Failed to stop container")?;
// Mark as user-stopped BEFORE the spawn — ordering is load-bearing
// (crash recovery / health monitor inspect this flag concurrently
// with the in-flight stop; see runtime.rs:145-148 for the package
// path that also writes this in the same order).
crate::crash_recovery::mark_user_stopped(&self.config.data_dir, app_id).await;
Ok(serde_json::json!({ "status": "stopped" }))
// podman stop -t 600 (bitcoin-core) / -t 330 (lnd) runs in the
// background; the RPC returns now with "stopping".
self.spawn_transitional(Op::Stop, app_id.to_string())
.await?;
Ok(serde_json::json!({ "status": "stopping" }))
}
pub(super) async fn handle_container_restart(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let app_id = params
.get("app_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
validate_app_id(app_id)?;
// Restart does not mark user-stopped (the user wants the app to
// keep running). Clear the marker as a defensive measure in case a
// prior stop left it set and the restart is intended to revive the
// normal running state.
crate::crash_recovery::clear_user_stopped(&self.config.data_dir, app_id).await;
self.spawn_transitional(Op::Restart, app_id.to_string())
.await?;
Ok(serde_json::json!({ "status": "restarting" }))
}
pub(super) async fn handle_container_remove(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let orchestrator = self.orchestrator.as_ref().ok_or_else(|| {
anyhow::anyhow!("Container orchestrator not available (dev mode required)")
})?;
let orchestrator = self
.orchestrator
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available"))?;
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let app_id = params
@@ -119,7 +158,7 @@ impl RpcHandler {
.unwrap_or(false);
orchestrator
.remove_container(app_id, preserve_data)
.remove(app_id, preserve_data)
.await
.context("Failed to remove container")?;
@@ -133,39 +172,75 @@ 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)| {
let state = match &pkg.state {
crate::data_model::PackageState::Running => "running",
crate::data_model::PackageState::Stopped => "stopped",
crate::data_model::PackageState::Exited => "exited",
crate::data_model::PackageState::Starting => "created",
_ => "unknown",
};
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));
}
// Fallback: scanner hasn't run yet, query podman directly
// Fallback: scanner hasn't run yet, query the orchestrator directly.
if let Some(orchestrator) = &self.orchestrator {
if let Ok(containers) = orchestrator.list_containers().await {
if let Ok(containers) = orchestrator.list().await {
if !containers.is_empty() {
return Ok(serde_json::to_value(containers)?);
}
@@ -242,9 +317,10 @@ impl RpcHandler {
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let orchestrator = self.orchestrator.as_ref().ok_or_else(|| {
anyhow::anyhow!("Container orchestrator not available (dev mode required)")
})?;
let orchestrator = self
.orchestrator
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available"))?;
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let app_id = params
@@ -253,21 +329,36 @@ impl RpcHandler {
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
validate_app_id(app_id)?;
let status = orchestrator
.get_container_status(app_id)
.await
.context("Failed to get container status")?;
let mut last_err: Option<anyhow::Error> = None;
for candidate in status_app_id_candidates(app_id) {
match orchestrator.status(&candidate).await {
Ok(status) => return Ok(serde_json::to_value(status)?),
Err(e) => last_err = Some(e),
}
}
Ok(serde_json::to_value(status)?)
// Fallback for alias drift: query podman directly by likely container
// names so status checks stay useful during migration.
for name in status_container_name_candidates(app_id) {
if let Some(v) = inspect_container_state_value(&name).await {
return Ok(v);
}
}
if let Some(e) = last_err {
return Err(e.context("Failed to get container status"));
}
Err(anyhow::anyhow!("Failed to get container status"))
}
pub(super) async fn handle_container_logs(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let orchestrator = self.orchestrator.as_ref().ok_or_else(|| {
anyhow::anyhow!("Container orchestrator not available (dev mode required)")
})?;
let orchestrator = self
.orchestrator
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available"))?;
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let app_id = params
@@ -278,7 +369,7 @@ impl RpcHandler {
let lines = params.get("lines").and_then(|v| v.as_u64()).unwrap_or(100) as u32;
let logs = orchestrator
.get_container_logs(app_id, lines)
.logs(app_id, lines)
.await
.context("Failed to get container logs")?;
@@ -291,12 +382,13 @@ impl RpcHandler {
app_id: &str,
lines: u32,
) -> Result<serde_json::Value> {
let orchestrator = self.orchestrator.as_ref().ok_or_else(|| {
anyhow::anyhow!("Container orchestrator not available (dev mode required)")
})?;
let orchestrator = self
.orchestrator
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available"))?;
let logs = orchestrator
.get_container_logs(app_id, lines)
.logs(app_id, lines)
.await
.context("Failed to get container logs")?;
@@ -307,47 +399,523 @@ impl RpcHandler {
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let orchestrator = self.orchestrator.as_ref().ok_or_else(|| {
anyhow::anyhow!("Container orchestrator not available (dev mode required)")
})?;
let orchestrator = self
.orchestrator
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available"))?;
// If app_id is provided, get health for that app
// If app_id is provided, get health for that app.
if let Some(params) = params {
if let Some(app_id) = params.get("app_id").and_then(|v| v.as_str()) {
let health = orchestrator
.get_health_status(app_id)
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"));
}
}
// Otherwise, get health for all containers
// Otherwise, get health for all containers.
let containers = orchestrator
.list_containers()
.list()
.await
.context("Failed to list containers")?;
let mut health_map = serde_json::Map::new();
for container in containers {
if let Some(app_id) = container.name.strip_prefix("archipelago-") {
if let Some(app_id) = app_id.strip_suffix("-dev") {
match orchestrator.get_health_status(app_id).await {
Ok(health) => {
health_map
.insert(app_id.to_string(), serde_json::Value::String(health));
}
Err(_) => {
health_map.insert(
app_id.to_string(),
serde_json::Value::String("unknown".to_string()),
);
}
}
// Map the runtime container name back to the app_id the orchestrator
// knows about. Dev orchestrator uses `archipelago-<id>-dev`; Prod
// uses bare `<id>` (or `archy-<id>` for UIs — health() accepts the
// app_id either way since UI_APP_IDS is centralised).
let app_id_candidate = container
.name
.strip_prefix("archipelago-")
.and_then(|s| s.strip_suffix("-dev"))
.or_else(|| container.name.strip_prefix("archy-"))
.unwrap_or(container.name.as_str());
match 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),
);
}
Ok(Err(_)) | Err(_) => {
health_map.insert(
app_id_candidate.to_string(),
serde_json::Value::String("unknown".to_string()),
);
}
}
}
Ok(serde_json::Value::Object(health_map))
}
async fn 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> {
let mut out = Vec::new();
let mut push = |s: &str| {
if !out.iter().any(|e: &String| e == s) {
out.push(s.to_string());
}
};
match app_id {
"bitcoin-knots" => {
push("bitcoin-knots");
push("bitcoin-core");
push("bitcoin");
}
"bitcoin-core" | "bitcoin" => {
push("bitcoin-core");
push("bitcoin-knots");
push("bitcoin");
}
"electrs" | "mempool-electrs" => {
push("electrs");
push("mempool-electrs");
push("electrumx");
}
"mempool" | "mempool-web" => {
push("mempool");
push("archy-mempool-web");
}
"immich" => {
push("immich");
push("immich_server");
}
_ => push(app_id),
}
out
}
fn status_container_name_candidates(app_id: &str) -> Vec<String> {
let mut out = Vec::new();
let mut push = |s: &str| {
if !out.iter().any(|e: &String| e == s) {
out.push(s.to_string());
}
};
match app_id {
"bitcoin-knots" | "bitcoin-core" | "bitcoin" => push("bitcoin-knots"),
"bitcoin-ui" => push("archy-bitcoin-ui"),
"lnd-ui" => push("archy-lnd-ui"),
"electrs-ui" => push("archy-electrs-ui"),
"electrs" | "mempool-electrs" => push("electrumx"),
"mempool" | "mempool-web" | "archy-mempool-web" => push("mempool"),
"immich" => push("immich_server"),
_ => {}
}
push(app_id);
if let Some(stripped) = app_id.strip_prefix("archy-") {
push(stripped);
} else {
push(&format!("archy-{}", app_id));
}
out
}
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,
"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> {
if let Some(v) = ps_container_state_value(name).await {
return Some(v);
}
let mut cmd = tokio::process::Command::new("podman");
cmd.args([
"inspect",
name,
"--format",
"{{.State.Status}} {{.State.Running}} {{if .State.Healthcheck}}{{.State.Healthcheck.Status}}{{else}}none{{end}}",
]);
cmd.kill_on_drop(true);
let out = tokio::time::timeout(PODMAN_INSPECT_TIMEOUT, cmd.output())
.await
.ok()?
.ok()?;
if !out.status.success() {
return None;
}
let line = String::from_utf8_lossy(&out.stdout).trim().to_string();
if line.is_empty() {
return None;
}
let mut parts = line.split_whitespace();
let status = parts.next().unwrap_or("unknown");
let running = parts.next().unwrap_or("false") == "true";
let health = parts.next().unwrap_or("none");
Some(serde_json::json!({
"name": name,
"status": status,
"state": status,
"running": running,
"health": health,
}))
}
async fn ps_container_state_value(name: &str) -> Option<serde_json::Value> {
let mut cmd = tokio::process::Command::new("podman");
cmd.args([
"ps",
"-a",
"--filter",
&format!("name={name}"),
"--format",
"{{.Names}}|{{.Status}}",
]);
cmd.kill_on_drop(true);
let out = tokio::time::timeout(PODMAN_PS_TIMEOUT, cmd.output())
.await
.ok()?
.ok()?;
if !out.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&out.stdout);
for line in stdout.lines() {
let mut parts = line.splitn(2, '|');
let container_name = parts.next().unwrap_or_default();
if container_name != name {
continue;
}
let status = parts.next().unwrap_or_default();
let state = state_from_podman_status(status);
let health = parse_health_from_status(status).unwrap_or("none");
return Some(serde_json::json!({
"name": name,
"status": state,
"state": state,
"running": state.eq_ignore_ascii_case("running"),
"health": health,
}));
}
None
}
fn state_from_podman_status(status: &str) -> &str {
if status.starts_with("Up ") {
"running"
} else if status.starts_with("Exited ") {
"exited"
} else if status.starts_with("Created") {
"created"
} else if status.starts_with("Stopping") {
"stopping"
} else if status.starts_with("Removing") {
"removing"
} else {
"unknown"
}
}
fn parse_health_from_status(status: &str) -> Option<&str> {
let start = status.rfind('(')?;
let end = status.rfind(')')?;
(start < end).then(|| &status[start + 1..end])
}
async fn inspect_container_health_value(name: &str) -> Option<String> {
let v = inspect_container_state_value(name).await?;
if let Some(health) = v.get("health").and_then(|s| s.as_str()) {
if health != "none" {
return Some(health.to_string());
}
}
match v.get("state").and_then(|s| s.as_str()).unwrap_or("unknown") {
"running" => Some("healthy".to_string()),
"created" => Some("starting".to_string()),
"paused" => Some("paused".to_string()),
"stopping" => Some("unhealthy".to_string()),
"exited" | "stopped" => Some("unhealthy".to_string()),
other => Some(format!("unknown:{other}")),
}
}
+15 -10
View File
@@ -231,8 +231,7 @@ impl RpcHandler {
let (data, _) = self.state_manager.get_snapshot().await;
let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
let fips_npub =
crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
let path = format!("/content/{}", content_id);
let (response, _transport) =
@@ -287,10 +286,13 @@ impl RpcHandler {
return Err(anyhow::anyhow!("Invalid v3 onion address"));
}
let fips_npub =
crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
debug!("Browsing peer content at {} (fips={})", onion, fips_npub.is_some());
debug!(
"Browsing peer content at {} (fips={})",
onion,
fips_npub.is_some()
);
let (response, _transport) =
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, "/content")
@@ -348,8 +350,7 @@ impl RpcHandler {
let (data, _) = self.state_manager.get_snapshot().await;
let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
let fips_npub =
crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
let path = format!("/content/{}", content_id);
let (response, _transport) =
@@ -407,11 +408,15 @@ impl RpcHandler {
return Err(anyhow::anyhow!("Invalid v3 onion address"));
}
let fips_npub =
crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
let path = format!("/content/{}/preview", content_id);
debug!("Fetching content preview from {}{} (fips={})", onion, path, fips_npub.is_some());
debug!(
"Fetching content preview from {}{} (fips={})",
onion,
path,
fips_npub.is_some()
);
let (response, _transport) =
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
+49 -5
View File
@@ -1,10 +1,11 @@
use super::RpcHandler;
use anyhow::Result;
use std::sync::Arc;
impl RpcHandler {
/// Route an RPC method name to its handler, returning the result value.
pub(super) async fn dispatch(
&self,
self: &Arc<Self>,
method: &str,
params: Option<serde_json::Value>,
session_token: &Option<String>,
@@ -12,6 +13,7 @@ impl RpcHandler {
match method {
"echo" => self.handle_echo(params).await,
"server.echo" => self.handle_echo(params).await,
"server.get-state" => self.handle_server_get_state().await,
"health" => self.handle_health().await,
"auth.login" => self.handle_auth_login(params).await,
"auth.logout" => self.handle_auth_logout().await,
@@ -36,19 +38,24 @@ impl RpcHandler {
"container-install" => self.handle_container_install(params).await,
"container-start" => self.handle_container_start(params).await,
"container-stop" => self.handle_container_stop(params).await,
"container-restart" => self.handle_container_restart(params).await,
"container-remove" => self.handle_container_remove(params).await,
"container-list" => self.handle_container_list().await,
"container-status" => self.handle_container_status(params).await,
"container-logs" => self.handle_container_logs(params).await,
"container-health" => self.handle_container_health(params).await,
// Package management (for docker-compose apps)
"package.install" => self.handle_package_install(params).await,
// Package management (for docker-compose apps).
// install/uninstall/update return immediately with a
// transitional status; the actual work runs in a background
// tokio::spawn so the HTTP request doesn't block for minutes.
"package.install" => self.clone().spawn_package_install(params).await,
"package.start" => self.handle_package_start(params).await,
"package.stop" => self.handle_package_stop(params).await,
"package.restart" => self.handle_package_restart(params).await,
"package.uninstall" => self.handle_package_uninstall(params).await,
"package.update" => self.handle_package_update(params).await,
"package.uninstall" => self.clone().spawn_package_uninstall(params).await,
"package.update" => self.clone().spawn_package_update(params).await,
"package.credentials" => self.handle_package_credentials(params).await,
"app.filebrowser-token" => self.handle_filebrowser_token().await,
// Bundled app management (for pre-loaded container images)
@@ -91,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
}
@@ -220,6 +241,7 @@ impl RpcHandler {
"registry.list" => self.handle_registry_list().await,
"registry.add" => self.handle_registry_add(params).await,
"registry.remove" => self.handle_registry_remove(params).await,
"registry.set-primary" => self.handle_registry_set_primary(params).await,
"registry.test" => self.handle_registry_test(params).await,
// Streaming ecash payments
@@ -431,6 +453,23 @@ impl RpcHandler {
"update.dismiss" => self.handle_update_dismiss().await,
"update.download" => self.handle_update_download().await,
"update.cancel-download" => self.handle_update_cancel_download().await,
"update.list-mirrors" => self.handle_update_list_mirrors().await,
"update.add-mirror" => {
let p = params.unwrap_or(serde_json::json!({}));
self.handle_update_add_mirror(&p).await
}
"update.remove-mirror" => {
let p = params.unwrap_or(serde_json::json!({}));
self.handle_update_remove_mirror(&p).await
}
"update.set-primary-mirror" => {
let p = params.unwrap_or(serde_json::json!({}));
self.handle_update_set_primary_mirror(&p).await
}
"update.test-mirror" => {
let p = params.unwrap_or(serde_json::json!({}));
self.handle_update_test_mirror(&p).await
}
"update.apply" => self.handle_update_apply().await,
"update.git-apply" => self.handle_update_git_apply().await,
"update.rollback" => self.handle_update_rollback().await,
@@ -507,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();
@@ -403,7 +403,10 @@ impl RpcHandler {
});
let own_fips_npub = match own_fips_npub {
Some(n) => Some(n),
None => crate::fips::service::read_upstream_npub().await.ok().flatten(),
None => crate::fips::service::read_upstream_npub()
.await
.ok()
.flatten(),
};
let state = federation::build_local_state(
@@ -461,8 +464,7 @@ impl RpcHandler {
// the entry causes sync loops where the node syncs with itself
// forever. Drop it quietly — no useful recovery path.
let (own_data, _) = self.state_manager.get_snapshot().await;
let own_did_result =
identity::did_key_from_pubkey_hex(&own_data.server_info.pubkey).ok();
let own_did_result = identity::did_key_from_pubkey_hex(&own_data.server_info.pubkey).ok();
let own_onion_trim = own_data
.server_info
.tor_address
@@ -568,11 +570,7 @@ impl RpcHandler {
let new_peer_did = did.to_string();
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
if let Err(e) = crate::federation::sync_with_peer_by_did(
&data_dir,
&new_peer_did,
)
.await
if let Err(e) = crate::federation::sync_with_peer_by_did(&data_dir, &new_peer_did).await
{
tracing::debug!(
peer_did = %new_peer_did,
+5 -13
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 {
@@ -169,8 +162,7 @@ impl RpcHandler {
if !anchor.address.contains(':') {
anyhow::bail!("address must be host:port (e.g. 192.168.1.116:8668)");
}
let list =
fips::anchors::add(&self.config.data_dir, anchor.clone()).await?;
let list = fips::anchors::add(&self.config.data_dir, anchor.clone()).await?;
// Push just the newly-added anchor into the running daemon so
// the user sees effect without waiting for the periodic apply.
let results = fips::anchors::apply(&[anchor]).await;
@@ -742,24 +742,25 @@ impl RpcHandler {
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
validate_identity_id(id)?;
let relay_urls: Vec<String> = if let Some(arr) = params.get("relays").and_then(|v| v.as_array()) {
arr.iter()
.filter_map(|v| v.as_str())
.map(|s| s.to_string())
.collect()
} else if let Some(single) = params.get("relay").and_then(|v| v.as_str()) {
vec![single.to_string()]
} else {
// Default: every enabled relay in the user's Manage Relays list.
let statuses = crate::nostr_relays::list_relays(&self.config.data_dir)
.await
.unwrap_or_default();
statuses
.into_iter()
.filter(|s| s.enabled)
.map(|s| s.url)
.collect()
};
let relay_urls: Vec<String> =
if let Some(arr) = params.get("relays").and_then(|v| v.as_array()) {
arr.iter()
.filter_map(|v| v.as_str())
.map(|s| s.to_string())
.collect()
} else if let Some(single) = params.get("relay").and_then(|v| v.as_str()) {
vec![single.to_string()]
} else {
// Default: every enabled relay in the user's Manage Relays list.
let statuses = crate::nostr_relays::list_relays(&self.config.data_dir)
.await
.unwrap_or_default();
statuses
.into_iter()
.filter(|s| s.enabled)
.map(|s| s.url)
.collect()
};
if relay_urls.is_empty() {
anyhow::bail!("No enabled relays configured; add one under Manage Relays");
+76 -5
View File
@@ -31,7 +31,7 @@ impl RpcHandler {
let password = params
.get("password")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: password"))?;
.unwrap_or("");
// Validate SSID (prevent command injection)
if ssid.len() > 64 || ssid.contains('\0') {
@@ -284,7 +284,7 @@ async fn scan_wifi() -> Result<Vec<serde_json::Value>> {
let networks: Vec<serde_json::Value> = stdout
.lines()
.filter_map(|line| {
let parts: Vec<&str> = line.splitn(3, ':').collect();
let parts = split_nmcli_escaped(line, 3);
if parts.len() < 3 {
return None;
}
@@ -305,16 +305,87 @@ async fn scan_wifi() -> Result<Vec<serde_json::Value>> {
Ok(networks)
}
fn split_nmcli_escaped(line: &str, limit: usize) -> Vec<String> {
let mut fields = Vec::new();
let mut current = String::new();
let mut chars = line.chars();
while let Some(ch) = chars.next() {
if ch == '\\' {
if let Some(next) = chars.next() {
current.push(next);
}
} else if ch == ':' && fields.len() + 1 < limit {
fields.push(current);
current = String::new();
} else {
current.push(ch);
}
}
fields.push(current);
fields
}
/// 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 mut args = vec![
"connection",
"add",
"type",
"wifi",
"con-name",
&conn_name,
"ifname",
"*",
"ssid",
ssid,
"ipv4.method",
"auto",
"ipv6.method",
"auto",
];
if !password.is_empty() {
args.extend(["wifi-sec.key-mgmt", "wpa-psk", "wifi-sec.psk", password]);
}
let output = tokio::process::Command::new("nmcli")
.args(["device", "wifi", "connect", ssid, "password", password])
.args(args)
.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
);
+9 -19
View File
@@ -3,7 +3,7 @@ use anyhow::{Context, Result};
use base64::Engine;
use serde::{Deserialize, Serialize};
use super::{LndAmount, LndBalanceResponse};
use super::{read_lnd_admin_macaroon, LndAmount, LndBalanceResponse, LND_REST_BASE_URL};
#[derive(Debug, Serialize)]
struct LndInfo {
@@ -34,11 +34,7 @@ struct LndChannelBalanceResponse {
impl RpcHandler {
pub(in crate::api::rpc) async fn handle_lnd_getinfo(&self) -> Result<serde_json::Value> {
let macaroon_path = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
let macaroon_bytes = tokio::fs::read(macaroon_path)
.await
.context("Failed to read LND admin macaroon — is LND installed?")?;
let macaroon_bytes = read_lnd_admin_macaroon().await?;
let macaroon_hex = hex::encode(&macaroon_bytes);
let client = reqwest::Client::builder()
@@ -48,7 +44,7 @@ impl RpcHandler {
.context("Failed to create HTTP client")?;
let get_info: LndGetInfoResponse = client
.get("https://127.0.0.1:8080/v1/getinfo")
.get(format!("{LND_REST_BASE_URL}/v1/getinfo"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
@@ -58,7 +54,7 @@ impl RpcHandler {
.context("Failed to parse LND getinfo response")?;
let channel_balance: LndChannelBalanceResponse = match client
.get("https://127.0.0.1:8080/v1/balance/channels")
.get(format!("{LND_REST_BASE_URL}/v1/balance/channels"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
@@ -74,7 +70,7 @@ impl RpcHandler {
};
let wallet_balance: LndBalanceResponse = match client
.get("https://127.0.0.1:8080/v1/balance/blockchain")
.get(format!("{LND_REST_BASE_URL}/v1/balance/blockchain"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
@@ -114,7 +110,6 @@ impl RpcHandler {
/// for building lndconnect:// URIs in the frontend.
pub(crate) async fn handle_lnd_connect_info(&self) -> Result<serde_json::Value> {
let cert_path = "/var/lib/archipelago/lnd/tls.cert";
let macaroon_path = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
// Read and encode TLS cert (PEM -> DER -> base64url)
let cert_pem = tokio::fs::read_to_string(cert_path)
@@ -130,9 +125,7 @@ impl RpcHandler {
let cert_b64url = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&cert_der);
// Read and encode macaroon (binary -> base64url)
let macaroon_bytes = tokio::fs::read(macaroon_path)
.await
.context("Failed to read LND admin macaroon")?;
let macaroon_bytes = read_lnd_admin_macaroon().await?;
let macaroon_b64url =
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&macaroon_bytes);
@@ -173,7 +166,7 @@ impl RpcHandler {
"cert_base64url": cert_b64url,
"macaroon_base64url": macaroon_b64url,
"tor_onion": tor_onion,
"rest_port": 8080,
"rest_port": 18080,
"grpc_port": 10009,
}))
}
@@ -183,10 +176,7 @@ impl RpcHandler {
pub(in crate::api::rpc) async fn handle_lnd_export_channel_backup(
&self,
) -> Result<serde_json::Value> {
let macaroon_path = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
let macaroon_bytes = tokio::fs::read(macaroon_path)
.await
.context("Failed to read LND admin macaroon")?;
let macaroon_bytes = read_lnd_admin_macaroon().await?;
let macaroon_hex = hex::encode(&macaroon_bytes);
let client = reqwest::Client::builder()
@@ -196,7 +186,7 @@ impl RpcHandler {
.context("Failed to build HTTP client")?;
let resp = client
.get("https://127.0.0.1:8080/v1/channels/backup")
.get(format!("{LND_REST_BASE_URL}/v1/channels/backup"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
+40 -5
View File
@@ -4,7 +4,12 @@ mod payments;
mod wallet;
use crate::api::rpc::RpcHandler;
use anyhow::{Context, Result};
use anyhow::{anyhow, Context, Result};
/// Canonical on-host path for LND's admin macaroon.
pub(crate) const LND_ADMIN_MACAROON_PATH: &str =
"/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
pub(in crate::api) const LND_REST_BASE_URL: &str = "https://127.0.0.1:18080";
// Shared LND response types used by multiple submodules
#[derive(Debug, serde::Deserialize)]
@@ -17,15 +22,45 @@ pub(super) struct LndAmount {
pub sat: Option<String>,
}
/// Read LND's admin macaroon from disk.
///
/// The macaroon lives inside LND's container data dir and is owned by a
/// rootless-podman subordinate UID (typically 100000), mode 640. The
/// archipelago server runs as UID 1000 and therefore cannot read it
/// directly. We first try a plain read (works if an operator has relaxed
/// permissions), then fall back to `sudo cat` — mirroring the pattern
/// already used for Tor hidden-service hostnames.
pub(crate) async fn read_lnd_admin_macaroon() -> Result<Vec<u8>> {
match tokio::fs::read(LND_ADMIN_MACAROON_PATH).await {
Ok(bytes) => Ok(bytes),
Err(direct_err) => {
let output = tokio::process::Command::new("sudo")
.args(["-n", "cat", LND_ADMIN_MACAROON_PATH])
.output()
.await
.with_context(|| {
format!(
"Failed to read LND admin macaroon (direct: {direct_err}); sudo fallback also failed"
)
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow!(
"Failed to read LND admin macaroon — is LND installed? (direct: {direct_err}; sudo: {})",
stderr.trim()
));
}
Ok(output.stdout)
}
}
}
impl RpcHandler {
/// Helper: create an authenticated LND REST client.
/// Returns an HTTP client configured for LND's self-signed TLS and the
/// hex-encoded admin macaroon for request headers.
pub(crate) async fn lnd_client(&self) -> Result<(reqwest::Client, String)> {
let macaroon_path = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
let macaroon_bytes = tokio::fs::read(macaroon_path)
.await
.context("Failed to read LND admin macaroon — is LND installed?")?;
let macaroon_bytes = read_lnd_admin_macaroon().await?;
let macaroon_hex = hex::encode(&macaroon_bytes);
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
+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
+64 -10
View File
@@ -4,27 +4,49 @@ 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"))
.query(&[("type", "WITNESS_PUBKEY_HASH")])
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
.context("LND REST connection failed")?;
let status = resp.status();
let body: serde_json::Value = resp
.json()
.await
.context("Failed to parse newaddress response")?;
if !status.is_success() {
let message = lnd_error_message(&body);
anyhow::bail!(
"LND could not generate a Bitcoin address ({}): {}",
status,
message
);
}
if let Some(error) = body
.get("error")
.or_else(|| body.get("message"))
.and_then(|v| v.as_str())
{
anyhow::bail!("LND could not generate a Bitcoin 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 +91,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 +151,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 +253,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 +311,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 +344,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 +409,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 +438,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 +536,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
@@ -541,3 +563,35 @@ impl RpcHandler {
}))
}
}
fn lnd_error_message(body: &serde_json::Value) -> String {
body.get("message")
.or_else(|| body.get("error"))
.and_then(|v| v.as_str())
.filter(|s| !s.trim().is_empty())
.unwrap_or("unknown LND error")
.to_string()
}
#[cfg(test)]
mod tests {
use super::lnd_error_message;
#[test]
fn lnd_error_message_prefers_message_field() {
let body = serde_json::json!({
"error": "grpc proxy error",
"message": "wallet locked",
});
assert_eq!(lnd_error_message(&body), "wallet locked");
}
#[test]
fn lnd_error_message_falls_back_to_unknown() {
assert_eq!(
lnd_error_message(&serde_json::json!({})),
"unknown LND error"
);
}
}
+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)
@@ -761,7 +761,9 @@ impl RpcHandler {
.await
.map_err(|e| anyhow::anyhow!("Read body failed: {}", e))?;
let meta = blob_store.put(&bytes, &mime, filename_hint, None, false).await?;
let meta = blob_store
.put(&bytes, &mime, filename_hint, None, false)
.await?;
if meta.cid != cid {
anyhow::bail!("CID mismatch: expected {}, got {}", cid, meta.cid);
}
@@ -53,6 +53,7 @@ pub(super) fn sanitize_error_message(msg: &str) -> String {
"Unauthorized",
"Forbidden",
"Not supported",
"Requires",
"requires",
"must be",
"cannot",
+47 -12
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;
@@ -31,6 +32,7 @@ mod streaming;
mod system;
mod tor;
mod totp;
mod transitional;
mod transport;
mod update;
mod vpn;
@@ -39,7 +41,7 @@ mod webhooks;
use crate::auth::AuthManager;
use crate::config::Config;
use crate::container::DevContainerOrchestrator;
use crate::container::{ContainerOrchestrator, DevContainerOrchestrator};
use crate::monitoring::MetricsStore;
use crate::port_allocator::PortAllocator;
use crate::rate_limit::{EndpointRateLimiter, LoginRateLimiter};
@@ -62,7 +64,14 @@ pub(crate) const DEV_DEFAULT_PASSWORD: &str = "password123";
pub struct RpcHandler {
config: Config,
auth_manager: AuthManager,
orchestrator: Option<Arc<DevContainerOrchestrator>>,
/// Shared lifecycle orchestrator (Dev or Prod). Always `Some` in a normal
/// build — the only reason it is `Option` is so tests that don't exercise
/// container RPCs can skip constructing one.
orchestrator: Option<Arc<dyn ContainerOrchestrator>>,
/// Concrete handle to the dev orchestrator, when we're in dev mode. Used by
/// `container-install { manifest_path }` which takes an ad-hoc manifest
/// path and is not part of the shared trait.
dev_orchestrator: Option<Arc<DevContainerOrchestrator>>,
state_manager: Arc<StateManager>,
pub(crate) metrics_store: Arc<MetricsStore>,
port_allocator: Arc<tokio::sync::Mutex<PortAllocator>>,
@@ -79,6 +88,15 @@ pub struct RpcHandler {
/// Our own Ed25519 pubkey hex — needed by ContentRef senders for cap scoping
/// and by ContentRef receivers to request caps scoped to themselves.
pub(crate) self_pubkey_hex: Arc<tokio::sync::RwLock<Option<String>>>,
/// Kick the package scanner to run immediately (bypassing the 60s interval).
/// Used by install/update success paths so the fresh manifest (with populated
/// `interfaces.main.ui`) lands before we flip state to Running — closes the
/// "Launch button is missing for up to 60s after install" UX gap.
pub(crate) scan_kick: Arc<tokio::sync::Notify>,
/// Monotonic counter incremented by the scan loop after each completed scan.
/// Install/update success paths subscribe to this to know when a kicked scan
/// has actually finished before flipping to the terminal state.
pub(crate) scan_tick: Arc<tokio::sync::watch::Sender<u64>>,
}
impl RpcHandler {
@@ -87,15 +105,10 @@ impl RpcHandler {
state_manager: Arc<StateManager>,
metrics_store: Arc<MetricsStore>,
session_store: SessionStore,
orchestrator: Option<Arc<dyn ContainerOrchestrator>>,
dev_orchestrator: Option<Arc<DevContainerOrchestrator>>,
) -> Result<Self> {
let auth_manager = AuthManager::new(config.data_dir.clone());
let orchestrator = if config.dev_mode {
Some(Arc::new(
DevContainerOrchestrator::new(config.clone()).await?,
))
} else {
None
};
let port_allocator = Arc::new(tokio::sync::Mutex::new(
PortAllocator::new(&config.data_dir).await?,
));
@@ -129,6 +142,7 @@ impl RpcHandler {
config,
auth_manager,
orchestrator,
dev_orchestrator,
state_manager,
metrics_store,
port_allocator,
@@ -140,6 +154,8 @@ impl RpcHandler {
transport_router: Arc::new(tokio::sync::RwLock::new(None)),
blob_store: Arc::new(tokio::sync::RwLock::new(None)),
self_pubkey_hex: Arc::new(tokio::sync::RwLock::new(None)),
scan_kick: Arc::new(tokio::sync::Notify::new()),
scan_tick: Arc::new(tokio::sync::watch::channel(0u64).0),
})
}
@@ -180,6 +196,21 @@ impl RpcHandler {
Arc::clone(&self.mesh_service)
}
/// Shared Notify handle the package-scanner loop waits on (in addition to
/// its periodic tick). Install/update success paths call `notify_one()` to
/// trigger an immediate scan so the fresh manifest lands before we flip to
/// the terminal Running state.
pub fn scan_kick(&self) -> Arc<tokio::sync::Notify> {
Arc::clone(&self.scan_kick)
}
/// Sender half of the scan-completion watch channel. The scanner bumps this
/// counter after every finished scan; install/update wait for an advance
/// after kicking so they know the fresh manifest has landed.
pub fn scan_tick(&self) -> Arc<tokio::sync::watch::Sender<u64>> {
Arc::clone(&self.scan_tick)
}
fn cookie_suffix_for_request(&self, headers: &hyper::header::HeaderMap) -> &'static str {
// Only set Secure flag when the original request was over HTTPS.
// Nginx sends X-Forwarded-Proto: https for HTTPS connections.
@@ -197,7 +228,10 @@ impl RpcHandler {
""
}
pub async fn handle(&self, req: Request<hyper::Body>) -> Result<Response<hyper::Body>> {
pub async fn handle(
self: Arc<Self>,
req: Request<hyper::Body>,
) -> Result<Response<hyper::Body>> {
// Extract session cookie before consuming the request
let (parts, body) = req.into_parts();
let session_token = session::extract_session_cookie(&parts.headers);
@@ -269,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"
@@ -376,7 +411,7 @@ impl RpcHandler {
// Route to handler (track latency for metrics)
let rpc_start = std::time::Instant::now();
let result = self.dispatch(&rpc_req.method, params, &session_token).await;
let result = Self::dispatch(&self, &rpc_req.method, params, &session_token).await;
// Record RPC latency for monitoring
let elapsed_ms = rpc_start.elapsed().as_secs_f64() * 1000.0;
@@ -0,0 +1,443 @@
//! Async wrappers for `package.install`, `package.uninstall`, `package.update`.
//!
//! The inner `handle_package_*` functions are large (install is 480 lines with
//! the stack dispatchers, update is 300, uninstall is 200) and do their own
//! fine-grained progress tracking via `install_progress` and `uninstall_stage`.
//! We wrap them rather than refactor them.
//!
//! Each wrapper:
//! 1. Parses + validates the RPC params (cheap, synchronous). Errors here
//! return immediately to the caller before any state change.
//! 2. Flips the package state to the transitional variant
//! (`Installing` / `Removing` / `Updating`) so the UI sees it on the
//! next WebSocket push (before the RPC response even lands).
//! 3. `tokio::spawn`s a background task that invokes the existing
//! `handle_package_*` method on the Arc-held self.
//! 4. On task success: no state change needed — the inner handler has
//! already written the terminal state (Running for install/update, or
//! removed the entry for uninstall).
//! 5. On task failure: revert state to the pre-transition value (or delete
//! the entry for install, since there was no pre-state), write a line
//! to the persistent install log, and clear any stale progress fields.
//! 6. Returns `{ "status": "installing" }` etc. immediately.
//!
//! The server package-scan loop's `merge_preserving_transitional` helper
//! already knows to preserve `Installing` / `Removing` / `Updating` between
//! scans, so live progress updates broadcast from inside the spawned task
//! reach the UI correctly.
use super::install::install_log;
use crate::api::rpc::RpcHandler;
use crate::data_model::PackageState;
use crate::state::StateManager;
use anyhow::Result;
use std::sync::Arc;
use tracing::{error, info, warn};
impl RpcHandler {
/// Async wrapper for `package.install`. Returns `{ "status": "installing" }`
/// immediately after flipping state to `Installing` and spawning the
/// actual install pipeline. On failure, removes the package entry from
/// state so the UI reverts to "not installed".
pub(in crate::api::rpc) async fn spawn_package_install(
self: Arc<Self>,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
// Extract + validate package_id synchronously so bad params fail
// fast without touching state.
let params_val = params
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let package_id = params_val
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?
.to_string();
super::validation::validate_app_id(&package_id)?;
super::dependencies::check_bitcoin_pruning_compatibility(&package_id).await?;
// Reject if already in a transitional lifecycle (prevents double-click
// queuing two installs on the same package).
{
let (data, _) = self.state_manager.get_snapshot().await;
if let Some(entry) = data.package_data.get(&package_id) {
if matches!(
entry.state,
PackageState::Installing | PackageState::Removing | PackageState::Updating
) {
return Err(anyhow::anyhow!(
"{} is already {:?}",
package_id,
entry.state
));
}
}
}
// Flip state to Installing BEFORE the spawn so the first WebSocket
// push carries the transitional state. Uses the same
// `create_installing_entry` path the inner handler would use once
// it starts pulling, so the UI sees a consistent shape.
flip_to_installing(&self.state_manager, &package_id).await;
install_log(&format!("INSTALL SPAWN: {}", package_id)).await;
let handler = Arc::clone(&self);
let package_id_spawn = package_id.clone();
tokio::spawn(async move {
match handler.handle_package_install(params).await {
Ok(_) => {
info!("package.install {}: complete", package_id_spawn);
// The install pipeline has verified the container is up
// and healthy (see install.rs post-start exit check).
// Kick the scanner first so the fresh manifest (with
// `interfaces.main.ui` from the live port binding) lands
// BEFORE we flip to Running — without this the Launch
// button is missing for up to 60s after a successful
// install, because the skeletal install-time manifest
// has `interfaces: None`.
kick_scanner_and_wait(&handler).await;
// We MUST explicitly transition out of Installing here:
// `merge_preserving_transitional` in the package-scan
// loop treats Installing as RPC-owned and refuses to
// let the scanner overwrite it with the observed
// Running state. Without this write, the entry stays
// stuck at Installing forever.
set_package_state(
&handler.state_manager,
&package_id_spawn,
PackageState::Running,
)
.await;
handler.clear_install_progress(&package_id_spawn).await;
}
Err(e) => {
error!("package.install {} failed: {:#}", package_id_spawn, e);
install_log(&format!("INSTALL FAIL: {}{:#}", package_id_spawn, e)).await;
// Don't remove the entry — that's what made the card
// vanish from My Apps mid-install / between retry-loop
// attempts (e.g. tailscale's entrypoint failure). Leave
// the entry visible with state=Stopped + the install
// error in install_progress.message so the user can see
// what went wrong and decide whether to retry or
// uninstall. clear_install_progress would erase the
// message, so we set it explicitly here instead.
let err_msg = format!("Install failed: {:#}", e);
let (mut data, _) = handler.state_manager.get_snapshot().await;
if let Some(entry) = data.package_data.get_mut(&package_id_spawn) {
entry.state = PackageState::Stopped;
entry.install_progress = Some(crate::data_model::InstallProgress {
size: 0,
downloaded: 0,
phase: None,
message: Some(err_msg),
});
handler.state_manager.update_data(data).await;
}
}
}
});
Ok(serde_json::json!({
"status": "installing",
"package_id": package_id,
}))
}
/// Async wrapper for `package.uninstall`. Returns `{ "status": "removing" }`
/// immediately. State stays `Removing` until the inner handler finishes
/// (including the `sudo rm -rf` of app data, which can take minutes for
/// bitcoin-core's chainstate). On failure, reverts to the pre-transition
/// state (usually Running or Stopped) so the user can retry.
pub(in crate::api::rpc) async fn spawn_package_uninstall(
self: Arc<Self>,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params_val = params
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let package_id = params_val
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?
.to_string();
super::validation::validate_app_id(&package_id)?;
// Reject if already in a transitional lifecycle.
{
let (data, _) = self.state_manager.get_snapshot().await;
if let Some(entry) = data.package_data.get(&package_id) {
if matches!(
entry.state,
PackageState::Installing | PackageState::Removing | PackageState::Updating
) {
return Err(anyhow::anyhow!(
"{} is already {:?}",
package_id,
entry.state
));
}
}
}
let pre_state =
flip_package_state(&self.state_manager, &package_id, PackageState::Removing).await;
install_log(&format!("UNINSTALL SPAWN: {}", package_id)).await;
let handler = Arc::clone(&self);
let package_id_spawn = package_id.clone();
tokio::spawn(async move {
match handler.handle_package_uninstall(params).await {
Ok(_) => {
info!("package.uninstall {}: complete", package_id_spawn);
// Inner handler already removed the package entry on
// success. Nothing more to do here.
}
Err(e) => {
error!("package.uninstall {} failed: {:#}", package_id_spawn, e);
install_log(&format!("UNINSTALL FAIL: {}{:#}", package_id_spawn, e)).await;
// Revert to pre-transition state so the user can retry.
// Also clear any stale uninstall_stage label.
if let Some(prev) = pre_state {
set_package_state_and_clear_uninstall_stage(
&handler.state_manager,
&package_id_spawn,
prev,
)
.await;
}
}
}
});
Ok(serde_json::json!({
"status": "removing",
"package_id": package_id,
}))
}
/// Async wrapper for `package.update`. Returns `{ "status": "updating" }`
/// immediately. The inner handler already manages its own rollback on
/// failure (restarts old containers); this wrapper just flips state and
/// spawns.
pub(in crate::api::rpc) async fn spawn_package_update(
self: Arc<Self>,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params_val = params
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let package_id = params_val
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?
.to_string();
super::validation::validate_app_id(&package_id)?;
// Reject if already in a transitional lifecycle.
{
let (data, _) = self.state_manager.get_snapshot().await;
if let Some(entry) = data.package_data.get(&package_id) {
if matches!(
entry.state,
PackageState::Installing | PackageState::Removing | PackageState::Updating
) {
return Err(anyhow::anyhow!(
"{} is already {:?}",
package_id,
entry.state
));
}
}
}
// The inner handler flips state to Updating itself, but we do it
// here too so the transitional state lands before the spawn yields.
let pre_state =
flip_package_state(&self.state_manager, &package_id, PackageState::Updating).await;
install_log(&format!("UPDATE SPAWN: {}", package_id)).await;
let handler = Arc::clone(&self);
let package_id_spawn = package_id.clone();
tokio::spawn(async move {
match handler.handle_package_update(params).await {
Ok(_) => {
info!("package.update {}: complete", package_id_spawn);
// Same reasoning as install: the merge_preserving_transitional
// helper treats Updating as RPC-owned, so we MUST write the
// terminal Running state ourselves or the entry will stay
// stuck at Updating forever. The update pipeline has
// already verified the new container is running via its
// post-recreate check.
// Kick the scanner first so any manifest changes from the
// new image version (interfaces, ports, etc.) land before
// we flip to Running.
kick_scanner_and_wait(&handler).await;
set_package_state(
&handler.state_manager,
&package_id_spawn,
PackageState::Running,
)
.await;
}
Err(e) => {
error!("package.update {} failed: {:#}", package_id_spawn, e);
install_log(&format!("UPDATE FAIL: {}{:#}", package_id_spawn, e)).await;
// Inner handler already ran rollback_update + cleared
// update state, but be defensive: revert to pre-state
// in case the inner flow died before its cleanup.
if let Some(prev) = pre_state {
set_package_state(&handler.state_manager, &package_id_spawn, prev).await;
}
}
}
});
Ok(serde_json::json!({
"status": "updating",
"package_id": package_id,
}))
}
}
// ---------------------------------------------------------------------------
// State-manager helpers (free fns, usable from inside spawned tasks)
// ---------------------------------------------------------------------------
/// Create or update the entry for this package with `Installing` state.
/// Matches what the inner handler's `set_install_progress` would do on first
/// call, but fires before the spawn so the UI sees it immediately.
async fn flip_to_installing(state_manager: &StateManager, package_id: &str) {
use crate::data_model::{Description, Manifest, PackageDataEntry, StaticFiles};
let (mut data, _) = state_manager.get_snapshot().await;
let entry = data
.package_data
.entry(package_id.to_string())
.or_insert_with(|| PackageDataEntry {
state: PackageState::Installing,
health: None,
exit_code: None,
static_files: StaticFiles {
license: String::new(),
instructions: String::new(),
// Leave icon empty during the transient Installing window:
// hardcoding `<id>.png` is wrong for ~half our apps (many use
// `.svg` / `.webp`), producing a broken-image flicker until
// the scanner refreshes the entry. The frontend's `icon`
// computed falls through to `curatedMap.get(id)?.icon` which
// has the correct extensions for known apps.
icon: String::new(),
},
manifest: Manifest {
id: package_id.to_string(),
title: package_id.to_string(),
version: String::new(),
description: Description {
short: "Installing...".to_string(),
long: String::new(),
},
release_notes: String::new(),
license: String::new(),
wrapper_repo: String::new(),
upstream_repo: String::new(),
support_site: String::new(),
marketing_site: String::new(),
donation_url: None,
author: None,
website: None,
interfaces: None,
tier: None,
},
installed: None,
install_progress: None,
uninstall_stage: None,
available_update: None,
});
entry.state = PackageState::Installing;
state_manager.update_data(data).await;
}
/// Flip an existing entry's state and return the pre-flip value (or None if
/// no entry existed). Used for revert-on-failure.
async fn flip_package_state(
state_manager: &StateManager,
package_id: &str,
new_state: PackageState,
) -> Option<PackageState> {
let (mut data, _) = state_manager.get_snapshot().await;
let prev = data.package_data.get(package_id).map(|e| e.state.clone());
if let Some(entry) = data.package_data.get_mut(package_id) {
entry.state = new_state;
state_manager.update_data(data).await;
} else {
warn!(
"flip_package_state: no entry for {} — cannot flip",
package_id
);
}
prev
}
/// Set state unconditionally (no-op if entry no longer exists).
async fn set_package_state(
state_manager: &StateManager,
package_id: &str,
new_state: PackageState,
) {
let (mut data, _) = state_manager.get_snapshot().await;
if let Some(entry) = data.package_data.get_mut(package_id) {
if entry.state != new_state {
entry.state = new_state;
state_manager.update_data(data).await;
}
}
}
/// Set state and clear the uninstall_stage label. Used when an uninstall
/// fails and we revert — the user doesn't want a stale "Removing app data"
/// message sitting on a Running entry.
async fn set_package_state_and_clear_uninstall_stage(
state_manager: &StateManager,
package_id: &str,
new_state: PackageState,
) {
let (mut data, _) = state_manager.get_snapshot().await;
if let Some(entry) = data.package_data.get_mut(package_id) {
entry.state = new_state;
entry.uninstall_stage = None;
state_manager.update_data(data).await;
}
}
/// Kick the container scanner to run immediately and wait for it to finish
/// (with a 2s timeout). Used by install/update success paths so the fresh
/// manifest — with `interfaces.main.ui` populated from the now-running
/// container's port binding — lands BEFORE we flip state to Running.
///
/// Without this, the frontend sees `state = running` but the skeletal
/// install-time manifest (interfaces = None), and hides the Launch button
/// for up to the full 60s scan interval.
///
/// The scan merges via `merge_preserving_transitional`, which keeps
/// state = Installing (we haven't flipped yet) while taking the fresh
/// manifest. After this returns, the caller writes Running on top of the
/// now-populated manifest.
async fn kick_scanner_and_wait(handler: &RpcHandler) {
let mut rx = handler.scan_tick.subscribe();
let start = *rx.borrow_and_update();
handler.scan_kick.notify_one();
// 2s is well above a typical podman scan (~200ms on .228, ~500ms worst
// case). If it times out we proceed anyway — the next 60s scan will
// self-heal and the worst case is the pre-fix behavior (Launch button
// appears a bit late).
let _ = tokio::time::timeout(std::time::Duration::from_secs(2), async {
while *rx.borrow_and_update() == start {
if rx.changed().await.is_err() {
break;
}
}
})
.await;
}
+337 -129
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)]
@@ -9,7 +99,7 @@ pub(super) const TRUSTED_REGISTRIES: &[&str] = &[
"ghcr.io/",
"localhost/",
"git.tx1138.com/",
"23.182.128.160:3000/",
"146.59.87.168:3000/",
];
/// Validate Docker image against trusted registry allowlist.
@@ -29,7 +119,7 @@ pub(super) fn is_valid_docker_image(image: &str) -> bool {
};
matches!(
registry,
"docker.io" | "ghcr.io" | "localhost" | "git.tx1138.com" | "23.182.128.160:3000"
"docker.io" | "ghcr.io" | "localhost" | "git.tx1138.com" | "146.59.87.168:3000"
)
}
@@ -63,26 +153,39 @@ pub(super) fn get_app_capabilities(app_id: &str) -> Vec<String> {
"--cap-add=SETGID".to_string(),
"--cap-add=DAC_OVERRIDE".to_string(),
],
// Nginx Proxy Manager needs to bind low ports
// Nginx Proxy Manager initializes/chowns mounted state on first boot.
"nginx-proxy-manager" => vec![
"--cap-add=CHOWN".to_string(),
"--cap-add=FOWNER".to_string(),
"--cap-add=SETUID".to_string(),
"--cap-add=SETGID".to_string(),
"--cap-add=DAC_OVERRIDE".to_string(),
"--cap-add=NET_BIND_SERVICE".to_string(),
],
// Bitcoin and Lightning need file ownership ops + NET_BIND_SERVICE for port binding
// LND additionally needs NET_RAW for TLS certificate generation (netlinkrib interface enumeration)
"bitcoin" | "bitcoin-core" | "bitcoin-knots" | "lnd" | "fedimint" | "fedimint-gateway" => {
vec![
"--cap-add=CHOWN".to_string(),
"--cap-add=FOWNER".to_string(),
"--cap-add=SETUID".to_string(),
"--cap-add=SETGID".to_string(),
"--cap-add=DAC_OVERRIDE".to_string(),
"--cap-add=NET_BIND_SERVICE".to_string(),
"--cap-add=NET_RAW".to_string(),
]
}
// Bitcoin needs only file-ownership ops + NET_BIND_SERVICE for the
// RPC port. NO NET_RAW — bitcoind never opens raw sockets and
// dropping it removes a class of intra-pod spoofing capability.
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => vec![
"--cap-add=CHOWN".to_string(),
"--cap-add=FOWNER".to_string(),
"--cap-add=SETUID".to_string(),
"--cap-add=SETGID".to_string(),
"--cap-add=DAC_OVERRIDE".to_string(),
"--cap-add=NET_BIND_SERVICE".to_string(),
],
// LND additionally needs NET_RAW for TLS certificate generation
// (netlink interface enumeration during `lnd --tlscertpath` first run).
// Fedimint inherits the same set because the gateway also enumerates
// network interfaces on startup.
"lnd" | "fedimint" | "fedimint-gateway" => vec![
"--cap-add=CHOWN".to_string(),
"--cap-add=FOWNER".to_string(),
"--cap-add=SETUID".to_string(),
"--cap-add=SETGID".to_string(),
"--cap-add=DAC_OVERRIDE".to_string(),
"--cap-add=NET_BIND_SERVICE".to_string(),
"--cap-add=NET_RAW".to_string(),
],
// Vaultwarden needs file ownership + NET_BIND_SERVICE (binds port 80 internally)
"vaultwarden" => vec![
"--cap-add=CHOWN".to_string(),
@@ -114,7 +217,7 @@ pub(super) fn get_app_capabilities(app_id: &str) -> Vec<String> {
"--cap-add=DAC_OVERRIDE".to_string(),
"--cap-add=NET_BIND_SERVICE".to_string(),
],
// Nostr VPN and FIPS: mesh networking daemons need TUN + NET_ADMIN
// VPN/mesh daemons need TUN + NET_ADMIN.
// Note: --device=/dev/net/tun is added separately in install.rs
"nostr-vpn" | "fips" => vec![
"--cap-add=NET_ADMIN".to_string(),
@@ -153,36 +256,35 @@ pub(super) fn is_readonly_compatible(app_id: &str) -> bool {
/// Get container health check arguments for podman run.
/// Returns (health-cmd, interval, retries) args to append to run_args.
pub(super) fn get_health_check_args(app_id: &str, _rpc_pass: &str) -> Vec<String> {
// bitcoin-cli reads the .cookie file from -datadir automatically (no plaintext creds needed)
let btc_health =
"bitcoin-cli -datadir=/home/bitcoin/.bitcoin getblockchaininfo || exit 1".to_string();
let (cmd, interval, retries) = match app_id {
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => (btc_health.as_str(), "30s", "3"),
// Bitcoin images do not consistently ship bitcoin-cli/curl/nc. Rely on
// process state here; manifests still describe the desired TCP check.
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => return vec![],
"lnd" => ("lncli getinfo || exit 1", "30s", "3"),
"btcpay-server" | "btcpayserver" => {
("curl -sf http://localhost:49392/ || exit 1", "30s", "3")
("bash -ec '</dev/tcp/127.0.0.1/49392'", "30s", "3")
}
"mempool-api" => (
"curl -sf http://localhost:8999/api/v1/backend-info || exit 1",
http_probe_cmd("http://localhost:8999/api/v1/backend-info"),
"30s",
"3",
),
"mempool" | "mempool-web" | "archy-mempool-web" => {
("curl -sf http://localhost:8080/ || exit 1", "30s", "3")
(http_probe_cmd("http://localhost:8080/"), "30s", "3")
}
"electrumx" | "mempool-electrs" | "electrs" => {
("curl -sf http://localhost:8000/ || exit 1", "60s", "3")
}
"nextcloud" => (
"curl -sf http://localhost:80/status.php || exit 1",
"curl -s -o /dev/null http://localhost:80/status.php || exit 1",
"30s",
"3",
),
"homeassistant" | "home-assistant" => {
("curl -sf http://localhost:8123/api/ || exit 1", "30s", "3")
("curl -sf http://localhost:8123/ || exit 1", "30s", "3")
}
"grafana" => (
"curl -sf http://localhost:3000/api/health || exit 1",
"test -w /var/lib/grafana && test -w /var/lib/grafana/grafana.db && curl -sf http://localhost:3000/api/health || exit 1",
"30s",
"3",
),
@@ -194,7 +296,12 @@ pub(super) fn get_health_check_args(app_id: &str, _rpc_pass: &str) -> Vec<String
"vaultwarden" => ("curl -sf http://localhost:80/alive || exit 1", "30s", "3"),
"uptime-kuma" => ("curl -sf http://localhost:3001/ || exit 1", "30s", "3"),
"filebrowser" => ("curl -sf http://localhost:80/health || exit 1", "30s", "3"),
"searxng" => ("curl -sf http://localhost:8080/ || exit 1", "30s", "3"),
"botfights" => (
"node -e \"fetch(\\\"http://127.0.0.1:9100/api/health\\\").then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\"",
"30s",
"3",
),
"searxng" => (http_probe_cmd("http://localhost:8080/"), "30s", "3"),
"photoprism" => (
"curl -sf http://localhost:2342/api/v1/status || exit 1",
"60s",
@@ -205,23 +312,12 @@ pub(super) fn get_health_check_args(app_id: &str, _rpc_pass: &str) -> Vec<String
"30s",
"3",
),
"dwn" => (
"curl -sf http://localhost:3000/health || exit 1",
"30s",
"3",
),
"portainer" => (
"curl -sf http://localhost:9000/api/status || exit 1",
"30s",
"3",
),
"portainer" => return vec![],
"ollama" => ("curl -sf http://localhost:11434/ || exit 1", "30s", "3"),
"fedimint" => ("curl -sf http://localhost:8175/ || exit 1", "60s", "3"),
"fedimint-gateway" => ("curl -sf http://localhost:8176/ || exit 1", "60s", "3"),
"nostr-rs-relay" | "nostr-relay" => {
("curl -sf http://localhost:8080/ || exit 1", "30s", "3")
}
"nginx-proxy-manager" => ("curl -sf http://localhost:81/api/ || exit 1", "30s", "3"),
"nostr-rs-relay" | "nostr-relay" => (http_probe_cmd("http://localhost:8080/"), "30s", "3"),
"nginx-proxy-manager" => (http_probe_cmd("http://localhost:81/"), "30s", "3"),
"routstr" => (
"curl -sf http://localhost:8000/v1/models || exit 1",
"30s",
@@ -236,20 +332,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 indexing spikes above its cache size due Python,
// RocksDB, socket buffers, and reorg/history work. Keep cache
// conservative and give the process headroom to avoid restart loops.
"electrumx" | "mempool-electrs" | "electrs" => "6g",
"cryptpad" => "512m",
"ollama" => "4g",
// Medium apps
"lnd" => "512m",
"electrumx" | "mempool-electrs" | "electrs" => "1g",
"nextcloud" => "1g",
"immich_server" | "immich" => "1g",
"btcpay-server" | "btcpayserver" => "1g",
@@ -266,16 +379,17 @@ pub(super) fn get_memory_limit(app_id: &str) -> &'static str {
"uptime-kuma" => "256m",
"filebrowser" => "256m",
"searxng" => "512m",
"dwn" => "256m",
"portainer" => "256m",
"nostr-rs-relay" | "nostr-relay" => "256m",
"routstr" => "512m",
"nostr-vpn" => "256m",
"netbird" => "1g",
"fips" => "256m",
"nginx-proxy-manager" => "256m",
// Databases
"archy-btcpay-db" | "archy-mempool-db" | "mysql-mempool" => "512m",
"immich_postgres" | "penpot-postgres" => "256m",
"immich_postgres" => "2g",
"penpot-postgres" => "256m",
"immich_redis" | "penpot-valkey" => "128m",
// Default
_ => "512m",
@@ -291,16 +405,23 @@ pub(super) fn all_container_names(package_id: &str) -> Vec<String> {
let archy = format!("archy-{}", package_id);
match package_id {
// Bitcoin: multiple historical names
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => vec![
// Bitcoin variants share the UI but not the backend process. Keep
// backend names precise so stopping one implementation does not clear
// stop markers or issue podman operations for the other.
"bitcoin" | "bitcoin-knots" => vec![
"bitcoin-knots".into(),
"bitcoin".into(),
"bitcoin-core".into(),
"archy-bitcoin-knots".into(),
"archy-bitcoin".into(),
"bitcoin-ui".into(),
"archy-bitcoin-ui".into(),
],
"bitcoin-core" => vec![
"bitcoin-core".into(),
"archy-bitcoin-core".into(),
"bitcoin-ui".into(),
"archy-bitcoin-ui".into(),
],
// LND + UI
"lnd" => vec!["lnd".into(), "archy-lnd".into(), "archy-lnd-ui".into()],
// Electrumx: multiple aliases
@@ -359,6 +480,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(),
@@ -373,12 +508,14 @@ pub(super) fn all_container_names(package_id: &str) -> Vec<String> {
/// Find all running/stopped containers that belong to a given app.
/// Uses the canonical name list from all_container_names().
pub(super) async fn get_containers_for_app(package_id: &str) -> Result<Vec<String>> {
pub(in crate::api::rpc) async fn get_containers_for_app(package_id: &str) -> Result<Vec<String>> {
validate_app_id(package_id)?;
let output = tokio::process::Command::new("podman")
.args(["ps", "-a", "--format", "{{.Names}}"])
.output()
let mut cmd = tokio::process::Command::new("podman");
cmd.args(["ps", "-a", "--format", "{{.Names}}"]);
cmd.kill_on_drop(true);
let output = tokio::time::timeout(PODMAN_LIST_TIMEOUT, cmd.output())
.await
.context("podman ps timed out while listing containers")?
.context("Failed to list containers")?;
let stdout = String::from_utf8_lossy(&output.stdout);
let all: Vec<&str> = stdout.lines().filter(|s| !s.is_empty()).collect();
@@ -393,16 +530,47 @@ pub(super) async fn get_containers_for_app(package_id: &str) -> Result<Vec<Strin
Ok(result)
}
#[cfg(test)]
mod tests {
use super::{all_container_names, get_health_check_args};
#[test]
fn bitcoin_variant_container_names_are_precise() {
let core = all_container_names("bitcoin-core");
assert!(core.contains(&"bitcoin-core".to_string()));
assert!(!core.contains(&"bitcoin-knots".to_string()));
let knots = all_container_names("bitcoin-knots");
assert!(knots.contains(&"bitcoin-knots".to_string()));
assert!(!knots.contains(&"bitcoin-core".to_string()));
}
#[test]
fn grafana_health_requires_writable_data_and_http_health() {
let args = get_health_check_args("grafana", "unused");
let health_cmd = args
.iter()
.find_map(|arg| arg.strip_prefix("--health-cmd="))
.expect("grafana should have a health command");
assert!(health_cmd.contains("test -w /var/lib/grafana"));
assert!(health_cmd.contains("test -w /var/lib/grafana/grafana.db"));
assert!(health_cmd.contains("http://localhost:3000/api/health"));
}
}
/// Get data directories to clean for an app.
/// Caller must validate package_id before calling.
pub(super) fn get_data_dirs_for_app(package_id: &str) -> Vec<String> {
let base = "/var/lib/archipelago";
match package_id {
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => {
vec![format!("{}/bitcoin", base), format!("{}/bitcoin-ui", base)]
}
"mempool" | "mempool-web" => vec![
format!("{}/mempool", base),
format!("{}/mysql-mempool", base),
format!("{}/electrumx", base),
format!("{}/mempool-electrs", base),
],
"fedimint" => vec![
format!("{}/fedimint", base),
@@ -414,6 +582,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 +644,10 @@ pub(super) async fn get_app_config(
Option<String>,
Option<Vec<String>>,
) {
if let Some(config) = dynamic_app_config(app_id).await {
return config;
}
match app_id {
"homeassistant" | "home-assistant" => (
vec!["8123:8123".to_string()],
@@ -483,7 +656,43 @@ pub(super) async fn get_app_config(
None,
None,
),
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => (
"bitcoin-core" => (
vec![
"8332:8332".to_string(),
"8333:8333".to_string(),
"28332:28332".to_string(),
"28333:28333".to_string(),
],
vec!["/var/lib/archipelago/bitcoin:/home/bitcoin/.bitcoin".to_string()],
vec![],
None,
// Vanilla bitcoin/bitcoin image has no entrypoint wrapper and reads
// only what's in bitcoin.conf + argv. The shared bitcoin.conf
// carries rpcauth; we inject the networking flags as CLI args so
// RPC is reachable from the bitcoin-ui companion container.
//
// Sync-speed flags:
// -dbcache=4096 — UTXO set cache; 4GB is the sweet spot before
// diminishing returns. Container has --memory=8g now so
// there's headroom for mempool + connections.
// -par=0 — use all available cores for script
// verification (defaults to NCPU-1 capped at 16). Was
// effectively pinned at 2 by --cpus=2 (now removed).
// -maxconnections=125 — default but explicit, so ops can
// tune downward on bandwidth-constrained nodes.
Some(vec![
"-server=1".to_string(),
"-rpcbind=0.0.0.0".to_string(),
"-rpcallowip=0.0.0.0/0".to_string(),
"-rpcport=8332".to_string(),
"-printtoconsole=1".to_string(),
"-datadir=/home/bitcoin/.bitcoin".to_string(),
"-dbcache=4096".to_string(),
"-par=0".to_string(),
"-maxconnections=125".to_string(),
]),
),
"bitcoin" | "bitcoin-knots" => (
vec![
"8332:8332".to_string(),
"8333:8333".to_string(),
@@ -499,7 +708,7 @@ pub(super) async fn get_app_config(
vec![
"9735:9735".to_string(),
"10009:10009".to_string(),
"8080:8080".to_string(),
"18080:8080".to_string(),
],
vec!["/var/lib/archipelago/lnd:/root/.lnd".to_string()],
vec![],
@@ -510,9 +719,7 @@ pub(super) async fn get_app_config(
"--bitcoin.node=bitcoind".to_string(),
format!("--bitcoind.rpcuser={}", rpc_user),
format!("--bitcoind.rpcpass={}", rpc_pass),
"--bitcoind.rpchost=host.containers.internal:8332".to_string(),
"--bitcoind.zmqpubrawblock=tcp://host.containers.internal:28332".to_string(),
"--bitcoind.zmqpubrawtx=tcp://host.containers.internal:28333".to_string(),
"--bitcoind.rpchost=bitcoin-knots:8332".to_string(),
"--rpclisten=0.0.0.0:10009".to_string(),
"--restlisten=0.0.0.0:8080".to_string(),
"--listen=0.0.0.0:9735".to_string(),
@@ -526,7 +733,8 @@ pub(super) async fn get_app_config(
"BTCPAY_PROTOCOL=http".to_string(),
format!("BTCPAY_HOST={}:23000", host_ip),
"BTCPAY_CHAINS=btc".to_string(),
format!("BTCPAY_BTCRPCURL=http://{}:8332", host_ip),
"BTCPAY_BTCEXPLORERURL=http://archy-nbxplorer:32838".to_string(),
"BTCPAY_BTCRPCURL=http://bitcoin-knots:8332".to_string(),
format!("BTCPAY_BTCRPCUSER={}", rpc_user),
format!("BTCPAY_BTCRPCPASSWORD={}", rpc_pass),
format!("BTCPAY_POSTGRES=User ID=btcpay;Password={};Host=archy-btcpay-db;Port=5432;Database=btcpay;Include Error Detail=true",
@@ -538,7 +746,7 @@ pub(super) async fn get_app_config(
"mempool" | "mempool-web" => (
vec!["4080:8080".to_string()],
vec![],
vec![format!("BACKEND_MAINNET_HTTP_HOST={}", host_ip)],
vec!["BACKEND_MAINNET_HTTP_HOST=mempool-api".to_string()],
None,
None,
),
@@ -547,12 +755,12 @@ pub(super) async fn get_app_config(
vec!["/var/lib/archipelago/mempool:/data".to_string()],
vec![
"MEMPOOL_BACKEND=electrum".to_string(),
"ELECTRUM_HOST=host.containers.internal".to_string(),
"ELECTRUM_HOST=electrumx".to_string(),
"ELECTRUM_PORT=50001".to_string(),
"ELECTRUM_TLS_ENABLED=false".to_string(),
format!("CORE_RPC_HOST={}", host_ip),
"CORE_RPC_HOST=bitcoin-knots".to_string(),
"CORE_RPC_PORT=8332".to_string(),
format!("CORE_RPC_USERNAME={}", rpc_user),
"CORE_RPC_USERNAME=archipelago".to_string(),
format!("CORE_RPC_PASSWORD={}", rpc_pass),
"DATABASE_ENABLED=true".to_string(),
"DATABASE_HOST=archy-mempool-db".to_string(),
@@ -569,12 +777,18 @@ pub(super) async fn get_app_config(
vec!["/var/lib/archipelago/electrumx:/data".to_string()],
vec![
format!(
"DAEMON_URL=http://{}:{}@host.containers.internal:8332/",
"DAEMON_URL=http://{}:{}@bitcoin-knots:8332/",
rpc_user, rpc_pass
),
"COIN=Bitcoin".to_string(),
"DB_DIRECTORY=/data".to_string(),
"SERVICES=tcp://:50001,rpc://0.0.0.0:8000".to_string(),
// Keep cache below the container limit; high values
// have caused OOM/restart loops during catch-up.
"CACHE_MB=1024".to_string(),
// Block-fetcher concurrency — defaults are conservative
// for shared hosts; 4 is plenty for one bitcoind backend.
"MAX_SEND=10000000".to_string(),
],
None,
None,
@@ -587,7 +801,7 @@ pub(super) async fn get_app_config(
"MYSQL_DATABASE=mempool".to_string(),
"MYSQL_USER=mempool".to_string(),
format!("MYSQL_PASSWORD={}", read_secret("mempool-db-password", "mempoolpass")),
format!("MYSQL_ROOT_PASSWORD={}", read_secret("mempool-db-root-password", "rootpass")),
format!("MYSQL_ROOT_PASSWORD={}", read_secret("mysql-root-db-password", "rootpass")),
],
None,
None,
@@ -711,45 +925,71 @@ pub(super) async fn get_app_config(
]),
)
}
"nginx-proxy-manager" => (
vec![
"81:81".to_string(),
"8084:80".to_string(),
"8443:443".to_string(),
],
vec![
"/var/lib/archipelago/nginx-proxy-manager/data:/data".to_string(),
"/var/lib/archipelago/nginx-proxy-manager/letsencrypt:/etc/letsencrypt".to_string(),
],
vec![],
None,
None,
),
"nginx-proxy-manager" => {
let admin_port = allocator
.allocate_or_get(app_id, 8081, 81)
.await
.unwrap_or(8081);
let http_port = allocator
.allocate_or_get("nginx-proxy-manager-http", 8084, 80)
.await
.unwrap_or(8084);
let https_port = allocator
.allocate_or_get("nginx-proxy-manager-https", 8444, 443)
.await
.unwrap_or(8444);
(
vec![
format!("{}:81", admin_port),
format!("{}:80", http_port),
format!("{}:443", https_port),
],
vec![
"/var/lib/archipelago/nginx-proxy-manager/data:/data".to_string(),
"/var/lib/archipelago/nginx-proxy-manager/letsencrypt:/etc/letsencrypt".to_string(),
],
vec![],
None,
None,
)
}
"portainer" => (
vec!["9000:9000".to_string()],
vec![
"/var/lib/archipelago/portainer:/data".to_string(),
"/var/run/podman/podman.sock:/var/run/docker.sock".to_string(),
"/run/user/1000/podman/podman.sock:/var/run/docker.sock".to_string(),
"/var/lib/archipelago/portainer/compose:/data/compose".to_string(),
],
vec![],
None,
None,
),
"uptime-kuma" => (
vec!["3001:3001".to_string()],
vec!["3002:3001".to_string()],
vec!["/var/lib/archipelago/uptime-kuma:/app/data".to_string()],
vec!["TZ=UTC".to_string()],
None,
None,
Some(vec![
"--".to_string(),
"node".to_string(),
"server/server.js".to_string(),
]),
),
"tailscale" => (
vec!["8240:8240".to_string()],
vec!["/var/lib/archipelago/tailscale:/var/lib/tailscale".to_string()],
vec!["TS_STATE_DIR=/var/lib/tailscale".to_string()],
Some(
"sh -c 'tailscale web --listen 0.0.0.0:8240 & exec tailscaled'".to_string(),
),
// Don't use custom_command (Option<String>) — install.rs passes
// it as a SINGLE arg to podman, which then treats the whole
// "sh -c 'tailscale web …'" string as the executable name and
// fails: "executable file `sh -c 'tailscale web …'` not found".
// custom_args (Option<Vec<String>>) splits properly.
None,
Some(vec![
"sh".to_string(),
"-c".to_string(),
"tailscaled --tun=userspace-networking & 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" => (
vec![
@@ -768,13 +1008,13 @@ pub(super) async fn get_app_config(
"FM_BIND_UI=0.0.0.0:8175".to_string(),
format!("FM_P2P_URL=fedimint://{}:8173", host_ip),
format!("FM_API_URL=ws://{}:8174", host_ip),
format!("FM_BITCOIND_URL=http://{}:8332", host_ip),
"FM_BITCOIND_URL=http://bitcoin-knots:8332".to_string(),
],
None,
Some(vec![
"--data-dir".to_string(),
"/data".to_string(),
format!("--bitcoind-url=http://{}:{}@{}:8332", rpc_user, rpc_pass, host_ip),
format!("--bitcoind-url=http://{}:{}@bitcoin-knots:8332", rpc_user, rpc_pass),
]),
),
"fedimint-gateway" => {
@@ -798,7 +1038,7 @@ pub(super) async fn get_app_config(
"--network".to_string(),
"bitcoin".to_string(),
"--bitcoind-url".to_string(),
format!("http://{}:8332", host_ip),
"http://bitcoin-knots:8332".to_string(),
"--bitcoind-username".to_string(),
rpc_user.to_string(),
"--bitcoind-password".to_string(),
@@ -881,18 +1121,6 @@ pub(super) async fn get_app_config(
None,
)
}
"dwn" => (
vec!["3100:3000".to_string()],
vec!["/var/lib/archipelago/dwn:/dwn/data".to_string()],
vec![
"DS_PORT=3000".to_string(),
"DS_MESSAGES_STORE_URI=level://data/messages".to_string(),
"DS_DATA_STORE_URI=level://data/data".to_string(),
"DS_EVENT_LOG_URI=level://data/events".to_string(),
],
None,
None,
),
"botfights" => {
let jwt_secret = read_or_generate_secret("botfights-jwt").await;
(
@@ -909,8 +1137,8 @@ pub(super) async fn get_app_config(
None,
)
}
// Gitea binds to 3001 internally. Nginx on port 3000 strips X-Frame-Options
// so Gitea works in Archipelago's iframe. See nginx-gitea-iframe.conf.
// Gitea listens on container port 3000 and is launched directly on
// host port 3001 because it blocks iframe embedding.
"gitea" => (
vec!["3001:3000".to_string(), "2222:22".to_string()],
vec![
@@ -931,31 +1159,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
+3 -1
View File
@@ -1,3 +1,4 @@
mod async_lifecycle;
mod config;
mod dependencies;
mod install;
@@ -8,5 +9,6 @@ mod stacks;
mod update;
mod validation;
// Re-export items needed by sibling modules (container.rs, security.rs)
// Re-export items needed by sibling modules (container.rs, security.rs, transitional.rs)
pub(in crate::api::rpc) use install::install_log;
pub(super) use validation::validate_app_id;
@@ -2,12 +2,17 @@
use crate::api::rpc::RpcHandler;
use crate::data_model::{
Description, InstallProgress, Manifest, PackageDataEntry, PackageState, StaticFiles,
Description, InstallPhase, InstallProgress, Manifest, PackageDataEntry, PackageState,
StaticFiles,
};
impl RpcHandler {
/// Set install progress for a package and broadcast the update.
/// Creates a minimal package entry if one doesn't exist yet.
///
/// Prefer `set_install_phase` — this byte-counter API is kept for
/// the rare case where the pull stream actually parses, but podman
/// almost never emits parseable progress on a piped stderr.
pub(super) async fn set_install_progress(&self, package_id: &str, downloaded: u64, size: u64) {
let (mut data, _rev) = self.state_manager.get_snapshot().await;
let entry = data
@@ -15,7 +20,44 @@ impl RpcHandler {
.entry(package_id.to_string())
.or_insert_with(|| create_installing_entry(package_id));
entry.state = PackageState::Installing;
entry.install_progress = Some(InstallProgress { size, downloaded });
let existing_phase = entry.install_progress.as_ref().and_then(|p| p.phase);
entry.install_progress = Some(InstallProgress {
size,
downloaded,
phase: existing_phase,
message: None,
});
self.state_manager.update_data(data).await;
}
/// Set the install pipeline phase and broadcast. This is the
/// primary progress signal — the UI maps each phase to a
/// percentage and a user-facing label. Byte counters are retained
/// for the rare case podman emits parseable progress.
pub(super) async fn set_install_phase(&self, package_id: &str, phase: InstallPhase) {
let (mut data, _rev) = self.state_manager.get_snapshot().await;
let entry = data
.package_data
.entry(package_id.to_string())
.or_insert_with(|| create_installing_entry(package_id));
// Preparing / PullingImage / CreatingContainer / StartingContainer /
// WaitingHealthy / PostInstall all map to the Installing state.
// Updates use Updating state — the wrapper has already flipped
// state to Updating, so don't clobber it.
if entry.state != PackageState::Updating {
entry.state = PackageState::Installing;
}
let (size, downloaded) = entry
.install_progress
.as_ref()
.map(|p| (p.size, p.downloaded))
.unwrap_or((0, 0));
entry.install_progress = Some(InstallProgress {
size,
downloaded,
phase: Some(phase),
message: None,
});
self.state_manager.update_data(data).await;
}
@@ -28,6 +70,18 @@ impl RpcHandler {
self.state_manager.update_data(data).await;
}
/// Set the uninstall stage label so the UI can show what's happening
/// instead of a generic spinner. Each call broadcasts a state change
/// — call sparingly (one per pipeline phase, not per container).
pub(super) async fn set_uninstall_stage(&self, package_id: &str, stage: &str) {
let (mut data, _rev) = self.state_manager.get_snapshot().await;
if let Some(entry) = data.package_data.get_mut(package_id) {
entry.uninstall_stage = Some(stage.to_string());
entry.state = crate::data_model::PackageState::Removing;
}
self.state_manager.update_data(data).await;
}
/// Update install progress (static method for use in async closures).
pub(super) async fn update_install_progress(
state_manager: &crate::state::StateManager,
@@ -40,9 +94,12 @@ impl RpcHandler {
.package_data
.entry(package_id.to_string())
.or_insert_with(|| create_installing_entry(package_id));
let existing_phase = entry.install_progress.as_ref().and_then(|p| p.phase);
entry.install_progress = Some(InstallProgress {
size: total,
downloaded,
phase: existing_phase,
message: None,
});
state_manager.update_data(data).await;
}
@@ -57,7 +114,11 @@ fn create_installing_entry(package_id: &str) -> PackageDataEntry {
static_files: StaticFiles {
license: String::new(),
instructions: String::new(),
icon: format!("/assets/img/app-icons/{}.png", package_id),
// Empty icon: hardcoding `<id>.png` is wrong for apps that use
// `.svg` or `.webp` assets and produces a broken-image flicker.
// The frontend's `icon` computed falls through to the curated
// map which has correct extensions for known apps.
icon: String::new(),
},
manifest: Manifest {
id: package_id.to_string(),
@@ -81,6 +142,7 @@ fn create_installing_entry(package_id: &str) -> PackageDataEntry {
},
installed: None,
install_progress: None,
uninstall_stage: None,
available_update: None,
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+301 -59
View File
@@ -1,7 +1,7 @@
//! Per-app manual update handler.
//!
//! Flow: validate → set Updating state → graceful stop → pull new image(s) →
//! remove old container(s) → recreate via reconcile script → verify running.
//! remove old container(s) → recreate (orchestrator-first, legacy fallback) → verify running.
//! Data volumes are preserved (bind mounts, not stored in container).
use super::config::get_containers_for_app;
@@ -11,11 +11,13 @@ use super::runtime::stop_timeout_secs;
use super::validation::validate_app_id;
use crate::api::rpc::RpcHandler;
use crate::container::image_versions;
use crate::data_model::PackageState;
use crate::data_model::{InstallPhase, PackageState};
use anyhow::{Context, Result};
use tokio::io::{AsyncBufReadExt, BufReader};
use tracing::{error, info, warn};
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.
@@ -34,15 +36,10 @@ impl RpcHandler {
let pinned = image_versions::pinned_image_for_app(package_id)
.ok_or_else(|| anyhow::anyhow!("No pinned image found for {}", package_id))?;
// Reject if already updating
{
let (data, _) = self.state_manager.get_snapshot().await;
if let Some(entry) = data.package_data.get(package_id) {
if entry.state == PackageState::Updating {
return Err(anyhow::anyhow!("{} is already updating", package_id));
}
}
}
// Note: the `already updating` guard lives in `spawn_package_update`
// (the async wrapper that dispatch actually routes to). By the time
// this inner function runs, the wrapper has already flipped state to
// `Updating`, so duplicating the check here would be a false positive.
install_log(&format!("UPDATE: {}{}", package_id, pinned)).await;
@@ -56,6 +53,64 @@ impl RpcHandler {
self.state_manager.update_data(data).await;
}
// Preferred path: for single-container apps managed by manifests, route
// updates through the orchestrator's upgrade lifecycle instead of the
// legacy shell/CLI flow. Keep stack-style packages on legacy for now.
if should_try_orchestrator_update(package_id, self.orchestrator.is_some()) {
let orchestrator_app_id = orchestrator_update_app_id(package_id);
self.set_install_phase(package_id, InstallPhase::Preparing)
.await;
install_log(&format!(
"UPDATE ORCH: {} — attempting orchestrator upgrade as {}",
package_id, orchestrator_app_id
))
.await;
if let Some(orchestrator) = self.orchestrator.as_ref() {
match orchestrator.upgrade(orchestrator_app_id).await {
Ok(()) => {
self.set_install_phase(package_id, InstallPhase::WaitingHealthy)
.await;
if let Ok(health) = orchestrator.health(orchestrator_app_id).await {
if health != "healthy" {
warn!(
"Update {}: orchestrator upgrade completed with health={} (expected healthy)",
package_id, health
);
}
}
install_log(&format!(
"UPDATE ORCH OK: {} (app={})",
package_id, orchestrator_app_id
))
.await;
self.clear_install_progress(package_id).await;
return Ok(serde_json::json!({
"status": "updated",
"package_id": package_id,
}));
}
Err(e) if is_unknown_app_id_error(&e) => {
info!(
"Update {}: orchestrator has no manifest mapping yet, falling back to legacy updater",
package_id
);
install_log(&format!(
"UPDATE ORCH SKIP: {} — unknown app_id, using legacy flow",
package_id
))
.await;
}
Err(e) => {
install_log(&format!("UPDATE ORCH FAIL: {}{}", package_id, e)).await;
self.clear_install_progress(package_id).await;
self.clear_update_state(package_id).await;
return Err(e.context(format!("Orchestrator update {} failed", package_id)));
}
}
}
}
// Resolve images to pull — either a stack or single container
let images_to_pull = self.resolve_images_to_pull(package_id, &pinned);
@@ -101,6 +156,11 @@ impl RpcHandler {
containers: &[String],
images_to_pull: &[(String, String)],
) -> Result<()> {
// Phase: Preparing — about to stop the running container(s) so
// we can swap images. Fast.
self.set_install_phase(package_id, InstallPhase::Preparing)
.await;
// 1. Graceful stop all containers (reverse order for dependencies)
info!(
"Update {}: stopping {} containers",
@@ -130,6 +190,10 @@ impl RpcHandler {
}
}
// Phase: PullingImage — about to fetch each pinned image in turn.
self.set_install_phase(package_id, InstallPhase::PullingImage)
.await;
// 2. Pull new images with progress
info!(
"Update {}: pulling {} images",
@@ -173,38 +237,23 @@ impl RpcHandler {
}
}
// 4. Recreate via reconcile script (single source of truth for container specs)
info!("Update {}: recreating containers via reconcile", package_id);
// Phase: CreatingContainer — about to recreate each container.
self.set_install_phase(package_id, InstallPhase::CreatingContainer)
.await;
// 4. Recreate containers (orchestrator-first, reconcile fallback)
info!("Update {}: recreating containers", package_id);
for name in containers {
let out = tokio::process::Command::new("bash")
.args([
"/opt/archipelago/scripts/reconcile-containers.sh",
&format!("--container={}", name),
"--force",
])
.output()
.await
.context(format!("Failed to reconcile {}", name))?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
let stdout = String::from_utf8_lossy(&out.stdout);
error!(
"Update {}: reconcile {} failed:\nstdout: {}\nstderr: {}",
package_id,
name,
stdout.trim(),
stderr.trim()
);
return Err(anyhow::anyhow!(
"Reconcile failed for {}: {}",
name,
stderr.trim()
));
}
self.recreate_container_for_update(package_id, name).await?;
// Brief delay between containers for dependency initialization
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
}
// Phase: WaitingHealthy — reconcile has started every container,
// now verifying each reached running state.
self.set_install_phase(package_id, InstallPhase::WaitingHealthy)
.await;
// 5. Verify containers reached running state
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
for name in containers {
@@ -226,34 +275,100 @@ impl RpcHandler {
Ok(())
}
async fn recreate_container_for_update(
&self,
package_id: &str,
container_name: &str,
) -> Result<()> {
let Some(orchestrator) = self.orchestrator.as_ref() else {
return Err(anyhow::anyhow!(
"Cannot recreate {} during update {}: orchestrator unavailable",
container_name,
package_id
));
};
let mut attempted = Vec::new();
for app_id in candidate_app_ids_for_container(container_name) {
attempted.push(app_id.clone());
match orchestrator.install(&app_id).await {
Ok(created_name) => {
install_log(&format!(
"UPDATE ORCH RECREATE OK: {} — container={} app_id={} created={}",
package_id, container_name, app_id, created_name
))
.await;
return Ok(());
}
Err(e) if is_unknown_app_id_error(&e) => {
continue;
}
Err(e) => {
return Err(e.context(format!(
"orchestrator recreate failed for update {} (container={}, app_id={})",
package_id, container_name, app_id
)));
}
}
}
Err(anyhow::anyhow!(
"No manifest mapping found while recreating {} during update {} (attempted app_ids: {})",
container_name,
package_id,
attempted.join(", ")
))
}
/// Pull a single image with progress broadcasting (reuses install progress pattern).
async fn pull_update_image(&self, package_id: &str, image: &str) -> Result<()> {
self.set_install_progress(package_id, 0, 0).await;
let mut child = tokio::process::Command::new("podman")
.args(["pull", image])
let mut cmd = tokio::process::Command::new("podman");
cmd.arg("pull");
if archipelago_container::image_uses_insecure_registry(image) {
cmd.arg("--tls-verify=false");
}
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));
}
@@ -296,15 +411,16 @@ impl RpcHandler {
Ok(o) => {
let stderr = String::from_utf8_lossy(&o.stderr);
warn!("Rollback: could not restart {}: {}", name, stderr.trim());
// Container was already removed — try reconcile to recreate with old image
let _ = tokio::process::Command::new("bash")
.args([
"/opt/archipelago/scripts/reconcile-containers.sh",
&format!("--container={}", name),
"--force",
])
.output()
.await;
// Container was already removed (forward path ran `podman rm`).
// Recreate via orchestrator-first path with legacy fallback.
if let Err(recreate_err) =
self.recreate_container_for_update(package_id, name).await
{
error!(
"Rollback: failed to recreate {} during rollback of {}: {}",
name, package_id, recreate_err
);
}
}
Err(e) => {
error!("Rollback: failed to restart {}: {}", name, e);
@@ -325,3 +441,129 @@ impl RpcHandler {
self.state_manager.update_data(data).await;
}
}
fn should_try_orchestrator_update(package_id: &str, orchestrator_available: bool) -> bool {
orchestrator_available && !uses_legacy_update_flow(package_id)
}
fn orchestrator_update_app_id(package_id: &str) -> &str {
match package_id {
"electrs" | "mempool-electrs" => "electrumx",
_ => package_id,
}
}
fn uses_legacy_update_flow(package_id: &str) -> bool {
matches!(
package_id,
// Multi-container stacks still updated via the stack-aware path.
"immich" | "penpot" | "penpot-frontend" | "indeedhub"
)
}
fn is_unknown_app_id_error(err: &anyhow::Error) -> bool {
err.chain()
.any(|cause| cause.to_string().contains("unknown app_id"))
}
fn candidate_app_ids_for_container(container_name: &str) -> Vec<String> {
let mut out = Vec::new();
let mut push = |s: &str| {
if !out.iter().any(|e: &String| e == s) {
out.push(s.to_string());
}
};
match container_name {
"bitcoin-knots" | "bitcoin-core" => {
push("bitcoin-knots");
push("bitcoin-core");
}
"archy-bitcoin-ui" => push("bitcoin-ui"),
"archy-lnd-ui" => push("lnd-ui"),
"archy-electrs-ui" => push("electrs-ui"),
"mempool" => {
push("archy-mempool-web");
push("mempool");
}
_ => {}
}
push(container_name);
if let Some(stripped) = container_name.strip_prefix("archy-") {
push(stripped);
}
out
}
#[cfg(test)]
mod tests {
use super::{
candidate_app_ids_for_container, orchestrator_update_app_id,
should_try_orchestrator_update, uses_legacy_update_flow,
};
#[test]
fn legacy_flow_for_stack_apps() {
for app in ["immich", "penpot", "indeedhub"] {
assert!(uses_legacy_update_flow(app), "{app} should stay legacy");
}
}
#[test]
fn orchestrator_flow_for_single_apps() {
for app in [
"lnd",
"bitcoin-core",
"searxng",
"grafana",
"btcpay-server",
"mempool",
"fedimint",
] {
assert!(
!uses_legacy_update_flow(app),
"{app} should be orchestrator-first"
);
assert!(
should_try_orchestrator_update(app, true),
"{app} should use orchestrator when available"
);
}
}
#[test]
fn no_orchestrator_means_no_orchestrator_flow() {
assert!(!should_try_orchestrator_update("lnd", false));
assert!(!should_try_orchestrator_update("btcpay-server", false));
}
#[test]
fn container_name_candidates_cover_common_aliases() {
assert_eq!(
candidate_app_ids_for_container("bitcoin-knots"),
vec!["bitcoin-knots", "bitcoin-core"]
);
assert_eq!(
candidate_app_ids_for_container("archy-bitcoin-ui"),
vec!["bitcoin-ui", "archy-bitcoin-ui"]
);
assert_eq!(
candidate_app_ids_for_container("mempool"),
vec!["archy-mempool-web", "mempool"]
);
assert_eq!(
candidate_app_ids_for_container("archy-mempool-db"),
vec!["archy-mempool-db", "mempool-db"]
);
}
#[test]
fn update_aliases_map_to_manifest_app_ids() {
assert_eq!(orchestrator_update_app_id("bitcoin-knots"), "bitcoin-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");
}
}
+1 -2
View File
@@ -147,8 +147,7 @@ impl RpcHandler {
.get("onion")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing onion"))?;
let fips_npub =
crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
let reachable = node_message::check_peer_reachable(onion, fips_npub.as_deref())
.await
.unwrap_or(false);

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